From 146ba4d2ffb0ded4119af34736433a0eb89597f9 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Wed, 23 Sep 2026 22:04:48 -0400 Subject: [PATCH] feat(desktop): upload progress on the taskbar button (cinny-desktop #10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upload atoms now also report into an app-wide `uploadProgressAtom`, and `aggregateUploadProgress` turns every in-flight upload into one taskbar state: byte-weighted percentage while uploading, indeterminate until a size is known, red after a failure (held 4 s, then cleared), none when done. Cancelled uploads just disappear. `useTauriTaskbarProgress` sends it to the native `set_taskbar_progress`, at most ~4 times a second, always ending on the latest state. Verified with a throttled 6 MB upload: 0% → 99% at 250 ms steps, then cleared; with the upload request aborted: 0% → error → cleared after 4 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/components/TauriDesktopFeatures.tsx | 2 + src/app/hooks/useTauriTaskbarProgress.ts | 62 +++++++++++++++++++ src/app/state/upload.ts | 22 +++++++ src/app/state/uploadProgress.test.ts | 56 +++++++++++++++++ src/app/state/uploadProgress.ts | 67 +++++++++++++++++++++ 5 files changed, 209 insertions(+) create mode 100644 src/app/hooks/useTauriTaskbarProgress.ts create mode 100644 src/app/state/uploadProgress.test.ts create mode 100644 src/app/state/uploadProgress.ts diff --git a/src/app/components/TauriDesktopFeatures.tsx b/src/app/components/TauriDesktopFeatures.tsx index be462fbb9..328053ed7 100644 --- a/src/app/components/TauriDesktopFeatures.tsx +++ b/src/app/components/TauriDesktopFeatures.tsx @@ -1,6 +1,7 @@ import { useTauriCallPower } from '../hooks/useTauriCallPower'; import { useTauriJumpList } from '../hooks/useTauriJumpList'; import { useTauriThumbbar } from '../hooks/useTauriThumbbar'; +import { useTauriTaskbarProgress } from '../hooks/useTauriTaskbarProgress'; import { useTauriSmtc } from '../hooks/useTauriSmtc'; import { useTauriNetwork } from '../hooks/useTauriNetwork'; import { useTauriToastActions } from '../hooks/useTauriToastActions'; @@ -18,6 +19,7 @@ export function TauriDesktopFeatures(): null { useTauriCallPower(); // P5-46 no-sleep during calls useTauriJumpList(); // P5-36 Windows jump list of recent rooms useTauriThumbbar(); // P5-44 taskbar thumbnail toolbar (mute/deafen/end) + useTauriTaskbarProgress(); // cinny-desktop #10 upload progress on the taskbar button useTauriSmtc(); // P5-43 system media transport controls useTauriNetwork(); // P5-49 network-change awareness → sync retry useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send diff --git a/src/app/hooks/useTauriTaskbarProgress.ts b/src/app/hooks/useTauriTaskbarProgress.ts new file mode 100644 index 000000000..2f9581994 --- /dev/null +++ b/src/app/hooks/useTauriTaskbarProgress.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef } from 'react'; +import { useAtom } from 'jotai'; +import { aggregateUploadProgress, uploadProgressAtom } from '../state/uploadProgress'; +import { invokeTauri, isTauri } from './useTauri'; + +/** Native updates at most ~4 per second. */ +const MIN_INTERVAL_MS = 250; +/** How long a failed upload keeps the bar red before it clears. */ +const ERROR_HOLD_MS = 4_000; + +/** + * [cinny-desktop #10] One taskbar progress bar for all uploads: the combined + * percentage while files upload, indeterminate until a size is known, red for a + * few seconds after a failure, cleared when done. No-op in the browser. + */ +export function useTauriTaskbarProgress(): void { + const [entries, setEntries] = useAtom(uploadProgressAtom); + const lastSent = useRef(''); + const lastAt = useRef(0); + const pending = useRef(undefined); + // The (possibly delayed) send must use the latest uploads, not the ones from + // when it was scheduled — otherwise the final "done" could be dropped. + const latest = useRef(entries); + latest.current = entries; + + useEffect(() => { + if (!isTauri()) return undefined; + const send = () => { + pending.current = undefined; + const state = aggregateUploadProgress(latest.current.values(), Date.now()); + const key = JSON.stringify(state); + if (key === lastSent.current) return; + lastSent.current = key; + lastAt.current = Date.now(); + invokeTauri('set_taskbar_progress', { + status: state.status, + progress: 'progress' in state ? state.progress : null, + }); + }; + const wait = MIN_INTERVAL_MS - (Date.now() - lastAt.current); + if (wait <= 0) send(); + else if (pending.current === undefined) pending.current = window.setTimeout(send, wait); + return undefined; + }, [entries]); + + // Drop failed entries after a moment so the red bar doesn't stick. + useEffect(() => { + const failed = Array.from(entries.values()).some((e) => e.failed); + if (!failed) return undefined; + const timer = window.setTimeout(() => { + setEntries((prev) => new Map(Array.from(prev).filter(([, e]) => !e.failed))); + }, ERROR_HOLD_MS); + return () => window.clearTimeout(timer); + }, [entries, setEntries]); + + useEffect( + () => () => { + if (pending.current !== undefined) window.clearTimeout(pending.current); + }, + [], + ); +} diff --git a/src/app/state/upload.ts b/src/app/state/upload.ts index 8ecee4cbf..c344d116b 100644 --- a/src/app/state/upload.ts +++ b/src/app/state/upload.ts @@ -4,6 +4,7 @@ import { MatrixClient, UploadResponse, UploadProgress, MatrixError } from 'matri import { useCallback, useRef } from 'react'; import { useThrottle } from '../hooks/useThrottle'; import { uploadContent, TUploadContent } from '../utils/matrix'; +import { uploadProgressAtom } from './uploadProgress'; export enum UploadStatus { Idle = 'idle', @@ -61,7 +62,17 @@ export const createUploadAtom = (file: TUploadContent) => { (get) => get(baseUploadAtom), (get, set, update) => { const uploadState = get(baseUploadAtom); + // [cinny-desktop #10] Mirror into the app-wide progress map for the + // desktop taskbar bar. Cancelled uploads just disappear (no red bar). + const track = (entry: { loaded: number; total: number; failed: boolean } | undefined) => + set(uploadProgressAtom, (prev) => { + const next = new Map(prev); + if (entry) next.set(file, { ...entry, updatedAt: Date.now() }); + else next.delete(file); + return next; + }); if ('promise' in update) { + track({ loaded: 0, total: file.size, failed: false }); set(baseUploadAtom, { status: UploadStatus.Loading, file, @@ -71,6 +82,11 @@ export const createUploadAtom = (file: TUploadContent) => { return; } if ('progress' in update && uploadState.status === UploadStatus.Loading) { + track({ + loaded: update.progress.loaded, + total: update.progress.total || file.size, + failed: false, + }); set(baseUploadAtom, { ...uploadState, progress: update.progress, @@ -78,6 +94,7 @@ export const createUploadAtom = (file: TUploadContent) => { return; } if ('mxc' in update) { + track(undefined); set(baseUploadAtom, { status: UploadStatus.Success, file, @@ -86,6 +103,11 @@ export const createUploadAtom = (file: TUploadContent) => { return; } if ('error' in update) { + const cancelled = + update.error?.name === 'AbortError' || /abort/i.test(update.error?.message ?? ''); + const loaded = + uploadState.status === UploadStatus.Loading ? uploadState.progress.loaded : 0; + track(cancelled ? undefined : { loaded, total: file.size, failed: true }); set(baseUploadAtom, { status: UploadStatus.Error, file, diff --git a/src/app/state/uploadProgress.test.ts b/src/app/state/uploadProgress.test.ts new file mode 100644 index 000000000..1a1f316ed --- /dev/null +++ b/src/app/state/uploadProgress.test.ts @@ -0,0 +1,56 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { aggregateUploadProgress, UPLOAD_STALE_MS, UploadProgressEntry } from './uploadProgress'; + +const NOW = 1_000_000; +const e = (loaded: number, total: number, failed = false, age = 0): UploadProgressEntry => ({ + loaded, + total, + failed, + updatedAt: NOW - age, +}); + +test('nothing uploading → no bar', () => { + assert.deepEqual(aggregateUploadProgress([], NOW), { status: 'none' }); +}); + +test('byte-weighted percentage across uploads', () => { + // 10 of 100 + 90 of 100 = 100 of 200 → 50% + assert.deepEqual(aggregateUploadProgress([e(10, 100), e(90, 100)], NOW), { + status: 'normal', + progress: 50, + }); + // A big file dominates: 0 of 900 + 100 of 100 → 10% + assert.deepEqual(aggregateUploadProgress([e(0, 900), e(100, 100)], NOW), { + status: 'normal', + progress: 10, + }); +}); + +test('unknown size → indeterminate', () => { + assert.deepEqual(aggregateUploadProgress([e(0, 0)], NOW), { status: 'indeterminate' }); +}); + +test('a failure shows red only once nothing else is uploading', () => { + assert.deepEqual(aggregateUploadProgress([e(40, 100, true)], NOW), { + status: 'error', + progress: 40, + }); + assert.deepEqual(aggregateUploadProgress([e(40, 100, true), e(30, 100)], NOW), { + status: 'normal', + progress: 30, + }); +}); + +test('stale entries are ignored', () => { + assert.deepEqual(aggregateUploadProgress([e(10, 100, false, UPLOAD_STALE_MS + 1)], NOW), { + status: 'none', + }); +}); + +test('loaded never exceeds 100%', () => { + assert.deepEqual(aggregateUploadProgress([e(150, 100)], NOW), { + status: 'normal', + progress: 100, + }); +}); diff --git a/src/app/state/uploadProgress.ts b/src/app/state/uploadProgress.ts new file mode 100644 index 000000000..b08b1dcdd --- /dev/null +++ b/src/app/state/uploadProgress.ts @@ -0,0 +1,67 @@ +import { atom } from 'jotai'; +import type { TUploadContent } from '../utils/matrix'; + +/** + * [cinny-desktop #10] Every in-flight upload across all rooms, so the desktop + * taskbar can show one combined progress bar. Written by `createUploadAtom` + * (state/upload.ts) as its uploads start, progress, finish or fail. + */ +export type UploadProgressEntry = { + loaded: number; + total: number; + failed: boolean; + /** ms timestamp of the last change; stale entries are ignored. */ + updatedAt: number; +}; + +export const uploadProgressAtom = atom(new Map()); + +export type TaskbarProgress = + | { status: 'none' } + | { status: 'indeterminate' } + | { status: 'normal'; progress: number } + | { status: 'error'; progress: number }; + +/** An upload with no update for this long is treated as abandoned. */ +export const UPLOAD_STALE_MS = 2 * 60_000; + +/** + * Combine every upload into one taskbar state: the byte-weighted percentage + * while anything is uploading (indeterminate while no size is known yet), red + * when an upload failed and nothing else is running, nothing otherwise. + */ +export const aggregateUploadProgress = ( + entries: Iterable, + now: number, +): TaskbarProgress => { + let loaded = 0; + let total = 0; + let active = 0; + let failed = 0; + let failedLoaded = 0; + let failedTotal = 0; + Array.from(entries).forEach((e) => { + if (now - e.updatedAt > UPLOAD_STALE_MS) return; + if (e.failed) { + failed += 1; + failedLoaded += e.loaded; + failedTotal += e.total; + return; + } + active += 1; + loaded += Math.min(e.loaded, e.total); + total += e.total; + }); + if (active > 0) { + if (total <= 0) return { status: 'indeterminate' }; + return { status: 'normal', progress: Math.min(100, Math.floor((loaded / total) * 100)) }; + } + if (failed > 0) { + return { + status: 'error', + progress: + failedTotal > 0 ? Math.min(100, Math.floor((failedLoaded / failedTotal) * 100)) : 100, + }; + } + return { status: 'none' }; +};