From d9bd50eca071eb0b6dd597f41c71dbf5a556d79f Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 13:54:36 +0100 Subject: [PATCH 01/21] Defer desktop media uploads until send Signed-off-by: kenny lopez --- desktop/src-tauri/src/commands/media.rs | 10 +- .../src/features/channels/ui/ChannelPane.tsx | 3 +- .../features/communities/useCommunityInit.ts | 2 + .../lib/backgroundMediaUploadStore.ts | 175 ++++++++++++++++++ .../features/messages/lib/useMediaUpload.ts | 172 ++++++++++++++++- .../messages/ui/ComposerAttachments.tsx | 68 ++++++- .../ui/ComposerUploadProgressPill.tsx | 85 +++++++++ .../features/messages/ui/MessageComposer.tsx | 141 +++++++------- .../messages/ui/MessageComposer.types.ts | 2 + .../features/messages/ui/NewMessageScreen.tsx | 1 + .../features/messages/ui/submitMessageEdit.ts | 100 ++++++++++ .../messages/ui/useMentionSendFlow.ts | 98 +++++++--- desktop/src/shared/api/tauri.ts | 8 +- desktop/src/testing/e2eBridge.ts | 2 + desktop/tests/e2e/file-attachment.spec.ts | 76 +++++++- desktop/tests/helpers/bridge.ts | 2 + 16 files changed, 832 insertions(+), 113 deletions(-) create mode 100644 desktop/src/features/messages/lib/backgroundMediaUploadStore.ts create mode 100644 desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx create mode 100644 desktop/src/features/messages/ui/submitMessageEdit.ts diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index ed3b340238..b3921be02d 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -573,6 +573,7 @@ async fn process_picked_path( path: std::path::PathBuf, state: &AppState, images_only: bool, + progress: Option<(tauri::AppHandle, String)>, ) -> Result { // Pin the inode by opening the fd BEFORE spawn_blocking. This prevents a // local attacker from swapping the file between dialog return and read. @@ -639,8 +640,7 @@ async fn process_picked_path( // Upload video first, then poster (best-effort). If poster upload fails, // the video descriptor is returned without an image field. - let mut descriptor = do_upload(body, &mime, state, None).await?; - + let mut descriptor = do_upload(body, &mime, state, progress).await?; if let Some(poster) = poster_bytes { match do_upload(poster, "image/jpeg", state, None).await { Ok(poster_desc) => descriptor.image = Some(poster_desc.url), @@ -675,6 +675,7 @@ async fn process_picked_path( #[tauri::command] pub async fn pick_and_upload_media( app: tauri::AppHandle, + progress_id: Option, state: State<'_, AppState>, ) -> Result, String> { use tauri_plugin_dialog::DialogExt; @@ -694,7 +695,8 @@ pub async fn pick_and_upload_media( let mut descriptors = Vec::with_capacity(file_paths.len()); for file_path in file_paths { let path = file_path.as_path().ok_or("invalid path")?.to_path_buf(); - let descriptor = process_picked_path(path, &state, false).await?; + let progress = progress_id.clone().map(|id| (app.clone(), id)); + let descriptor = process_picked_path(path, &state, false, progress).await?; descriptors.push(descriptor); } @@ -735,7 +737,7 @@ pub async fn pick_and_upload_image( }; let path = file_path.as_path().ok_or("invalid path")?.to_path_buf(); - let descriptor = process_picked_path(path, &state, true).await?; + let descriptor = process_picked_path(path, &state, true, None).await?; Ok(Some(descriptor)) } diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 770c45e445..9489d95209 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -174,7 +174,7 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel, currentPubkey, ); - const mainComposerMedia = useMediaUpload(); + const mainComposerMedia = useMediaUpload({ deferUploadsUntilSend: true }); const isNonMemberView = activeChannel !== null && !activeChannel.isMember && @@ -792,6 +792,7 @@ export const ChannelPane = React.memo(function ChannelPane({ : "Select a channel" } showTopBorder={false} + showBackgroundUploadProgress /> {/* The activity accessory is anchored in the dock's reserved bottom rail, so fading it cannot change the observed diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index afa69f913f..1bd1e090a7 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -19,6 +19,7 @@ import { initDraftStore, } from "@/features/messages/lib/useDrafts"; import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; +import { resetBackgroundMediaUploads } from "@/features/messages/lib/backgroundMediaUploadStore"; import { resetActiveAgentTurnsStore, saveActiveAgentTurnsForCommunity, @@ -67,6 +68,7 @@ function resetCommunityState({ resetMediaCaches(); resetVideoPlayerState(); resetRenderScopedReactionHydration(); + resetBackgroundMediaUploads(); clearSearchHitEventCache(); clearMarkdownNodeCache(); } diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts new file mode 100644 index 0000000000..9add1223a3 --- /dev/null +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts @@ -0,0 +1,175 @@ +import * as React from "react"; + +import { type BlobDescriptor, uploadMediaBytes } from "@/shared/api/tauri"; + +export type QueuedMediaAttachment = { + file: File; + id: number; + previewUrl?: string; +}; + +type BackgroundUploadTask = { + canceled: boolean; + fileProgress: number[]; + id: number; +}; + +type BackgroundUploadSnapshot = { + isUploading: boolean; + percentage: number; +}; + +type EnqueueBackgroundUploadOptions = { + attachments: QueuedMediaAttachment[]; + onComplete: (descriptors: BlobDescriptor[]) => Promise; + onError: (error: unknown) => void; +}; + +const tasks = new Map(); +const listeners = new Set<() => void>(); +let nextTaskId = 0; +let snapshot: BackgroundUploadSnapshot = { + isUploading: false, + percentage: 0, +}; +let stopProgressListener: (() => void) | null = null; +let progressListenerPromise: Promise | null = null; + +function progressId(taskId: number, fileIndex: number): string { + return `background-media-upload-${taskId}-${fileIndex}`; +} + +function rebuildSnapshot(): void { + const allProgress = [...tasks.values()].flatMap((task) => task.fileProgress); + snapshot = { + isUploading: allProgress.length > 0, + percentage: + allProgress.length === 0 + ? 0 + : Math.round( + allProgress.reduce((total, progress) => total + progress, 0) / + allProgress.length, + ), + }; + for (const listener of listeners) listener(); +} + +async function ensureProgressListener(): Promise { + if (stopProgressListener || progressListenerPromise) return; + progressListenerPromise = (async () => { + try { + const { listen } = await import("@tauri-apps/api/event"); + const dispose = await listen<{ + id: string; + sent: number; + total: number; + }>("media-upload-progress", (event) => { + const match = /^background-media-upload-(\d+)-(\d+)$/.exec( + event.payload.id, + ); + if (!match || event.payload.total <= 0) return; + + const task = tasks.get(Number(match[1])); + const fileIndex = Number(match[2]); + if (!task || fileIndex >= task.fileProgress.length) return; + + task.fileProgress[fileIndex] = Math.min( + 100, + Math.max( + 0, + Math.round((event.payload.sent / event.payload.total) * 100), + ), + ); + rebuildSnapshot(); + }); + if (tasks.size === 0) dispose(); + else stopProgressListener = dispose; + } catch { + // Browser and E2E runtimes do not emit native byte-level progress. + } finally { + progressListenerPromise = null; + } + })(); + await progressListenerPromise; +} + +function finishTask(taskId: number): void { + tasks.delete(taskId); + rebuildSnapshot(); + if (tasks.size === 0 && stopProgressListener) { + stopProgressListener(); + stopProgressListener = null; + } +} + +export function enqueueBackgroundMediaUpload({ + attachments, + onComplete, + onError, +}: EnqueueBackgroundUploadOptions): void { + if (attachments.length === 0) { + void onComplete([]).catch(onError); + return; + } + + const taskId = nextTaskId; + nextTaskId += 1; + const task: BackgroundUploadTask = { + canceled: false, + fileProgress: attachments.map(() => 0), + id: taskId, + }; + tasks.set(taskId, task); + rebuildSnapshot(); + void ensureProgressListener(); + + void (async () => { + try { + const descriptors: BlobDescriptor[] = []; + for (let index = 0; index < attachments.length; index += 1) { + if (task.canceled) return; + const attachment = attachments[index]; + const buffer = await attachment.file.arrayBuffer(); + if (task.canceled) return; + const descriptor = await uploadMediaBytes( + [...new Uint8Array(buffer)], + attachment.file.name, + progressId(taskId, index), + ); + if (task.canceled) return; + task.fileProgress[index] = 100; + rebuildSnapshot(); + descriptors.push(descriptor); + } + + if (!task.canceled) await onComplete(descriptors); + } catch (error) { + if (!task.canceled) onError(error); + } finally { + finishTask(taskId); + } + })(); +} + +export function cancelBackgroundMediaUploads(): void { + for (const task of tasks.values()) task.canceled = true; + tasks.clear(); + rebuildSnapshot(); + stopProgressListener?.(); + stopProgressListener = null; +} + +export const resetBackgroundMediaUploads = cancelBackgroundMediaUploads; + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getSnapshot(): BackgroundUploadSnapshot { + return snapshot; +} + +export function useBackgroundMediaUpload(): BackgroundUploadSnapshot { + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index b627633be2..f14d153d4d 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -5,6 +5,7 @@ import { pickAndUploadMedia, uploadMediaBytes, } from "@/shared/api/tauri"; +import type { QueuedMediaAttachment } from "./backgroundMediaUploadStore"; /** * First 4 hex chars of the sha256 — used as a short display name. @@ -133,7 +134,22 @@ async function captureVideoPosterFrame( } } -export function useMediaUpload() { +type UseMediaUploadOptions = { + /** Keep newly selected files local until the message is submitted. */ + deferUploadsUntilSend?: boolean; +}; + +export function useMediaUpload({ + deferUploadsUntilSend = false, +}: UseMediaUploadOptions = {}) { + const e2eConfig = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { deferredComposerUploads?: boolean } }; + } + ).__BUZZ_E2E__; + const queueUntilSend = + deferUploadsUntilSend && + (!e2eConfig || e2eConfig.mock?.deferredComposerUploads === true); const [uploadState, setUploadState] = React.useState({ status: "idle", }); @@ -144,6 +160,11 @@ export function useMediaUpload() { >([]); const uploadingPreviewsRef = React.useRef(uploadingPreviews); uploadingPreviewsRef.current = uploadingPreviews; + const [queuedAttachments, setQueuedAttachmentsState] = React.useState< + QueuedMediaAttachment[] + >([]); + const queuedAttachmentsRef = React.useRef(queuedAttachments); + queuedAttachmentsRef.current = queuedAttachments; React.useEffect(() => { let unlisten: (() => void) | null = null; let cancelled = false; @@ -248,6 +269,83 @@ export function useMediaUpload() { * before React flushes the state update. */ const nextSlotRef = React.useRef(0); const nextUploadingPreviewIdRef = React.useRef(0); + const nextQueuedAttachmentIdRef = React.useRef(0); + + const updateQueuedVideoPoster = React.useCallback( + (id: number, posterUrl: string) => { + setQueuedAttachmentsState((current) => + current.map((attachment) => + attachment.id === id + ? { ...attachment, previewUrl: posterUrl } + : attachment, + ), + ); + }, + [], + ); + + const queueFiles = React.useCallback( + (files: File[]) => { + if (files.length === 0) return; + + const attachments = files.map((file) => { + const id = nextQueuedAttachmentIdRef.current; + nextQueuedAttachmentIdRef.current += 1; + const previewUrl = file.type.startsWith("image/") + ? URL.createObjectURL(file) + : undefined; + if (file.type.startsWith("video/")) { + void captureVideoPosterFrame(file).then((poster) => { + if (poster) updateQueuedVideoPoster(id, poster.posterUrl); + }); + } + return { file, id, previewUrl }; + }); + + setQueuedAttachmentsState((current) => [...current, ...attachments]); + }, + [updateQueuedVideoPoster], + ); + + const removeQueuedAttachment = React.useCallback((id: number) => { + setQueuedAttachmentsState((current) => { + const removed = current.find((attachment) => attachment.id === id); + if (removed?.previewUrl?.startsWith("blob:")) { + URL.revokeObjectURL(removed.previewUrl); + } + return current.filter((attachment) => attachment.id !== id); + }); + }, []); + + const clearQueuedAttachments = React.useCallback(() => { + setQueuedAttachmentsState((current) => { + for (const attachment of current) { + if (attachment.previewUrl?.startsWith("blob:")) { + URL.revokeObjectURL(attachment.previewUrl); + } + } + return []; + }); + }, []); + + const restoreQueuedAttachments = React.useCallback( + (attachments: QueuedMediaAttachment[]) => { + clearQueuedAttachments(); + queueFiles(attachments.map((attachment) => attachment.file)); + }, + [clearQueuedAttachments, queueFiles], + ); + + React.useEffect( + () => () => { + for (const attachment of queuedAttachmentsRef.current) { + if (attachment.previewUrl?.startsWith("blob:")) { + URL.revokeObjectURL(attachment.previewUrl); + } + } + }, + [], + ); const isUploadCanceled = React.useCallback( (previewId?: number) => @@ -367,6 +465,19 @@ export function useMediaUpload() { ); const handlePaperclip = React.useCallback(async () => { + if (queueUntilSend) { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.addEventListener( + "change", + () => queueFiles(Array.from(input.files ?? [])), + { once: true }, + ); + input.click(); + return; + } + // Hold a single pending tick while the native picker is open + uploads // run in Rust. We don't know the file count until the dialog returns, // and uploads are already complete by then, so we just append each @@ -374,7 +485,7 @@ export function useMediaUpload() { const previewId = reserveUploadingPreview(); setUploadingCount((c) => c + 1); try { - const descriptors = await pickAndUploadMedia(); + const descriptors = await pickAndUploadMedia(uploadProgressId(previewId)); if (isUploadCanceled(previewId)) return; finishUpload(previewId); for (const descriptor of descriptors) { @@ -385,7 +496,14 @@ export function useMediaUpload() { if (isUploadCanceled(previewId)) return; onUploadError(err, previewId); } - }, [finishUpload, isUploadCanceled, onUploadError, reserveUploadingPreview]); + }, [ + queueUntilSend, + finishUpload, + isUploadCanceled, + onUploadError, + queueFiles, + reserveUploadingPreview, + ]); const handleDrop = React.useCallback( async (event: React.DragEvent) => { @@ -399,6 +517,11 @@ export function useMediaUpload() { // (active-content + executables) and size caps; everything else uploads. const validFiles = files; + if (queueUntilSend) { + queueFiles(validFiles); + return; + } + setUploadingCount((c) => c + validFiles.length); const baseIndex = reserveSlots(validFiles.length); @@ -425,9 +548,11 @@ export function useMediaUpload() { }, [ reserveSlots, + queueUntilSend, fillSlot, isUploadCanceled, onUploadError, + queueFiles, reserveUploadingPreview, ], ); @@ -497,6 +622,11 @@ export function useMediaUpload() { event.preventDefault(); + if (queueUntilSend) { + queueFiles(mediaFiles); + return; + } + setUploadingCount((c) => c + mediaFiles.length); const baseIndex = reserveSlots(mediaFiles.length); @@ -522,9 +652,11 @@ export function useMediaUpload() { }, [ reserveSlots, + queueUntilSend, fillSlot, isUploadCanceled, onUploadError, + queueFiles, reserveUploadingPreview, ], ); @@ -532,6 +664,10 @@ export function useMediaUpload() { /** Upload a File directly — used by Tiptap's editorProps.handlePaste. */ const uploadFile = React.useCallback( async (file: File) => { + if (queueUntilSend) { + queueFiles([file]); + return; + } const previewId = reserveUploadingPreview(file); setUploadingCount((c) => c + 1); try { @@ -547,7 +683,14 @@ export function useMediaUpload() { onUploadError(err, previewId); } }, - [isUploadCanceled, onUploaded, onUploadError, reserveUploadingPreview], + [ + queueUntilSend, + isUploadCanceled, + onUploaded, + onUploadError, + queueFiles, + reserveUploadingPreview, + ], ); /** @@ -642,10 +785,21 @@ export function useMediaUpload() { ); const isUploading = uploadingCount > 0; + const queuedPreviews = React.useMemo( + () => + queuedAttachments.map((attachment) => ({ + filename: attachment.file.name, + id: attachment.id, + posterUrl: attachment.previewUrl, + type: attachment.file.type, + })), + [queuedAttachments], + ); return React.useMemo( () => ({ cancelUpload, + clearQueuedAttachments, handleDragEnter, handleDragLeave, handleDragOver, @@ -657,7 +811,12 @@ export function useMediaUpload() { originalUrlByUrl, pendingImeta, pendingImetaRef, + queuedAttachments, + queuedAttachmentsRef, + queuedPreviews, removeAttachment, + removeQueuedAttachment, + restoreQueuedAttachments, revertAttachment, setPendingImeta, setUploadState, @@ -669,6 +828,7 @@ export function useMediaUpload() { }), [ cancelUpload, + clearQueuedAttachments, handleDragEnter, handleDragLeave, handleDragOver, @@ -679,7 +839,11 @@ export function useMediaUpload() { isUploading, originalUrlByUrl, pendingImeta, + queuedAttachments, + queuedPreviews, removeAttachment, + removeQueuedAttachment, + restoreQueuedAttachments, revertAttachment, setPendingImeta, uploadEditedAttachment, diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx index bee38e321c..c27a0a2943 100644 --- a/desktop/src/features/messages/ui/ComposerAttachments.tsx +++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx @@ -61,6 +61,10 @@ type ComposerAttachmentsProps = { attachments: ImetaMedia[]; isUploading?: boolean; onCancelUpload?: (previewId: number) => void; + /** Remove a local attachment that has not started uploading yet. */ + onRemoveQueued?: (previewId: number) => void; + /** Local previews that are queued for upload when the message is sent. */ + queuedPreviews?: UploadingAttachmentPreview[]; uploadingCount?: number; uploadingPreviews?: UploadingAttachmentPreview[]; /** Upload annotated bytes as a replacement for the attachment at `url`. */ @@ -511,6 +515,8 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ uploadingCount = 0, uploadingPreviews = [], onCancelUpload, + onRemoveQueued, + queuedPreviews = [], onEditSave, onRemove, onRevert, @@ -518,7 +524,8 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ onToggleSpoiler, spoileredUrls, }: ComposerAttachmentsProps) { - if (attachments.length === 0 && !isUploading) return null; + if (attachments.length === 0 && queuedPreviews.length === 0 && !isUploading) + return null; const uploadPlaceholders: UploadingAttachmentPreview[] = uploadingPreviews.length > 0 @@ -609,6 +616,65 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ /> ); })} + {queuedPreviews.map((preview) => { + const isMedia = + preview.type?.startsWith("image/") || + preview.type?.startsWith("video/"); + return ( + + {isMedia ? ( +
+
+ {preview.posterUrl ? ( + {preview.filename + ) : ( +
+ +
+ )} +
+
+ ) : ( +
+ + + {preview.filename ?? "Attachment"} + +
+ )} + {onRemoveQueued ? ( + + + + + Remove attachment + + ) : null} +
+ ); + })} {isUploading && uploadPlaceholders.map((preview) => ( void; + percentage: number; +}) { + const reducedMotion = useReducedMotion(); + + return ( + + {isUploading ? ( + +
+ +
+ + Uploading + + {" · "} + {percentage}% + + + +
+
+
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 69e4ec67b5..e454c62be8 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -10,20 +10,20 @@ import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey"; import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete"; import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; -import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { - buildOutgoingMessage, findSpoileredImetaMediaUrls, type ImetaMedia, - mergeOutgoingTags, restoreImetaMediaDisplayLabels, stripImetaMediaLines, } from "@/features/messages/lib/imetaMediaMarkdown"; import { useAttachmentEditing } from "@/features/messages/lib/useAttachmentEditing"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; +import { + cancelBackgroundMediaUploads, + useBackgroundMediaUpload, +} from "@/features/messages/lib/backgroundMediaUploadStore"; import { useMentions } from "@/features/messages/lib/useMentions"; -import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; import { useIdentityQuery } from "@/shared/api/hooks"; import { @@ -50,11 +50,13 @@ import { type MentionSuggestion, } from "./MentionAutocomplete"; import { ComposerDockToolbar } from "./ComposerDockToolbar"; +import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { useMentionSendFlow } from "./useMentionSendFlow"; import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration"; import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; +import { submitMessageEdit } from "./submitMessageEdit"; import type { MessageComposerProps } from "./MessageComposer.types"; @@ -83,6 +85,7 @@ function MessageComposerImpl({ profiles, replyTarget = null, mediaController, + showBackgroundUploadProgress = false, showTopBorder = false, toolbarExtraActions, typingParentEventId = null, @@ -142,11 +145,12 @@ function MessageComposerImpl({ typingRootEventId, ); - // We pass a custom setter that both updates React state AND inserts - // markdown into the Tiptap editor when media upload completes. - const internalMedia = useMediaUpload(); + // New files stay local so the composer can render previews immediately; + // submitting hands them to the app-wide background upload queue. + const internalMedia = useMediaUpload({ deferUploadsUntilSend: true }); const media = mediaController ?? internalMedia; const ownsDropZone = mediaController === undefined; + const backgroundUpload = useBackgroundMediaUpload(); // Draft-persist lifecycle: restore/clear content + imeta + spoilered urls on // key change, and persist the outgoing draft in the cleanup. The StrictMode @@ -308,6 +312,8 @@ function MessageComposerImpl({ setContent: setComposerContent, setIsEmojiPickerOpen, setPendingImeta: media.setPendingImeta, + clearQueuedAttachments: media.clearQueuedAttachments, + restoreQueuedAttachments: media.restoreQueuedAttachments, setSpoileredAttachmentUrls, onSuccessfulExplicitAgentAudience: persistentAudience.enabled && audienceContext && ownerPubkey @@ -512,77 +518,52 @@ function MessageComposerImpl({ // Edit mode if (editTargetRef.current && onEditSaveRef.current) { if (isSendingRef.current || isUploadingRef.current) return; - const currentPendingImeta = media.pendingImetaRef.current; // No empty-edit guard here: clearing an edit to empty (no text, no // attachments) flows through to onEditSave as empty content, which // deletes the message instead of publishing it (see handleEditSave). - - // Build the edit's body + imeta tag set. Coerce `mediaTags ?? []` - // because edit semantics use `[]` as the explicit "wipe all - // attachments" signal — the receiver overlay drops imeta when the - // edit carries an empty (but defined) set. - const { content: finalContent, mediaTags } = buildOutgoingMessage( - trimmed, - currentPendingImeta, + await submitMessageEdit({ + content: trimmed, + customEmoji, + originalContent: editTargetRef.current.body, + ownerPubkey: ownerPubkeyRef.current, + pendingImeta: media.pendingImetaRef.current, + queuedAttachments: media.queuedAttachmentsRef.current, spoileredAttachmentUrls, - ); - - // NIP-30: attach `["emoji", shortcode, url]` tags for custom emoji in the - // edited body, exactly like the send path. Without this an edited message - // ships with no emoji tags, so the receiver can't resolve a `:shortcode:` - // and renders the literal text. `?? []` preserves edit semantics (a - // defined-but-empty media set means "wipe attachments"). - const outgoingTags = - mergeOutgoingTags( - mediaTags, - buildCustomEmojiTags(finalContent, customEmoji), - ) ?? []; - - // Notify only mentions this edit *newly adds* (see - // diffAddedMentionPubkeys): a typo-fix edit that leaves the mention set - // unchanged emits no `p` tags and re-wakes nobody. Computed before the - // composer state is cleared below. - const addedMentionPubkeys = diffAddedMentionPubkeys( - extractMentionPubkeysRef.current(editTargetRef.current.body), - extractMentionPubkeysRef.current(finalContent), - ownerPubkeyRef.current ?? "", - ); - - const savedContent = trimmed; - const savedImeta = [...currentPendingImeta]; - const savedSpoileredAttachmentUrls = new Set(spoileredAttachmentUrls); - setComposerContent(""); - richText.clearContent(); - media.setPendingImeta([]); - setSpoileredAttachmentUrls(new Set()); - mentions.clearMentions(); - channelLinks.clearChannels(); - emojiAutocomplete.clearEmojis(); - setIsEmojiPickerOpen(false); - - try { - await onEditSaveRef.current( - finalContent, - outgoingTags, - addedMentionPubkeys, - ); - } catch { - setComposerContent(savedContent); - richText.setContent(savedContent); - media.setPendingImeta(savedImeta); - setSpoileredAttachmentUrls(savedSpoileredAttachmentUrls); - } + extractMentionPubkeys: extractMentionPubkeysRef.current, + save: onEditSaveRef.current, + clearComposer: () => { + setComposerContent(""); + richText.clearContent(); + media.setPendingImeta([]); + media.clearQueuedAttachments(); + setSpoileredAttachmentUrls(new Set()); + mentions.clearMentions(); + channelLinks.clearChannels(); + emojiAutocomplete.clearEmojis(); + setIsEmojiPickerOpen(false); + }, + restoreComposer: (draft) => { + setComposerContent(draft.content); + richText.setContent(draft.content); + media.setPendingImeta(draft.pendingImeta); + media.restoreQueuedAttachments(draft.queuedAttachments); + setSpoileredAttachmentUrls(draft.spoileredAttachmentUrls); + }, + setUploadError: (message) => + media.setUploadState({ status: "error", message }), + }); return; } // Normal send const currentPendingImeta = media.pendingImetaRef.current; - const hasMedia = currentPendingImeta.length > 0; + const currentQueuedAttachments = media.queuedAttachmentsRef.current; + const hasMedia = + currentPendingImeta.length > 0 || currentQueuedAttachments.length > 0; if ( (!trimmed && !hasMedia) || disabledRef.current || isSendingRef.current || - isUploadingRef.current || mentionSendFlow.isPreparingMentionSend ) { return; @@ -603,6 +584,7 @@ function MessageComposerImpl({ capturedChannelId: channelId, capturedThreadContext, pendingImeta: currentPendingImeta, + queuedAttachments: currentQueuedAttachments, sentDraftKey: resolveSentDraftKey( effectiveDraftKeyRef.current, drafts.loadDraft, @@ -622,8 +604,12 @@ function MessageComposerImpl({ customEmoji, drafts.loadDraft, emojiAutocomplete.clearEmojis, + media.clearQueuedAttachments, media.pendingImetaRef, + media.queuedAttachmentsRef, + media.restoreQueuedAttachments, media.setPendingImeta, + media.setUploadState, mentionSendFlow.isPreparingMentionSend, mentionSendFlow.sendMessageWithMentionFlow, mentions.clearMentions, @@ -825,21 +811,23 @@ function MessageComposerImpl({ const sendDisabled = React.useMemo( () => disabled || - media.isUploading || + (editTarget !== null && media.isUploading) || mentionSendFlow.isPreparingMentionSend || - (isContentEmpty && media.pendingImeta.length === 0), + (isContentEmpty && + media.pendingImeta.length === 0 && + media.queuedAttachments.length === 0), [ disabled, + editTarget, media.isUploading, mentionSendFlow.isPreparingMentionSend, isContentEmpty, media.pendingImeta.length, + media.queuedAttachments.length, ], ); - const handleCaptureSelection = React.useCallback(() => { - // No-op for Tiptap — selection is managed by ProseMirror. - }, []); + const handleCaptureSelection = React.useCallback(() => {}, []); const handlePaperclipClick = React.useCallback(() => { void media.handlePaperclip(); @@ -897,6 +885,13 @@ function MessageComposerImpl({ onCancelEdit={onCancelEdit} onCancelReply={onCancelReply} /> + {showBackgroundUploadProgress ? ( + + ) : null}
) : null} - {(media.pendingImeta.length > 0 || media.isUploading) && ( + {(media.pendingImeta.length > 0 || + media.queuedAttachments.length > 0 || + media.isUploading) && (
diff --git a/desktop/src/features/messages/ui/submitMessageEdit.ts b/desktop/src/features/messages/ui/submitMessageEdit.ts new file mode 100644 index 0000000000..6762745461 --- /dev/null +++ b/desktop/src/features/messages/ui/submitMessageEdit.ts @@ -0,0 +1,100 @@ +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import { enqueueBackgroundMediaUpload } from "@/features/messages/lib/backgroundMediaUploadStore"; +import { + buildOutgoingMessage, + type ImetaMedia, + mergeOutgoingTags, +} from "@/features/messages/lib/imetaMediaMarkdown"; +import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; +import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; + +type EditDraft = { + content: string; + pendingImeta: ImetaMedia[]; + queuedAttachments: QueuedMediaAttachment[]; + spoileredAttachmentUrls: Set; +}; + +type SubmitMessageEditOptions = EditDraft & { + clearComposer: () => void; + customEmoji: ReadonlyArray; + extractMentionPubkeys: (content: string) => string[]; + originalContent: string; + ownerPubkey: string | null; + restoreComposer: (draft: EditDraft) => void; + save: ( + content: string, + mediaTags?: string[][], + mentionPubkeys?: string[], + ) => Promise; + setUploadError: (message: string) => void; +}; + +/** Clear an edited message immediately, then upload and save captured state. */ +export async function submitMessageEdit({ + clearComposer, + content, + customEmoji, + extractMentionPubkeys, + originalContent, + ownerPubkey, + pendingImeta, + queuedAttachments, + restoreComposer, + save, + setUploadError, + spoileredAttachmentUrls, +}: SubmitMessageEditOptions): Promise { + const draft: EditDraft = { + content, + pendingImeta: [...pendingImeta], + queuedAttachments: [...queuedAttachments], + spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), + }; + const addedMentionPubkeys = diffAddedMentionPubkeys( + extractMentionPubkeys(originalContent), + extractMentionPubkeys(content), + ownerPubkey ?? "", + ); + clearComposer(); + + const finishEdit = async (uploaded: ImetaMedia[]) => { + // An explicit empty media tag set tells edit receivers to wipe attachments. + const { content: finalContent, mediaTags } = buildOutgoingMessage( + content, + [...draft.pendingImeta, ...uploaded], + draft.spoileredAttachmentUrls, + ); + const outgoingTags = + mergeOutgoingTags( + mediaTags, + buildCustomEmojiTags(finalContent, customEmoji), + ) ?? []; + await save(finalContent, outgoingTags, addedMentionPubkeys); + }; + + if (draft.queuedAttachments.length > 0) { + enqueueBackgroundMediaUpload({ + attachments: draft.queuedAttachments, + onComplete: async (uploaded) => { + try { + await finishEdit(uploaded); + } catch { + restoreComposer(draft); + } + }, + onError: (error) => { + restoreComposer(draft); + setUploadError(String(error)); + }, + }); + return; + } + + try { + await finishEdit([]); + } catch { + restoreComposer(draft); + } +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 6d4e007cd4..345b87fc12 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -13,6 +13,10 @@ import { import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { useAddChannelMembersMutation } from "@/features/channels/hooks"; import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys"; +import { + enqueueBackgroundMediaUpload, + type QueuedMediaAttachment, +} from "@/features/messages/lib/backgroundMediaUploadStore"; import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; import { @@ -36,7 +40,7 @@ type PendingNonMemberMentionSend = { parentEventId: string | null; threadHeadId: string | null; } | null; - finalContent: string; + trimmed: string; mentionPubkeys: string[]; nonMemberPubkeys: string[]; outgoingTags?: string[][]; @@ -44,6 +48,7 @@ type PendingNonMemberMentionSend = { readyAgentPubkeys?: string[]; savedContent: string; savedImeta: ImetaMedia[]; + queuedAttachments: QueuedMediaAttachment[]; savedSpoileredAttachmentUrls: Set; sentDraftKey: string | null | undefined; audienceGeneration: number; @@ -60,6 +65,7 @@ type SendMessageWithMentionFlowInput = { threadHeadId: string | null; } | null; pendingImeta: ImetaMedia[]; + queuedAttachments?: QueuedMediaAttachment[]; sentDraftKey: string | null | undefined; spoileredAttachmentUrls?: ReadonlySet; trimmed: string; @@ -98,6 +104,8 @@ type UseMentionSendFlowOptions = { setContent: (content: string) => void; setIsEmojiPickerOpen: React.Dispatch>; setPendingImeta: (pendingImeta: ImetaMedia[]) => void; + clearQueuedAttachments: () => void; + restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; setSpoileredAttachmentUrls?: React.Dispatch< React.SetStateAction> >; @@ -161,6 +169,8 @@ export function useMentionSendFlow({ setContent, setIsEmojiPickerOpen, setPendingImeta, + clearQueuedAttachments, + restoreQueuedAttachments, setSpoileredAttachmentUrls, onSuccessfulExplicitAgentAudience, resolvePostSendContent, @@ -398,6 +408,7 @@ export function useMentionSendFlow({ mentions.cancelMentionAutocomplete(); } else richText.clearContent(); setPendingImeta([]); + clearQueuedAttachments(); setSpoileredAttachmentUrls?.(new Set()); if (!postSendContent) mentions.clearMentions(); channelLinks.clearChannels(); @@ -415,6 +426,7 @@ export function useMentionSendFlow({ setContent, setIsEmojiPickerOpen, setPendingImeta, + clearQueuedAttachments, setSpoileredAttachmentUrls, ], ); @@ -514,11 +526,37 @@ export function useMentionSendFlow({ ); } - try { - await onSendRef.current( - draft.finalContent, + const send = onSendRef.current; + const restoreComposerAfterFailure = () => { + if ( + !isMountedRef.current || + draft.capturedChannelId !== channelIdRef.current + ) { + return; + } + setContent(draft.savedContent); + contentRef.current = draft.savedContent; + richText.setContent(draft.savedContent); + setPendingImeta(draft.savedImeta); + restoreQueuedAttachments(draft.queuedAttachments); + setSpoileredAttachmentUrls?.( + new Set(draft.savedSpoileredAttachmentUrls), + ); + }; + const finishSend = async (uploaded: ImetaMedia[]) => { + const { content: finalContent, mediaTags } = buildOutgoingMessage( + draft.trimmed, + [...draft.savedImeta, ...uploaded], + draft.savedSpoileredAttachmentUrls, + ); + const finalOutgoingTags = mergeOutgoingTags( + mediaTags, + outgoingTags ?? [], + ); + await send( + finalContent, mentionPubkeys, - outgoingTags, + finalOutgoingTags, sendChannelId, draft.capturedThreadContext, ); @@ -542,17 +580,30 @@ export function useMentionSendFlow({ [...draft.savedSpoileredAttachmentUrls], ); } - } catch { - // Only restore the composer content if the user is still on the - // channel that originated the send. - if (draft.capturedChannelId === channelIdRef.current) { - setContent(draft.savedContent); - contentRef.current = draft.savedContent; - richText.setContent(draft.savedContent); - setPendingImeta(draft.savedImeta); - setSpoileredAttachmentUrls?.( - new Set(draft.savedSpoileredAttachmentUrls), - ); + }; + + if (draft.queuedAttachments.length > 0) { + enqueueBackgroundMediaUpload({ + attachments: draft.queuedAttachments, + onComplete: async (uploaded) => { + try { + await finishSend(uploaded); + } catch { + restoreComposerAfterFailure(); + } + }, + onError: (error) => { + restoreComposerAfterFailure(); + toast.error( + `Upload failed: ${getErrorMessage(error, "Unknown error")}`, + ); + }, + }); + } else { + try { + await finishSend([]); + } catch { + restoreComposerAfterFailure(); } } } finally { @@ -576,6 +627,7 @@ export function useMentionSendFlow({ richText.setContent, setContent, setPendingImeta, + restoreQueuedAttachments, setSpoileredAttachmentUrls, ], ); @@ -642,6 +694,7 @@ export function useMentionSendFlow({ capturedChannelId, capturedThreadContext = null, pendingImeta, + queuedAttachments = [], sentDraftKey, spoileredAttachmentUrls = new Set(), trimmed, @@ -703,15 +756,7 @@ export function useMentionSendFlow({ createdPersonaAgentPubkeySet.has(pubkey), ); const pubkeys = explicitMentionPubkeys; - const { content: finalContent, mediaTags } = buildOutgoingMessage( - trimmed, - pendingImeta, - spoileredAttachmentUrls, - ); - const outgoingTags = mergeOutgoingTags( - mediaTags, - buildCustomEmojiTags(finalContent, customEmoji), - ); + const outgoingTags = buildCustomEmojiTags(trimmed, customEmoji); const nonMemberPubkeys = getNonMemberMentionPubkeys(pubkeys); let promptNonMemberPubkeys = nonMemberPubkeys.filter( (pubkey) => @@ -734,7 +779,7 @@ export function useMentionSendFlow({ const pendingDraft: PendingNonMemberMentionSend = { capturedChannelId: effectiveChannelId, capturedThreadContext, - finalContent, + trimmed, mentionPubkeys: pubkeys, nonMemberPubkeys: promptNonMemberPubkeys, outgoingTags, @@ -745,6 +790,7 @@ export function useMentionSendFlow({ : createdPersonaAgentPubkeys, savedContent: trimmed, savedImeta: [...pendingImeta], + queuedAttachments: [...queuedAttachments], savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls), sentDraftKey, audienceGeneration, diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 69e2e455ec..17265ae309 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -596,11 +596,11 @@ export async function uploadMedia( isTemp, }); } - -export async function pickAndUploadMedia(): Promise { - return invokeTauri("pick_and_upload_media", {}); +export async function pickAndUploadMedia( + progressId?: string, +): Promise { + return invokeTauri("pick_and_upload_media", { progressId }); } - export async function uploadMediaBytes( data: number[], filename?: string, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index f17faa218b..d1b65ebe79 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -355,6 +355,8 @@ type E2eConfig = { // (e.g. a generic PDF) without a real upload pipeline. See // tests/helpers/bridge.ts:MockBridgeOptions.uploadDescriptors. uploadDelayMs?: number; + /** Exercise the production composer path that queues files until send. */ + deferredComposerUploads?: boolean; /** Delay (ms) applied to `encode_agent_snapshot_for_send` so E2E tests can * observe the "preparing" phase before the upload begins. 0/undefined = instant. */ encodeDelayMs?: number; diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index 403d26c136..dd7a700c4d 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; @@ -11,6 +12,7 @@ import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; test.beforeEach(async ({ page }) => { await installMockBridge(page, { + deferredComposerUploads: true, uploadDescriptors: [ { url: `https://mock.relay/media/${"a".repeat(64)}.pdf`, @@ -24,13 +26,25 @@ test.beforeEach(async ({ page }) => { }); }); +async function chooseQuarterlyReport(page: Page) { + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + page.getByRole("button", { name: "Attach image" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.from("quarterly report"), + mimeType: "application/pdf", + name: "quarterly-report.pdf", + }); +} + test("upload a file and see a FileCard in the timeline", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); - // Paperclip → mocked pick_and_upload_media returns the PDF descriptor. - await page.getByRole("button", { name: "Attach image" }).click(); + // The paperclip queues the local file without starting its upload. + await chooseQuarterlyReport(page); // The composer shows a chip with the original filename. await expect(page.getByTestId("message-composer")).toContainText( @@ -63,6 +77,64 @@ test("upload a file and see a FileCard in the timeline", async ({ page }) => { .toContain("download_file"); }); +test("sends immediately and keeps upload progress across channels", async ({ + page, +}) => { + await page.goto("/"); + await page.evaluate(() => { + const e2e = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } }; + } + ).__BUZZ_E2E__; + if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000; + }); + await page.getByTestId("channel-general").click(); + await chooseQuarterlyReport(page); + + await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0); + await page.getByTestId("send-message").click(); + + await expect(page.getByTestId("message-composer")).not.toContainText( + "quarterly-report.pdf", + ); + await expect(page.getByTestId("composer-upload-progress")).toBeVisible(); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect(page.getByTestId("composer-upload-progress")).toBeVisible(); + await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0, { + timeout: 5_000, + }); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("file-card").last()).toContainText( + "quarterly-report.pdf", + ); +}); + +test("canceling a background upload prevents the message from publishing", async ({ + page, +}) => { + await page.goto("/"); + await page.evaluate(() => { + const e2e = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } }; + } + ).__BUZZ_E2E__; + if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000; + }); + await page.getByTestId("channel-general").click(); + await chooseQuarterlyReport(page); + await page.getByTestId("send-message").click(); + + await page.getByTestId("composer-upload-cancel").click(); + await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0); + await page.waitForTimeout(1_100); + await expect(page.getByTestId("file-card")).toHaveCount(0); +}); + test("dropping a file on the channel column attaches it to the composer", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 345e1ee4d7..9e1626b756 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -361,6 +361,8 @@ type MockBridgeOptions = { * explicit `[]` is honoured (models a picker cancel / no files selected). */ uploadDelayMs?: number; + /** Exercise the production composer path that queues files until send. */ + deferredComposerUploads?: boolean; /** Delay (ms) applied to `encode_agent_snapshot_for_send` so E2E tests can * observe the "preparing" phase before the upload begins. 0/undefined = instant. */ encodeDelayMs?: number; From 2aae111ba6dee1462777cd7cd873b2d36f06afcd Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 14:29:25 +0100 Subject: [PATCH 02/21] Keep upload progress clear of latest control Signed-off-by: kenny lopez --- .../src/features/channels/ui/ChannelPane.tsx | 3 +- .../ui/ComposerUploadProgressOverlay.tsx | 19 +++++++ .../features/messages/ui/MessageTimeline.tsx | 5 +- .../messages/ui/useComposerHeightPadding.ts | 12 +++- desktop/tests/e2e/file-attachment.spec.ts | 55 +++++++++++++++++++ 5 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 9489d95209..8d29e1a7a9 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -4,6 +4,7 @@ import { AnimatePresence } from "motion/react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { ComposerDockBackdrop } from "@/features/messages/ui/ComposerDockBackdrop"; +import { ComposerUploadProgressOverlay } from "@/features/messages/ui/ComposerUploadProgressOverlay"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { ComposerTimeoutBanner } from "@/features/moderation/ui/ComposerTimeoutBanner"; import { useTimeoutState } from "@/features/moderation/lib/timeoutStore"; @@ -733,6 +734,7 @@ export const ChannelPane = React.memo(function ChannelPane({ data-testid="channel-composer-overlay" ref={composerWrapperRef} > +
{/* The activity accessory is anchored in the dock's reserved bottom rail, so fading it cannot change the observed diff --git a/desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx b/desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx new file mode 100644 index 0000000000..a972b6efe3 --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx @@ -0,0 +1,19 @@ +import { + cancelBackgroundMediaUploads, + useBackgroundMediaUpload, +} from "@/features/messages/lib/backgroundMediaUploadStore"; +import { ComposerUploadProgressPill } from "@/features/messages/ui/ComposerUploadProgressPill"; + +export function ComposerUploadProgressOverlay() { + const backgroundUpload = useBackgroundMediaUpload(); + + return ( +
+ +
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 954da08b01..34e20fa381 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -827,8 +827,9 @@ const MessageTimelineBase = React.forwardRef< {!isAtBottom ? (
mode === "css-variable" ? (scrollEl.querySelector( @@ -79,7 +84,10 @@ export function useComposerHeightPadding( const wasAtBottom = isNearBottom(); if (mode === "css-variable") { - scrollEl.style.setProperty("--composer-overlay-height", `${padding}px`); + cssVariableTarget.style.setProperty( + "--composer-overlay-height", + `${padding}px`, + ); } else { scrollEl.style.paddingBottom = `${padding}px`; } @@ -116,7 +124,7 @@ export function useComposerHeightPadding( cancelAnimationFrame(followBottomFrame); } if (mode === "css-variable") { - scrollEl.style.removeProperty("--composer-overlay-height"); + cssVariableTarget.style.removeProperty("--composer-overlay-height"); } else { scrollEl.style.paddingBottom = ""; } diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index dd7a700c4d..f3ccd465c6 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -135,6 +135,61 @@ test("canceling a background upload prevents the message from publishing", async await expect(page.getByTestId("file-card")).toHaveCount(0); }); +test("upload progress floats above the dock and lifts Jump to latest", async ({ + page, +}) => { + await page.goto("/"); + await page.evaluate(() => { + const e2e = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } }; + } + ).__BUZZ_E2E__; + if (e2e?.mock) e2e.mock.uploadDelayMs = 2_000; + }); + await page.getByTestId("channel-deep-history").click(); + + const timeline = page.getByTestId("message-timeline"); + await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); + await timeline.evaluate((element) => { + element.scrollTop = Math.max(500, element.scrollHeight / 2); + element.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + const jumpToLatest = page.getByTestId("message-scroll-to-latest"); + await expect(jumpToLatest).toBeVisible(); + const restingBox = await jumpToLatest.boundingBox(); + + await chooseQuarterlyReport(page); + await page.getByTestId("send-message").click(); + const uploadMotion = page.getByTestId("composer-upload-progress-motion"); + await expect(uploadMotion).toBeVisible(); + await timeline.evaluate((element) => { + element.scrollTop = Math.max(500, element.scrollHeight / 2); + element.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await expect(jumpToLatest).toBeVisible(); + await page.waitForTimeout(250); + + const [uploadBox, dockBackdropBox, liftedBox] = await Promise.all([ + uploadMotion.boundingBox(), + page.getByTestId("composer-dock-backdrop").boundingBox(), + jumpToLatest.boundingBox(), + ]); + expect(restingBox).not.toBeNull(); + expect(uploadBox).not.toBeNull(); + expect(dockBackdropBox).not.toBeNull(); + expect(liftedBox).not.toBeNull(); + expect((dockBackdropBox?.y ?? 0) + 1).toBeGreaterThanOrEqual( + (uploadBox?.y ?? 0) + (uploadBox?.height ?? 0), + ); + expect((liftedBox?.y ?? 0) + (liftedBox?.height ?? 0)).toBeLessThanOrEqual( + uploadBox?.y ?? 0, + ); + expect(liftedBox?.y ?? 0).toBeLessThan((restingBox?.y ?? 0) - 10); + + await page.getByTestId("composer-upload-cancel").click(); +}); + test("dropping a file on the channel column attaches it to the composer", async ({ page, }) => { From 60022c5487371b26870ae9357f1901728e3ce56e Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 14:58:08 +0100 Subject: [PATCH 03/21] Show desktop upload feedback immediately Signed-off-by: kenny lopez --- desktop/src-tauri/src/commands/media.rs | 8 +- desktop/src-tauri/src/commands/media_raw.rs | 73 +++++++++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 2 +- .../lib/backgroundMediaUploadStore.ts | 123 +++++++++++++----- .../messages/ui/useMentionSendFlow.ts | 36 +++-- desktop/src/shared/api/tauriMedia.ts | 31 +++++ desktop/src/testing/e2eBridge.ts | 12 +- desktop/tests/e2e/file-attachment.spec.ts | 54 ++++++++ 9 files changed, 284 insertions(+), 57 deletions(-) create mode 100644 desktop/src-tauri/src/commands/media_raw.rs diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index b3921be02d..10fe129936 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -741,13 +741,7 @@ pub async fn pick_and_upload_image( Ok(Some(descriptor)) } -/// Upload raw bytes directly (for paste and drag-drop). -/// -/// The renderer already has the bytes in memory from the clipboard/drag event. -/// If the bytes are a video, they're written to a temp file, transcoded via -/// ffmpeg, and the transcoded output is uploaded instead. -#[tauri::command] -pub async fn upload_media_bytes( +pub(super) async fn upload_media_bytes_inner( data: Vec, filename: Option, progress_id: Option, diff --git a/desktop/src-tauri/src/commands/media_raw.rs b/desktop/src-tauri/src/commands/media_raw.rs new file mode 100644 index 0000000000..747121b549 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_raw.rs @@ -0,0 +1,73 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use tauri::{ + ipc::{InvokeBody, Request}, + State, +}; + +use crate::app_state::AppState; + +use super::media::{upload_media_bytes_inner, BlobDescriptor}; + +/// Upload raw bytes directly (for paste and drag-drop). +/// +/// The renderer already has the bytes in memory from the clipboard/drag event. +/// If the bytes are a video, they're written to a temp file, transcoded via +/// ffmpeg, and the transcoded output is uploaded instead. +#[tauri::command] +pub async fn upload_media_bytes( + data: Vec, + filename: Option, + progress_id: Option, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + upload_media_bytes_inner(data, filename, progress_id, app, state).await +} + +fn decode_raw_upload_header(value: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(value) + .map_err(|error| format!("invalid raw upload header: {error}"))?; + String::from_utf8(bytes).map_err(|error| format!("invalid raw upload header text: {error}")) +} + +fn optional_raw_upload_header(request: &Request<'_>, name: &str) -> Result, String> { + request + .headers() + .get(name) + .map(|value| { + value + .to_str() + .map_err(|error| format!("invalid {name} header: {error}")) + .and_then(decode_raw_upload_header) + }) + .transpose() +} + +/// Upload raw IPC bytes without expanding a large browser File into JSON. +#[tauri::command] +pub async fn upload_media_bytes_raw( + request: Request<'_>, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + let data = match request.body() { + InvokeBody::Raw(data) => data.clone(), + InvokeBody::Json(_) => return Err("raw upload requires a byte body".to_string()), + }; + let filename = optional_raw_upload_header(&request, "x-buzz-filename")?; + let progress_id = optional_raw_upload_header(&request, "x-buzz-progress-id")?; + + upload_media_bytes_inner(data, filename, progress_id, app, state).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_raw_upload_header_preserves_unicode() { + let encoded = URL_SAFE_NO_PAD.encode("clip 🎬.mp4"); + assert_eq!(decode_raw_upload_header(&encoded).unwrap(), "clip 🎬.mp4"); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17b..c5e1b41f5b 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -28,6 +28,7 @@ pub(crate) mod media; mod media_animated; mod media_download; mod media_gif; +mod media_raw; mod media_snapshot_png; mod media_transcode; #[cfg(feature = "mesh-llm")] @@ -85,6 +86,7 @@ pub use legacy_storage::*; pub use link_preview::*; pub use media::*; pub use media_download::*; +pub use media_raw::*; #[cfg(feature = "mesh-llm")] pub use mesh_llm::*; pub use messages::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c4b733e3e0..09c68b83e3 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -659,7 +659,6 @@ pub fn run() { } }); } - Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -778,6 +777,7 @@ pub fn run() { pick_and_upload_media, pick_and_upload_image, upload_media_bytes, + upload_media_bytes_raw, download_image, save_png_data_url, download_file, diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts index 9add1223a3..43f16aa80e 100644 --- a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts @@ -1,6 +1,7 @@ import * as React from "react"; -import { type BlobDescriptor, uploadMediaBytes } from "@/shared/api/tauri"; +import type { BlobDescriptor } from "@/shared/api/tauri"; +import { uploadMediaFile } from "@/shared/api/tauriMedia"; export type QueuedMediaAttachment = { file: File; @@ -25,6 +26,16 @@ type EnqueueBackgroundUploadOptions = { onError: (error: unknown) => void; }; +type StartBackgroundUploadOptions = Omit< + EnqueueBackgroundUploadOptions, + "attachments" +>; + +export type PreparedBackgroundMediaUpload = { + cancel: () => void; + start: (options: StartBackgroundUploadOptions) => boolean; +}; + const tasks = new Map(); const listeners = new Set<() => void>(); let nextTaskId = 0; @@ -102,14 +113,34 @@ function finishTask(taskId: number): void { } } -export function enqueueBackgroundMediaUpload({ - attachments, - onComplete, - onError, -}: EnqueueBackgroundUploadOptions): void { +function yieldForUploadFeedback(): Promise { + if ( + typeof window === "undefined" || + typeof window.requestAnimationFrame !== "function" || + document.visibilityState === "hidden" + ) { + return new Promise((resolve) => setTimeout(resolve, 0)); + } + + return new Promise((resolve) => { + window.requestAnimationFrame(() => window.setTimeout(resolve, 0)); + }); +} + +export function prepareBackgroundMediaUpload( + attachments: QueuedMediaAttachment[], +): PreparedBackgroundMediaUpload { if (attachments.length === 0) { - void onComplete([]).catch(onError); - return; + let started = false; + return { + cancel: () => undefined, + start: ({ onComplete, onError }) => { + if (started) return false; + started = true; + void onComplete([]).catch(onError); + return true; + }, + }; } const taskId = nextTaskId; @@ -119,36 +150,60 @@ export function enqueueBackgroundMediaUpload({ fileProgress: attachments.map(() => 0), id: taskId, }; + let started = false; tasks.set(taskId, task); rebuildSnapshot(); - void ensureProgressListener(); - void (async () => { - try { - const descriptors: BlobDescriptor[] = []; - for (let index = 0; index < attachments.length; index += 1) { - if (task.canceled) return; - const attachment = attachments[index]; - const buffer = await attachment.file.arrayBuffer(); - if (task.canceled) return; - const descriptor = await uploadMediaBytes( - [...new Uint8Array(buffer)], - attachment.file.name, - progressId(taskId, index), - ); - if (task.canceled) return; - task.fileProgress[index] = 100; - rebuildSnapshot(); - descriptors.push(descriptor); - } - - if (!task.canceled) await onComplete(descriptors); - } catch (error) { - if (!task.canceled) onError(error); - } finally { + return { + cancel: () => { + if (task.canceled) return; + task.canceled = true; finishTask(taskId); - } - })(); + }, + start: ({ onComplete, onError }) => { + if (started || task.canceled) return false; + started = true; + void ensureProgressListener(); + + void (async () => { + try { + // Let React commit and paint the 0% task before file reads or native + // IPC begin, so large attachments never hide the initial feedback. + await yieldForUploadFeedback(); + const descriptors: BlobDescriptor[] = []; + for (let index = 0; index < attachments.length; index += 1) { + if (task.canceled) return; + const attachment = attachments[index]; + const descriptor = await uploadMediaFile( + attachment.file, + progressId(taskId, index), + ); + if (task.canceled) return; + task.fileProgress[index] = 100; + rebuildSnapshot(); + descriptors.push(descriptor); + } + + if (!task.canceled) await onComplete(descriptors); + } catch (error) { + if (!task.canceled) onError(error); + } finally { + finishTask(taskId); + } + })(); + return true; + }, + }; +} + +export function enqueueBackgroundMediaUpload({ + attachments, + onComplete, + onError, +}: EnqueueBackgroundUploadOptions): PreparedBackgroundMediaUpload { + const preparedUpload = prepareBackgroundMediaUpload(attachments); + preparedUpload.start({ onComplete, onError }); + return preparedUpload; } export function cancelBackgroundMediaUploads(): void { diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 345b87fc12..cb91badf65 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -14,7 +14,7 @@ import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRunti import { useAddChannelMembersMutation } from "@/features/channels/hooks"; import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys"; import { - enqueueBackgroundMediaUpload, + prepareBackgroundMediaUpload, type QueuedMediaAttachment, } from "@/features/messages/lib/backgroundMediaUploadStore"; import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; @@ -453,6 +453,11 @@ export function useMentionSendFlow({ isCompleteSendPendingRef.current = true; setIsCompleteSendPending(true); + const preparedUpload = + draft.queuedAttachments.length > 0 + ? prepareBackgroundMediaUpload(draft.queuedAttachments) + : null; + let uploadStarted = false; try { const readyAgentPubkeys = new Set( (draft.readyAgentPubkeys ?? []).map(normalizePubkey), @@ -517,15 +522,6 @@ export function useMentionSendFlow({ mentionPubkeys, ); - // Replace the sent body directly with its final post-send state before - // the async network send starts. This avoids an intermediate blank frame - // for persistent audiences while preserving the ordinary empty state. - if (draft.capturedChannelId === channelIdRef.current) { - clearComposer( - resolvePostSendContent?.(effectiveExplicitAgentPubkeys), - ); - } - const send = onSendRef.current; const restoreComposerAfterFailure = () => { if ( @@ -582,9 +578,8 @@ export function useMentionSendFlow({ } }; - if (draft.queuedAttachments.length > 0) { - enqueueBackgroundMediaUpload({ - attachments: draft.queuedAttachments, + if (preparedUpload) { + uploadStarted = preparedUpload.start({ onComplete: async (uploaded) => { try { await finishSend(uploaded); @@ -599,7 +594,19 @@ export function useMentionSendFlow({ ); }, }); - } else { + if (!uploadStarted) return; + } + + // Replace the sent body directly with its final post-send state before + // the async network send starts. This avoids an intermediate blank frame + // for persistent audiences while preserving the ordinary empty state. + if (draft.capturedChannelId === channelIdRef.current) { + clearComposer( + resolvePostSendContent?.(effectiveExplicitAgentPubkeys), + ); + } + + if (!preparedUpload) { try { await finishSend([]); } catch { @@ -607,6 +614,7 @@ export function useMentionSendFlow({ } } } finally { + if (!uploadStarted) preparedUpload?.cancel(); isCompleteSendPendingRef.current = false; if (isMountedRef.current) { setIsCompleteSendPending(false); diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index 1dab9b7728..5bef1195d3 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -1,5 +1,36 @@ +import { invoke as invokeTauriRaw } from "@tauri-apps/api/core"; import { type BlobDescriptor, invokeTauri } from "./tauri"; +function encodeRawIpcHeader(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return window + .btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} + +/** Transfer a browser File to Rust as a raw IPC body, avoiding JSON expansion. */ +export async function uploadMediaFile( + file: File, + progressId?: string, +): Promise { + const headers: Record = { + "x-buzz-filename": encodeRawIpcHeader(file.name), + }; + if (progressId) { + headers["x-buzz-progress-id"] = encodeRawIpcHeader(progressId); + } + + return invokeTauriRaw( + "upload_media_bytes_raw", + new Uint8Array(await file.arrayBuffer()), + { headers }, + ); +} + /** * Open a native single-file picker constrained to images and upload the * chosen file. Non-image files are rejected in Rust (via MIME sniffing) diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index d1b65ebe79..6be4d78fae 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8629,7 +8629,7 @@ async function resolveMockUploadDescriptors( } async function resolveMockUploadDescriptorForBytes( - args: { data: number[]; filename?: string | null }, + args: { data: number[] | Uint8Array; filename?: string | null }, config: E2eConfig | undefined, ): Promise { const configured = config?.mock?.uploadDescriptors; @@ -9943,6 +9943,9 @@ export function maybeInstallE2eTauriMocks() { const identity = getActiveIdentity(activeConfig); window.__BUZZ_E2E_COMMANDS__?.push(command); const loggedPayload = (() => { + if (payload instanceof Uint8Array) { + return { rawByteLength: payload.byteLength }; + } try { return JSON.parse(JSON.stringify(payload ?? null)); } catch { @@ -11814,6 +11817,13 @@ export function maybeInstallE2eTauriMocks() { payload as { data: number[]; filename?: string | null }, activeConfig, ); + case "upload_media_bytes_raw": + return resolveMockUploadDescriptorForBytes( + { + data: payload as Uint8Array, + }, + activeConfig, + ); case "fetch_media_bytes": { // The real command fetches relay media through Rust reqwest and // replies with raw bytes (`tauri::ipc::Response` → ArrayBuffer). In diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index f3ccd465c6..6548107e5a 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -38,6 +38,18 @@ async function chooseQuarterlyReport(page: Page) { }); } +async function chooseLargeVideo(page: Page) { + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + page.getByRole("button", { name: "Attach image" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.alloc(16 * 1024 * 1024, 1), + mimeType: "video/mp4", + name: "large-video.mp4", + }); +} + test("upload a file and see a FileCard in the timeline", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); @@ -113,6 +125,48 @@ test("sends immediately and keeps upload progress across channels", async ({ ); }); +test("shows upload feedback before transferring a large file", async ({ + page, +}) => { + await page.goto("/"); + await page.evaluate(() => { + const e2e = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } }; + } + ).__BUZZ_E2E__; + if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000; + }); + await page.getByTestId("channel-general").click(); + await chooseLargeVideo(page); + + const progress = page.getByTestId("composer-upload-progress"); + await Promise.all([ + page.getByTestId("send-message").click(), + expect(progress).toBeVisible({ timeout: 800 }), + ]); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ + command: string; + payload: { rawByteLength?: number } | null; + }>; + } + ).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [], + ), + ) + .toContainEqual({ + command: "upload_media_bytes_raw", + payload: { rawByteLength: 16 * 1024 * 1024 }, + }); + + await page.getByTestId("composer-upload-cancel").click(); +}); + test("canceling a background upload prevents the message from publishing", async ({ page, }) => { From 96fa445edc79c3c4084dc4cdd10eda96feb1c9c0 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 15:17:44 +0100 Subject: [PATCH 04/21] Polish desktop upload progress colors Signed-off-by: kenny lopez --- .../messages/ui/ComposerUploadProgressPill.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx index fe51c6adf7..3c7676bf6d 100644 --- a/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx +++ b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx @@ -42,13 +42,13 @@ export function ComposerUploadProgressPill({
- + Uploading - + {" · "} {percentage}%
diff --git a/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx index 3c7676bf6d..59ecc3e433 100644 --- a/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx +++ b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx @@ -1,17 +1,27 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { + type BackgroundMediaUploadPhase, + backgroundMediaUploadPhaseLabel, +} from "@/features/messages/lib/backgroundMediaUploadPhase"; import { cn } from "@/shared/lib/cn"; export function ComposerUploadProgressPill({ isUploading, onCancel, + phase, percentage, }: { isUploading: boolean; onCancel: () => void; + phase: BackgroundMediaUploadPhase; percentage: number; }) { const reducedMotion = useReducedMotion(); + const phaseLabel = backgroundMediaUploadPhaseLabel(phase); + const phaseTransition = reducedMotion + ? { duration: 0 } + : { duration: 0.18, ease: [0.77, 0, 0.175, 1] as const }; return ( @@ -40,7 +50,7 @@ export function ComposerUploadProgressPill({ }} >
- Uploading - - {" · "} - {percentage}% + + + + {phaseLabel} + + + + {" · "} + {percentage}% +
+ {preview.spoilered ? ( +
+ +
+ ) : null}
) : (
@@ -672,6 +680,30 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ Remove attachment ) : null} + {isMedia && onToggleQueuedSpoiler ? ( + + + + onToggleQueuedSpoiler(preview.id) + } + pressed={preview.spoilered} + type="button" + > + + + + + {preview.spoilered ? "Remove spoiler" : "Mark as spoiler"} + + + ) : null} ); })} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index b3049ce8e3..064aafa098 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -85,7 +85,7 @@ function MessageComposerImpl({ profiles, replyTarget = null, mediaController, - showBackgroundUploadProgress = false, + showBackgroundUploadProgress = true, showTopBorder = false, toolbarExtraActions, typingParentEventId = null, @@ -127,10 +127,10 @@ function MessageComposerImpl({ : null; const effectiveDraftKeyRef = React.useRef(effectiveDraftKey); effectiveDraftKeyRef.current = effectiveDraftKey; - // Snapshot composer state before edit mode so cancel can restore it. const preEditSnapshotRef = React.useRef<{ content: string; pendingImeta: ImetaMedia[]; + queuedAttachments: ReturnType["queuedAttachments"]; spoileredAttachmentUrls: Set; } | null>(null); const mentions = useMentions(channelId, undefined, profiles, { @@ -144,17 +144,12 @@ function MessageComposerImpl({ typingParentEventId, typingRootEventId, ); - - // New files stay local so the composer can render previews immediately; - // submitting hands them to the app-wide background upload queue. const internalMedia = useMediaUpload({ deferUploadsUntilSend: true }); const media = mediaController ?? internalMedia; const ownsDropZone = mediaController === undefined; const backgroundUpload = useBackgroundMediaUpload(); - // Draft-persist lifecycle: restore/clear content + imeta + spoilered urls on - // key change, and persist the outgoing draft in the cleanup. The StrictMode - // fix lives inside this hook — see useDraftPersistSnapshot.ts. + // Restore/persist drafts at a key boundary; the hook handles StrictMode. useDraftPersistLifecycle({ effectiveDraftKey, channelId, @@ -164,6 +159,7 @@ function MessageComposerImpl({ restoreMentionRefs: mentions.restoreDraftMentionRefs, livePendingImeta: media.pendingImeta, setPendingImeta: media.setPendingImeta, + clearQueuedAttachments: media.clearQueuedAttachments, setContent: (content) => { setComposerContent(content); richText.setContent(content); @@ -332,12 +328,11 @@ function MessageComposerImpl({ // biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger React.useEffect(() => { if (editTarget) { - // Snapshot the current draft (text + attachments) so the user's - // in-flight work survives the edit-mode hijack and is restored on - // edit-cancel/exit. + // Preserve the user's in-flight draft while editing another message. preEditSnapshotRef.current = { content: syncComposerContentFromEditor(), pendingImeta: [...media.pendingImetaRef.current], + queuedAttachments: [...media.queuedAttachmentsRef.current], spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), }; // Strip the trailing `![image|video](url)` lines that correspond to @@ -354,6 +349,7 @@ function MessageComposerImpl({ // attachments so they show up in `ComposerAttachments` and the user // can remove existing ones / add new ones before saving. media.setPendingImeta(editableImeta); + media.clearQueuedAttachments(); setSpoileredAttachmentUrls( findSpoileredImetaMediaUrls(editTarget.body, editableImeta), ); @@ -369,6 +365,7 @@ function MessageComposerImpl({ const { content: restoredContent, pendingImeta: restoredImeta, + queuedAttachments: restoredQueuedAttachments, spoileredAttachmentUrls: restoredSpoileredAttachmentUrls, } = preEditSnapshotRef.current; preEditSnapshotRef.current = null; @@ -377,6 +374,7 @@ function MessageComposerImpl({ ? richText.setContent(restoredContent) : richText.clearContent(); media.setPendingImeta(restoredImeta); + media.restoreQueuedAttachments(restoredQueuedAttachments); setSpoileredAttachmentUrls(restoredSpoileredAttachmentUrls); } }, [editTarget?.id]); @@ -523,6 +521,7 @@ function MessageComposerImpl({ // deletes the message instead of publishing it (see handleEditSave). await submitMessageEdit({ content: trimmed, + editTargetId: editTargetRef.current.id, customEmoji, originalContent: editTargetRef.current.body, ownerPubkey: ownerPubkeyRef.current, @@ -961,6 +960,7 @@ function MessageComposerImpl({ isUploading={media.isUploading} onCancelUpload={media.cancelUpload} onRemoveQueued={media.removeQueuedAttachment} + onToggleQueuedSpoiler={media.toggleQueuedAttachmentSpoiler} queuedPreviews={media.queuedPreviews} uploadingCount={media.uploadingCount} uploadingPreviews={media.uploadingPreviews} diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts index 70b3f50a21..7418687cfa 100644 --- a/desktop/src/features/messages/ui/MessageComposer.types.ts +++ b/desktop/src/features/messages/ui/MessageComposer.types.ts @@ -66,6 +66,8 @@ export type MessageComposerProps = { content: string, mediaTags?: string[][], mentionPubkeys?: string[], + /** Target captured when the edit was submitted; avoids a later ref swap. */ + eventId?: string, ) => Promise; /** Captures send context synchronously before awaits can change navigation. */ onCaptureSendContext?: () => { diff --git a/desktop/src/features/messages/ui/submitMessageEdit.ts b/desktop/src/features/messages/ui/submitMessageEdit.ts index 6762745461..b2f3db1827 100644 --- a/desktop/src/features/messages/ui/submitMessageEdit.ts +++ b/desktop/src/features/messages/ui/submitMessageEdit.ts @@ -20,6 +20,7 @@ type SubmitMessageEditOptions = EditDraft & { clearComposer: () => void; customEmoji: ReadonlyArray; extractMentionPubkeys: (content: string) => string[]; + editTargetId: string; originalContent: string; ownerPubkey: string | null; restoreComposer: (draft: EditDraft) => void; @@ -27,6 +28,7 @@ type SubmitMessageEditOptions = EditDraft & { content: string, mediaTags?: string[][], mentionPubkeys?: string[], + eventId?: string, ) => Promise; setUploadError: (message: string) => void; }; @@ -36,6 +38,7 @@ export async function submitMessageEdit({ clearComposer, content, customEmoji, + editTargetId, extractMentionPubkeys, originalContent, ownerPubkey, @@ -64,14 +67,19 @@ export async function submitMessageEdit({ const { content: finalContent, mediaTags } = buildOutgoingMessage( content, [...draft.pendingImeta, ...uploaded], - draft.spoileredAttachmentUrls, + new Set([ + ...draft.spoileredAttachmentUrls, + ...draft.queuedAttachments.flatMap((attachment, index) => + attachment.spoilered && uploaded[index] ? [uploaded[index].url] : [], + ), + ]), ); const outgoingTags = mergeOutgoingTags( mediaTags, buildCustomEmojiTags(finalContent, customEmoji), ) ?? []; - await save(finalContent, outgoingTags, addedMentionPubkeys); + await save(finalContent, outgoingTags, addedMentionPubkeys, editTargetId); }; if (draft.queuedAttachments.length > 0) { diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index e2c4134bd6..442aea4da4 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -28,6 +28,8 @@ type UseDraftPersistLifecycleParams = { livePendingImeta: ImetaMedia[]; /** Async setter for pendingImeta — called after the synchronous snapshot. */ setPendingImeta: (imeta: ImetaMedia[]) => void; + /** Local files cannot be persisted, so clear them at a draft-key boundary. */ + clearQueuedAttachments?: () => void; /** Set the rich-text editor content from a draft string. */ setContent: (content: string) => void; /** Clear the rich-text editor content (no-draft path). */ @@ -80,6 +82,7 @@ export function useDraftPersistLifecycle({ restoreMentionRefs, livePendingImeta, setPendingImeta, + clearQueuedAttachments, setContent, clearContent, setSpoileredAttachmentUrls, @@ -99,6 +102,9 @@ export function useDraftPersistLifecycle({ // already reflects the incoming channel, which would corrupt the outgoing // draft's channelId metadata. + // Files cannot be serialized into the draft store. Dropping the in-memory + // queue here prevents it from being sent in the next channel or thread. + clearQueuedAttachments?.(); const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined; if (saved) { setContent(saved.content); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index cb91badf65..ad9c5d1a46 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -526,7 +526,9 @@ export function useMentionSendFlow({ const restoreComposerAfterFailure = () => { if ( !isMountedRef.current || - draft.capturedChannelId !== channelIdRef.current + (draft.capturedChannelId !== channelIdRef.current && + channelIdRef.current !== null) || + contentRef.current.trim().length > 0 ) { return; } @@ -543,7 +545,14 @@ export function useMentionSendFlow({ const { content: finalContent, mediaTags } = buildOutgoingMessage( draft.trimmed, [...draft.savedImeta, ...uploaded], - draft.savedSpoileredAttachmentUrls, + new Set([ + ...draft.savedSpoileredAttachmentUrls, + ...draft.queuedAttachments.flatMap((attachment, index) => + attachment.spoilered && uploaded[index] + ? [uploaded[index].url] + : [], + ), + ]), ); const finalOutgoingTags = mergeOutgoingTags( mediaTags, @@ -593,6 +602,7 @@ export function useMentionSendFlow({ `Upload failed: ${getErrorMessage(error, "Unknown error")}`, ); }, + onCancel: restoreComposerAfterFailure, }); if (!uploadStarted) return; } @@ -600,7 +610,10 @@ export function useMentionSendFlow({ // Replace the sent body directly with its final post-send state before // the async network send starts. This avoids an intermediate blank frame // for persistent audiences while preserving the ordinary empty state. - if (draft.capturedChannelId === channelIdRef.current) { + if ( + draft.capturedChannelId === channelIdRef.current || + channelIdRef.current === null + ) { clearComposer( resolvePostSendContent?.(effectiveExplicitAgentPubkeys), ); diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index 5bef1195d3..110403daf2 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -31,6 +31,11 @@ export async function uploadMediaFile( ); } +/** Stop the native HTTP request associated with a background media upload. */ +export async function cancelMediaUpload(progressId: string): Promise { + await invokeTauri("cancel_media_upload", { progressId }); +} + /** * Open a native single-file picker constrained to images and upload the * chosen file. Non-image files are rejected in Rust (via MIME sniffing) From 6f1b3e69bd2dd9623dd8d97c4fa69e4c57fda72d Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 16:34:01 +0100 Subject: [PATCH 10/21] Prevent cancelling deferred publish Signed-off-by: kenny lopez --- .../lib/backgroundMediaUploadStore.ts | 8 +++++- .../ui/ComposerUploadProgressOverlay.tsx | 1 + .../ui/ComposerUploadProgressPill.tsx | 26 +++++++++++-------- .../features/messages/ui/MessageComposer.tsx | 2 +- 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts index 809bf4a7ad..f82436cb6f 100644 --- a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts @@ -20,10 +20,12 @@ type BackgroundUploadTask = { filePhases: BackgroundMediaUploadPhase[]; fileProgress: Array<{ sent: number; total: number }>; id: number; + isCompleting: boolean; onCancel?: () => void; }; type BackgroundUploadSnapshot = { + canCancel: boolean; isUploading: boolean; phase: BackgroundMediaUploadPhase; percentage: number; @@ -50,6 +52,7 @@ const tasks = new Map(); const listeners = new Set<() => void>(); let nextTaskId = 0; let snapshot: BackgroundUploadSnapshot = { + canCancel: false, isUploading: false, phase: "preparing", percentage: 0, @@ -73,6 +76,7 @@ function rebuildSnapshot(): void { 0, ); snapshot = { + canCancel: allTasks.some((task) => !task.isCompleting), isUploading: allTasks.length > 0, phase: resolveBackgroundMediaUploadPhase( allTasks.flatMap((task) => task.filePhases), @@ -164,7 +168,7 @@ function finishTask(taskId: number): void { } function cancelTask(task: BackgroundUploadTask): void { - if (task.canceled) return; + if (task.canceled || task.isCompleting) return; task.canceled = true; task.onCancel?.(); for (let index = 0; index < task.fileProgress.length; index += 1) { @@ -213,6 +217,7 @@ export function prepareBackgroundMediaUpload( total: attachment.file.size, })), id: taskId, + isCompleting: false, }; let started = false; tasks.set(taskId, task); @@ -252,6 +257,7 @@ export function prepareBackgroundMediaUpload( } if (!task.canceled) { + task.isCompleting = true; task.filePhases.fill("finishing"); rebuildSnapshot(); await onComplete(descriptors); diff --git a/desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx b/desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx index fa13997023..d021e49783 100644 --- a/desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx +++ b/desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx @@ -10,6 +10,7 @@ export function ComposerUploadProgressOverlay() { return (
void; phase: BackgroundMediaUploadPhase; @@ -147,17 +149,19 @@ export function ComposerUploadProgressPill({ - + {canCancel ? ( + + ) : null}
diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 064aafa098..e7bb99f538 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -1,5 +1,4 @@ import * as React from "react"; - import { EditorContent } from "@tiptap/react"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; @@ -886,6 +885,7 @@ function MessageComposerImpl({ /> {showBackgroundUploadProgress ? ( Date: Mon, 3 Aug 2026 17:41:18 +0100 Subject: [PATCH 11/21] Address deferred upload follow-up Signed-off-by: kenny lopez --- desktop/src-tauri/src/commands/media.rs | 34 +++++++++++-------- .../src/commands/media_upload_progress.rs | 27 +++++++++++---- .../lib/backgroundMediaUploadStore.ts | 4 +++ .../features/messages/ui/MessageComposer.tsx | 12 +++---- .../features/messages/ui/NewMessageScreen.tsx | 6 +--- .../features/messages/ui/submitMessageEdit.ts | 12 +++++-- desktop/src/shared/api/tauriMedia.ts | 13 ++++--- 7 files changed, 67 insertions(+), 41 deletions(-) diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 96c54ef705..7ae2575664 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -12,7 +12,7 @@ use super::media_transcode::{ has_heic_extension, is_heic_file, is_video_file, transcode_and_extract_poster, transcode_heic_path_to_jpeg_bytes, }; -use super::media_upload_progress::{emit_media_upload_phase, send_upload_attempt}; +use super::media_upload_progress::{emit_media_upload_phase, send_upload_attempt, UploadAttempt}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlobDescriptor { @@ -454,25 +454,29 @@ async fn do_upload( } let mut resp = send_upload_attempt( state, - format!("{base_url}/upload"), - &auth_header, - mime, - &sha256, - body.clone(), - progress.as_ref(), - cancellation, + UploadAttempt { + url: format!("{base_url}/upload"), + auth_header: &auth_header, + mime, + sha256: &sha256, + body: body.clone(), + progress: progress.as_ref(), + cancellation, + }, ) .await?; if should_retry_legacy_upload(resp.status()) { resp = send_upload_attempt( state, - format!("{base_url}/media/upload"), - &auth_header, - mime, - &sha256, - body, - progress.as_ref(), - cancellation, + UploadAttempt { + url: format!("{base_url}/media/upload"), + auth_header: &auth_header, + mime, + sha256: &sha256, + body, + progress: progress.as_ref(), + cancellation, + }, ) .await?; } diff --git a/desktop/src-tauri/src/commands/media_upload_progress.rs b/desktop/src-tauri/src/commands/media_upload_progress.rs index 67fef2e5d5..850afe1b12 100644 --- a/desktop/src-tauri/src/commands/media_upload_progress.rs +++ b/desktop/src-tauri/src/commands/media_upload_progress.rs @@ -37,16 +37,29 @@ pub(super) fn finish_media_upload(progress_id: Option<&str>) { } } +pub(super) struct UploadAttempt<'a> { + pub url: String, + pub auth_header: &'a str, + pub mime: &'a str, + pub sha256: &'a str, + pub body: bytes::Bytes, + pub progress: Option<&'a (tauri::AppHandle, String)>, + pub cancellation: Option<&'a CancellationToken>, +} + pub(super) async fn send_upload_attempt( state: &AppState, - url: String, - auth_header: &str, - mime: &str, - sha256: &str, - body: bytes::Bytes, - progress: Option<&(tauri::AppHandle, String)>, - cancellation: Option<&CancellationToken>, + attempt: UploadAttempt<'_>, ) -> Result { + let UploadAttempt { + url, + auth_header, + mime, + sha256, + body, + progress, + cancellation, + } = attempt; let req = state .http_client .put(url) diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts index f82436cb6f..9949542626 100644 --- a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts @@ -16,6 +16,7 @@ export type QueuedMediaAttachment = { }; type BackgroundUploadTask = { + abortController: AbortController; canceled: boolean; filePhases: BackgroundMediaUploadPhase[]; fileProgress: Array<{ sent: number; total: number }>; @@ -170,6 +171,7 @@ function finishTask(taskId: number): void { function cancelTask(task: BackgroundUploadTask): void { if (task.canceled || task.isCompleting) return; task.canceled = true; + task.abortController.abort(); task.onCancel?.(); for (let index = 0; index < task.fileProgress.length; index += 1) { void cancelMediaUpload(progressId(task.id, index)).catch(() => undefined); @@ -210,6 +212,7 @@ export function prepareBackgroundMediaUpload( const taskId = nextTaskId; nextTaskId += 1; const task: BackgroundUploadTask = { + abortController: new AbortController(), canceled: false, filePhases: attachments.map(() => "preparing"), fileProgress: attachments.map((attachment) => ({ @@ -245,6 +248,7 @@ export function prepareBackgroundMediaUpload( const descriptor = await uploadMediaFile( attachment.file, progressId(taskId, index), + task.abortController.signal, ); if (task.canceled) return; task.filePhases[index] = "finishing"; diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index e7bb99f538..b562a7a6b1 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -15,7 +15,6 @@ import { restoreImetaMediaDisplayLabels, stripImetaMediaLines, } from "@/features/messages/lib/imetaMediaMarkdown"; - import { useAttachmentEditing } from "@/features/messages/lib/useAttachmentEditing"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { @@ -56,9 +55,7 @@ import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionH import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; import { submitMessageEdit } from "./submitMessageEdit"; - import type { MessageComposerProps } from "./MessageComposer.types"; - function MessageComposerImpl({ audienceContext = null, channelId = null, @@ -105,12 +102,10 @@ function MessageComposerImpl({ >(() => new Set()); const spoileredAttachmentUrlsRef = React.useRef(spoileredAttachmentUrls); spoileredAttachmentUrlsRef.current = spoileredAttachmentUrls; - const handleFormattingToggle = React.useCallback((pressed: boolean) => { if (pressed) setIsEmojiPickerOpen(false); setIsFormattingOpen(pressed); }, []); - const drafts = useDrafts(); const identityQuery = useIdentityQuery(); const effectiveDraftKey = draftKey ?? channelId; @@ -145,6 +140,11 @@ function MessageComposerImpl({ ); const internalMedia = useMediaUpload({ deferUploadsUntilSend: true }); const media = mediaController ?? internalMedia; + const canRestoreEditDraftRef = React.useRef(false); + canRestoreEditDraftRef.current = + contentRef.current.trim().length === 0 && + media.pendingImetaRef.current.length === 0 && + media.queuedAttachmentsRef.current.length === 0; const ownsDropZone = mediaController === undefined; const backgroundUpload = useBackgroundMediaUpload(); @@ -178,7 +178,6 @@ function MessageComposerImpl({ channelLinks.clearChannels(); emojiAutocomplete.clearEmojis(); }, [effectiveDraftKey]); - const disabledRef = React.useRef(disabled); const isSendingRef = React.useRef(isSending); const isUploadingRef = React.useRef(media.isUploading); @@ -547,6 +546,7 @@ function MessageComposerImpl({ media.restoreQueuedAttachments(draft.queuedAttachments); setSpoileredAttachmentUrls(draft.spoileredAttachmentUrls); }, + shouldRestoreComposer: () => canRestoreEditDraftRef.current, setUploadError: (message) => media.setUploadState({ status: "error", message }), }); diff --git a/desktop/src/features/messages/ui/NewMessageScreen.tsx b/desktop/src/features/messages/ui/NewMessageScreen.tsx index f2a1427aa6..f94dd5d1e1 100644 --- a/desktop/src/features/messages/ui/NewMessageScreen.tsx +++ b/desktop/src/features/messages/ui/NewMessageScreen.tsx @@ -257,10 +257,6 @@ export function NewMessageScreen() { ); } - if (!isMountedRef.current) { - return; - } - try { await sendMessageMutation.mutateAsync({ targetChannel: directMessage, @@ -272,7 +268,7 @@ export function NewMessageScreen() { preparedDirectMessageRef.current = null; const message = error instanceof Error ? error.message : "Failed to send message."; - setSubmitErrorMessage(message); + if (isMountedRef.current) setSubmitErrorMessage(message); throw error; } diff --git a/desktop/src/features/messages/ui/submitMessageEdit.ts b/desktop/src/features/messages/ui/submitMessageEdit.ts index b2f3db1827..0ce8cea324 100644 --- a/desktop/src/features/messages/ui/submitMessageEdit.ts +++ b/desktop/src/features/messages/ui/submitMessageEdit.ts @@ -24,6 +24,7 @@ type SubmitMessageEditOptions = EditDraft & { originalContent: string; ownerPubkey: string | null; restoreComposer: (draft: EditDraft) => void; + shouldRestoreComposer: () => boolean; save: ( content: string, mediaTags?: string[][], @@ -45,6 +46,7 @@ export async function submitMessageEdit({ pendingImeta, queuedAttachments, restoreComposer, + shouldRestoreComposer, save, setUploadError, spoileredAttachmentUrls, @@ -55,6 +57,9 @@ export async function submitMessageEdit({ queuedAttachments: [...queuedAttachments], spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), }; + const restoreDraft = () => { + if (shouldRestoreComposer()) restoreComposer(draft); + }; const addedMentionPubkeys = diffAddedMentionPubkeys( extractMentionPubkeys(originalContent), extractMentionPubkeys(content), @@ -89,13 +94,14 @@ export async function submitMessageEdit({ try { await finishEdit(uploaded); } catch { - restoreComposer(draft); + restoreDraft(); } }, onError: (error) => { - restoreComposer(draft); + restoreDraft(); setUploadError(String(error)); }, + onCancel: restoreDraft, }); return; } @@ -103,6 +109,6 @@ export async function submitMessageEdit({ try { await finishEdit([]); } catch { - restoreComposer(draft); + restoreDraft(); } } diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index 110403daf2..60205a45fe 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -16,6 +16,7 @@ function encodeRawIpcHeader(value: string): string { export async function uploadMediaFile( file: File, progressId?: string, + signal?: AbortSignal, ): Promise { const headers: Record = { "x-buzz-filename": encodeRawIpcHeader(file.name), @@ -24,11 +25,13 @@ export async function uploadMediaFile( headers["x-buzz-progress-id"] = encodeRawIpcHeader(progressId); } - return invokeTauriRaw( - "upload_media_bytes_raw", - new Uint8Array(await file.arrayBuffer()), - { headers }, - ); + if (signal?.aborted) throw new Error("upload cancelled"); + const bytes = new Uint8Array(await file.arrayBuffer()); + if (signal?.aborted) throw new Error("upload cancelled"); + + return invokeTauriRaw("upload_media_bytes_raw", bytes, { + headers, + }); } /** Stop the native HTTP request associated with a background media upload. */ From 1dddbec2bf548237328f727d6f4d1f02cb1525ee Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 18:32:00 +0100 Subject: [PATCH 12/21] Harden deferred upload lifecycle Signed-off-by: kenny lopez --- .../lib/backgroundMediaUploadStore.ts | 27 +++++++++++----- .../messages/ui/ComposerReplyEditBanner.tsx | 3 ++ .../features/messages/ui/MessageComposer.tsx | 32 +++++++++---------- .../features/messages/ui/submitMessageEdit.ts | 21 +++++++++--- .../messages/ui/useMentionSendFlow.ts | 11 +++++-- 5 files changed, 62 insertions(+), 32 deletions(-) diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts index 9949542626..f7076dc979 100644 --- a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts @@ -35,7 +35,10 @@ type BackgroundUploadSnapshot = { type EnqueueBackgroundUploadOptions = { attachments: QueuedMediaAttachment[]; onCancel?: () => void; - onComplete: (descriptors: BlobDescriptor[]) => Promise; + onComplete: ( + descriptors: BlobDescriptor[], + signal: AbortSignal, + ) => Promise; onError: (error: unknown) => void; }; @@ -168,11 +171,14 @@ function finishTask(taskId: number): void { } } -function cancelTask(task: BackgroundUploadTask): void { - if (task.canceled || task.isCompleting) return; +function cancelTask( + task: BackgroundUploadTask, + { force = false, notify = true }: { force?: boolean; notify?: boolean } = {}, +): void { + if (task.canceled || (task.isCompleting && !force)) return; task.canceled = true; task.abortController.abort(); - task.onCancel?.(); + if (notify) task.onCancel?.(); for (let index = 0; index < task.fileProgress.length; index += 1) { void cancelMediaUpload(progressId(task.id, index)).catch(() => undefined); } @@ -203,7 +209,7 @@ export function prepareBackgroundMediaUpload( start: ({ onComplete, onError }) => { if (started) return false; started = true; - void onComplete([]).catch(onError); + void onComplete([], new AbortController().signal).catch(onError); return true; }, }; @@ -264,7 +270,7 @@ export function prepareBackgroundMediaUpload( task.isCompleting = true; task.filePhases.fill("finishing"); rebuildSnapshot(); - await onComplete(descriptors); + await onComplete(descriptors, task.abortController.signal); } } catch (error) { if (!task.canceled) onError(error); @@ -279,11 +285,12 @@ export function prepareBackgroundMediaUpload( export function enqueueBackgroundMediaUpload({ attachments, + onCancel, onComplete, onError, }: EnqueueBackgroundUploadOptions): PreparedBackgroundMediaUpload { const preparedUpload = prepareBackgroundMediaUpload(attachments); - preparedUpload.start({ onComplete, onError }); + preparedUpload.start({ onCancel, onComplete, onError }); return preparedUpload; } @@ -291,7 +298,11 @@ export function cancelBackgroundMediaUploads(): void { for (const task of [...tasks.values()]) cancelTask(task); } -export const resetBackgroundMediaUploads = cancelBackgroundMediaUploads; +export function resetBackgroundMediaUploads(): void { + for (const task of [...tasks.values()]) { + cancelTask(task, { force: true, notify: false }); + } +} function subscribe(listener: () => void): () => void { listeners.add(listener); diff --git a/desktop/src/features/messages/ui/ComposerReplyEditBanner.tsx b/desktop/src/features/messages/ui/ComposerReplyEditBanner.tsx index 5032fa453e..485d11de28 100644 --- a/desktop/src/features/messages/ui/ComposerReplyEditBanner.tsx +++ b/desktop/src/features/messages/ui/ComposerReplyEditBanner.tsx @@ -14,11 +14,13 @@ const BANNER_CLASS = */ export function ComposerReplyEditBanner({ isEditing, + isEditCancelDisabled = false, replyTarget, onCancelEdit, onCancelReply, }: { isEditing: boolean; + isEditCancelDisabled?: boolean; replyTarget?: { author: string; body: string; id: string } | null; onCancelEdit?: () => void; onCancelReply?: () => void; @@ -39,6 +41,7 @@ export function ComposerReplyEditBanner({