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
@@ -2,10 +2,10 @@
iOS before Safari 18.4, which cannot position them. --}}
<x-page brand>
{{-- Each state is one card under the page's h1: the card holds everything the recipient acts
on, and it is the shape SealShare has always shown them. --}}
{{-- Under the page's h1, what the recipient acts on is in cards: the password, or the share's
private text and its files, each in its own card, with the share's expiry and limit under them. --}}
@if (! $authenticated)
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to access these files')" heading="h2" variant="outlined">
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to open this share')" heading="h2" variant="outlined">
<x-form wire:submit="verifyPassword">
<x-password
full
@@ -22,32 +22,100 @@
</x-form>
</x-card>
@else
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
{{-- A download link does not render the page again: the download limit's note switches
on the first press here, and the server draws the open window on the next visit. --}}
<x-stack gap="space200" x-data="{ downloaded: false }">
<x-stack gap="space100">
<x-list :label="__('Shared Files')">
@foreach ($share->files as $file)
<x-list-item
:title="$file->relative_path ?: $file->original_name"
icon="description"
wire:key="file-{{ $file->id }}"
>
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
<x-slot:end>
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" x-on:click="downloaded = true" />
</x-slot:end>
</x-list-item>
@endforeach
</x-list>
{{-- A download link does not render the page again: the download limit's note switches on
the first press of any download or "Show text", and the server draws the open window on
the next visit. --}}
<x-stack gap="space200" x-data="{ downloaded: false }">
{{-- The text is fetched only when the recipient asks for it, never with the page: a link
preview must not use up a share limited to one download. It stays in Alpine and is
drawn as plain text. --}}
@if ($hasText)
<x-card :title="__('Private text')" heading="h2" variant="outlined">
<x-stack
gap="space200"
x-data="{
text: null,
async reveal() {
const text = await $wire.revealText();
if (text === null) {
window.materialToast({{ \Illuminate\Support\Js::from(__('This text is no longer available.')) }}, { type: 'error' });
return;
}
this.text = text;
downloaded = true;
},
}"
>
<template x-if="text !== null">
<x-stack gap="space200">
<div class="share-text md-type-body-lg" x-text="text" data-test="shared-text"></div>
<x-row justify="end">
<x-button
:label="__('Copy')"
icon="content_copy"
variant="tonal"
x-on:click="navigator.clipboard.writeText(text).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
data-test="copy-text"
/>
</x-row>
</x-stack>
</template>
<x-row justify="end" x-show="text === null">
<x-button :label="__('Show text')" icon="visibility" variant="filled" x-on:click="reveal()" spinner="revealText" data-test="show-text" />
</x-row>
</x-stack>
</x-card>
@endif
@if ($files->isNotEmpty())
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-list :label="__('Shared Files')">
@foreach ($files as $file)
<x-list-item
:title="$file->relative_path ?: $file->original_name"
icon="description"
wire:key="file-{{ $file->id }}"
>
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
<x-slot:end>
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" x-on:click="downloaded = true" />
</x-slot:end>
</x-list-item>
@endforeach
</x-list>
<x-row justify="end">
@if ($files->count() > 1)
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate x-on:click="downloaded = true" />
@else
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $files->first()])" no-wire-navigate x-on:click="downloaded = true" />
@endif
</x-row>
</x-stack>
</x-card>
@endif
@if ($share->expires_at || $share->max_downloads)
<x-stack gap="space100">
@if ($share->expires_at)
<p class="md-type-body-sm md-ink-variant">{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}</p>
@endif
@if ($share->max_downloads)
@if ($downloadWindowEndsAt)
@if ($hasText)
@if ($downloadWindowEndsAt)
<p class="md-type-body-sm md-ink-variant">{{ __('You can open this share for another :time.', ['time' => $downloadWindowEndsAt->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE)]) }}</p>
@elseif ($remainingDownloads > 0)
<p class="md-type-body-sm md-ink-variant" x-show="! downloaded">{{ trans_choice('{1} Showing the text or downloading uses the last remaining download. You then have :window to open this share.|[2,*] Showing the text or downloading uses 1 of :count remaining downloads. You then have :window to open this share.', $remainingDownloads, ['window' => $downloadWindow]) }}</p>
<p class="md-type-body-sm md-ink-variant" x-show="downloaded" x-cloak>{{ __('You have :window to open this share.', ['window' => $downloadWindow]) }}</p>
@endif
@elseif ($downloadWindowEndsAt)
<p class="md-type-body-sm md-ink-variant">{{ __('You can download these files for another :time.', ['time' => $downloadWindowEndsAt->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE)]) }}</p>
@elseif ($remainingDownloads > 0)
<p class="md-type-body-sm md-ink-variant" x-show="! downloaded">{{ trans_choice('{1} Downloading uses the last remaining download. You then have :window to download the files.|[2,*] Downloading uses 1 of :count remaining downloads. You then have :window to download the files.', $remainingDownloads, ['window' => $downloadWindow]) }}</p>
@@ -55,15 +123,7 @@
@endif
@endif
</x-stack>
<x-row justify="end">
@if ($share->files->count() > 1)
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate x-on:click="downloaded = true" />
@else
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate x-on:click="downloaded = true" />
@endif
</x-row>
</x-stack>
</x-card>
@endif
</x-stack>
@endif
</x-page>