fix(web): boot rehydrate normalizes transfers; paused has its own visual
This commit is contained in:
+13
-1
@@ -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' && (
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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` —
|
||||
|
||||
Reference in New Issue
Block a user