fix(web): boot rehydrate normalizes transfers; paused has its own visual

This commit is contained in:
Jannis Braun
2026-05-02 19:10:29 +02:00
parent 61be9d013f
commit 33cfc66ac4
4 changed files with 138 additions and 5 deletions
+13 -1
View File
@@ -481,11 +481,23 @@ Three stores with strict separation of concerns:
### Reload Survival
- `transferStore`, `composerStore`, `pendingMessageStore` all use Zustand `persist` against versioned `localStorage` keys (`transferStore@v1`, `composerStore@v1`, `pendingMessageStore@v1`).
- File handles (Chrome/Edge picker + drag-drop only) are persisted to IndexedDB via `idbHandles.ts` keyed by `transfer.fileHandleId`. Permission is re-prompted on resume.
- File handles (Chrome/Edge picker + drag-drop only) are persisted to IndexedDB via `idbHandles.ts` keyed by `transfer.fileHandleId`. Permission is re-prompted on resume only when the explicit Resume click provides a user-gesture; the boot path queries silently and never prompts.
- Bytes themselves never persist. On unsupported browsers the user re-picks (uploads) or restarts (downloads).
- TTL: pending bubbles whose `tusExpiresAt` is past are dropped on rehydrate with a one-time toast.
- All-transfers-already-complete branch: if every transferId in a rehydrated pending bubble already has an `attachmentId`, the deferred `POST /messages` fires immediately on app start.
#### Boot-time normalization
On store rehydrate (`onRehydrateStorage` in `transferStore.ts` -> `normalizeRehydratedTransfers`):
1. Any transfer left in `'active'` state (defensive — `partialize` already filters most) is demoted to `'paused'`. No live worker exists post-reload.
2. For each `'paused'` transfer, if the bytes are unrecoverable (upload with no `fileHandleId`; download with no `destFileHandleId`), the transfer is marked `'failed'` with an actionable message ("File no longer available — discard and re-upload" / "Download cannot resume — bytes lost. Restart the download."). The UI then surfaces the discard control instead of a misleading paused state.
3. For each `'paused'` transfer with a stored handle, the rehydrate path *silently* queries permission via `queryHandlePermission` (never calls `requestPermission`, so no user-gesture is required and no prompt appears). If `'granted'`, the transfer auto-resumes on the next tick. If `'prompt'` or `'denied'`, it stays paused — the user's click on Resume provides the user-gesture for `requestPermission`.
Idempotent: re-running is harmless (already-resumed transfers move to `'active'`, no-op for completed/failed).
The paused state is visually distinct in `AttachmentProgress.tsx`: desaturated grey conic-gradient ring with a centered pause-icon disk (instead of the mint ring + percentage text used while active), so the user can immediately tell "nothing is happening" from "in progress".
### Attachment Rendering (`AttachmentRenderer.tsx`)
URL resolution for attachment/thumbnail:
@@ -23,17 +23,35 @@ function fmt(bytes: number): string {
export function AttachmentProgress({ loaded, total, state, filename, error, onPause, onResume, onAbort, size = 'tile' }: Props) {
const pct = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
const bg = state === 'failed' ? 'bg-accent-rose/30' : 'bg-accent-mint/30';
const bg = state === 'failed' ? 'bg-accent-rose/30' : state === 'paused' ? 'bg-white/5' : 'bg-accent-mint/30';
const isFinal = state === 'completed' || state === 'aborted';
// Desaturate the conic-gradient ring when paused so it can't be mistaken for
// active progress. Active uses mint; paused uses a muted grey.
const ringColor = state === 'paused' ? 'rgba(180,180,190,.5)' : 'rgba(180,220,200,.85)';
const ringTrack = 'rgba(255,255,255,.15)';
const ringTitle = state === 'failed'
? error
: state === 'paused'
? `Paused — ${pct}%`
: undefined;
return (
<div className={`absolute inset-0 flex flex-col items-center justify-center gap-2 backdrop-blur-[2px] ${state === 'failed' ? 'bg-accent-rose/20' : 'bg-black/50'} pointer-events-auto`}>
<div
className={`w-9 h-9 rounded-full ${bg} flex items-center justify-center`}
style={state !== 'failed' ? { background: `conic-gradient(rgba(180,220,200,.85) ${pct}%, rgba(255,255,255,.15) ${pct}%)` } : undefined}
title={state === 'failed' ? error : undefined}
style={state !== 'failed' ? { background: `conic-gradient(${ringColor} ${pct}%, ${ringTrack} ${pct}%)` } : undefined}
title={ringTitle}
>
<div className="w-7 h-7 rounded-full bg-surface-overlay text-[10px] text-txt-primary flex items-center justify-center font-medium">
{state === 'failed' ? '!' : `${pct}%`}
{state === 'paused' ? (
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor" aria-hidden="true">
<rect x="2" y="1.5" width="2" height="7" rx="0.5" />
<rect x="6" y="1.5" width="2" height="7" rx="0.5" />
</svg>
) : state === 'failed' ? (
'!'
) : (
`${pct}%`
)}
</div>
</div>
{size === 'tile' && (
+86
View File
@@ -688,6 +688,92 @@ export const useTransferStore = create<TransferStore>()(
)
),
}),
onRehydrateStorage: () => (state, error) => {
if (error || !state) return;
// Normalize on the next tick so the store is fully wired before we mutate
// it, and so async work (handle probe + auto-resume) doesn't block the
// rehydrate path.
queueMicrotask(() => {
void normalizeRehydratedTransfers();
});
},
},
),
);
/**
* Boot-time normalization of rehydrated transfers.
*
* 1. Demotes any leaked 'active' state to 'paused' (defensive — partialize already
* filters most, but a transfer mid-progress can still slip through if it had a
* tusUploadUrl).
* 2. Marks bytes-unrecoverable paused transfers as 'failed' with an actionable
* message — we have no in-memory File post-reload and no FS handle to reacquire
* bytes from, so showing a paused state would be misleading.
* 3. For paused transfers with a stored handle whose permission is already
* 'granted', silently auto-resumes. For 'prompt' / 'denied', leaves paused so
* the user's explicit Resume click provides the user-gesture for
* `requestPermission`.
*
* Idempotent — re-running is harmless.
*/
async function normalizeRehydratedTransfers(): Promise<void> {
const store = useTransferStore.getState();
const transfers = Array.from(store.transfers.values());
for (const t of transfers) {
// 1) Demote any leaked 'active' state — no live worker exists post-reload.
if (t.state === 'active') {
store.setState_(t.id, 'paused');
}
const current = useTransferStore.getState().get(t.id);
if (!current) continue;
// 2) Bytes-unrecoverable paused transfers → immediately fail.
if (current.state === 'paused') {
const isUpload = current.type === 'upload';
const handleId = isUpload ? current.fileHandleId : current.destFileHandleId;
if (!handleId) {
store.setError(current.id, {
message: isUpload
? 'File no longer available — discard and re-upload'
: 'Download cannot resume — bytes lost. Restart the download.',
permanent: true,
});
continue;
}
// 3) Auto-resume when permission is already 'granted'.
try {
const { getHandle, queryHandlePermission } = await import('../utils/idbHandles');
const handle = await getHandle(handleId);
if (!handle) {
store.setError(current.id, {
message: isUpload
? 'File handle missing — discard and re-upload'
: 'Destination handle missing — restart the download',
permanent: true,
});
continue;
}
const mode: 'read' | 'readwrite' = isUpload ? 'read' : 'readwrite';
const perm = await queryHandlePermission(handle, mode);
if (perm === 'granted') {
// Use the live store reference so subsequent state changes are visible
// to subscribers.
if (isUpload) {
void useTransferStore.getState().resumeUpload(current.id);
} else {
void useTransferStore.getState().resumeDownload(current.id);
}
}
// perm === 'prompt' or 'denied' → user must click Resume to trigger
// requestPermission with a user-gesture.
} catch {
// Probe failed (IDB unavailable, etc.) — leave paused. User can click
// Resume to retry through the normal path.
}
}
}
}
+17
View File
@@ -54,6 +54,23 @@ export function supportsDnDHandles(): boolean {
&& typeof (DataTransferItem.prototype as unknown as { getAsFileSystemHandle?: unknown }).getAsFileSystemHandle === 'function';
}
/**
* Silently query the current permission state on a stored handle.
* Never prompts; returns whatever the browser reports right now. Used by the
* boot-time rehydrate path so we can auto-resume only when permission is
* already 'granted' and avoid a "user gesture required" failure for 'prompt'.
*/
export async function queryHandlePermission(
handle: FileSystemHandle,
mode: 'read' | 'readwrite',
): Promise<PermissionState> {
const handleAny = handle as unknown as {
queryPermission?: (opts: { mode: string }) => Promise<PermissionState>;
};
if (typeof handleAny.queryPermission !== 'function') return 'denied';
return handleAny.queryPermission({ mode });
}
/**
* Re-prompt for permission on a stored handle. Returns 'granted', 'denied', or 'prompt'.
* Some non-standard FS Access surfaces don't expose `queryPermission`/`requestPermission` —