Release 2.3.0
linter / quality (push) Successful in 1m5s
tests / ci (8.5) (push) Successful in 3m24s
docker / build-and-push (push) Successful in 7m10s
docker / test (8.5) (push) Successful in 3m21s
docker / release (push) Successful in 4s

A share can now hold a private text (a password, a key, a short note)
with its files or on its own. The upload page's new "Private text" card
takes up to 100 KB; the browser encrypts the text and sends it through
the same chunk pipeline as a file, flagged is_text on share_files, so it
gets the share's password, expiry, download limit and cleanup.

The recipient sees the text only after pressing "Show text", which
counts as their download, so a messenger link preview cannot use up a
share limited to one download. The text is left out of the file list,
the ZIP and the file counts; the admin dashboard marks shares that hold
one with "Text".

The website gains a Private text feature card and a fifth phone
screenshot; every screenshot is retaken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-26 07:00:17 +02:00
co-authored by Claude Opus 5.5
parent 202813a1e6
commit f9a7839ad3
73 changed files with 806 additions and 134 deletions
+50 -1
View File
@@ -10,6 +10,10 @@
* A failed request is retried after 1, 2, 4, 8 and 16 seconds; after that the file waits for its
* Retry button, which picks up from the chunk the server last confirmed. The server answers 409
* with its own count when a chunk skips ahead, and acknowledges a chunk it already has.
*
* The private text stays in this component (Alpine only, never a Livewire property) until the share
* is created: `submit()` registers its size, sends it through the same queue as a file named
* text.txt, and only then asks the component to create the share.
*/
const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000]
@@ -33,6 +37,14 @@ document.addEventListener('alpine:init', () => {
/** The request on its way, so Cancel and Remove can abort it. */
request: null,
/** The private text, as typed. */
text: '',
/** The private text's size as the server counts it, in UTF-8 bytes. */
get textBytes() {
return new TextEncoder().encode(this.text).length
},
get progress() {
const unfinished = Object.values(this.uploads).filter((upload) => upload.state !== 'failed')
const size = unfinished.reduce((total, upload) => total + upload.size, 0)
@@ -223,6 +235,43 @@ document.addEventListener('alpine:init', () => {
})
},
/**
* Send the private text, if there is one, then create the share. The text replaces any the
* server kept from an earlier attempt, so what is shared is what the field holds now.
*/
async submit() {
if (this.busy) {
return
}
const text = this.text.trim() === '' ? '' : this.text
const target = await this.$wire.registerText(text === '' ? 0 : this.textBytes)
if (text !== '') {
if (! target) {
return
}
const item = { id: target.id, file: new File([text], 'text.txt', { type: 'text/plain' }), target, nextIndex: 0 }
this.uploads[target.id] = { state: 'queued', sent: 0, size: item.file.size }
this.queue.push(item)
await this.run()
const sent = this.uploads[target.id]?.state === 'uploaded'
this.forget([target.id])
if (! sent) {
window.materialToast(messages.textFailed, { type: 'error' })
return
}
}
await this.$wire.createShare()
},
retry(id) {
const item = this.failed[id]
@@ -283,7 +332,7 @@ document.addEventListener('alpine:init', () => {
},
warnBeforeLeaving(event) {
if (this.busy) {
if (this.busy || this.text !== '') {
event.preventDefault()
event.returnValue = ''
}