feat(desktop): show sends while attachments prepare

**Category:** feat
**User Impact:** Messages with delayed link previews or deferred attachments appear in the timeline immediately with an inline preparation status.

Stage a send-scoped optimistic row only after destination and audience preflight, then let relay publication adopt the same stable local key. Failure and cancellation remove only that row before composer or draft recovery, preserving unrelated concurrent timeline updates.

Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-08-14 14:05:32 -06:00
co-authored by Carl
parent 757779bb1e
commit ab0e75a9cd
15 changed files with 379 additions and 78 deletions
@@ -125,6 +125,8 @@ export const ChannelPane = React.memo(function ChannelPane({
onResetThreadPanelWidth,
onSelectThreadReplyTarget,
onSendMessage,
onStagePendingSend,
onRemovePendingSend,
onSendToChannel,
onSendVideoReviewComment,
onSendThreadReply,
@@ -712,6 +714,8 @@ export const ChannelPane = React.memo(function ChannelPane({
: undefined
}
onSend={handleSendMessage}
onStagePendingSend={onStagePendingSend}
onRemovePendingSend={onRemovePendingSend}
profiles={profiles}
showBackgroundUploadProgress={false}
placeholder={
@@ -814,6 +818,8 @@ export const ChannelPane = React.memo(function ChannelPane({
onExpandReplies={onExpandThreadReplies}
onSelectReplyTarget={onSelectThreadReplyTarget}
onSend={onSendThreadReply}
onStagePendingSend={onStagePendingSend}
onRemovePendingSend={onRemovePendingSend}
onSendToChannel={
isComposerDisabled ? undefined : onSendToChannel
}
@@ -14,6 +14,7 @@ import type {
} from "@/features/profile/ui/UserProfilePanel";
import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext";
import type { Channel } from "@/shared/api/types";
import type { MessageComposerProps } from "@/features/messages/ui/MessageComposer.types";
export type ChannelPaneProps = {
activeChannel: Channel | null;
activityAgents?: BotActivityAgent[];
@@ -115,7 +116,10 @@ export type ChannelPaneProps = {
threadHeadId: string | null;
} | null,
forceRest?: boolean,
optimisticId?: string,
) => Promise<void>;
onStagePendingSend?: MessageComposerProps["onStagePendingSend"];
onRemovePendingSend?: MessageComposerProps["onRemovePendingSend"];
onSendToChannel: (
message: TimelineMessage,
threadRoot: TimelineMessage,
@@ -919,6 +919,12 @@ export function ChannelScreen({
onOpenThread={handleOpenThreadAndCloseAgentSession}
onSelectThreadReplyTarget={handleSelectThreadReplyTarget}
onSendMessage={handleSendMessage}
onStagePendingSend={
sendMessageMutation.stageOptimisticMessage
}
onRemovePendingSend={
sendMessageMutation.removeOptimisticMessage
}
onSendToChannel={handleSendToChannel}
onSendVideoReviewComment={effectiveSendVideoReviewComment}
onSendThreadReply={handleSendThreadReply}
@@ -289,6 +289,7 @@ export function useChannelPaneHandlers({
threadHeadId: string | null;
} | null,
forceRest?: boolean,
optimisticId?: string,
) => {
await sendMutateRef.current({
content,
@@ -296,6 +297,7 @@ export function useChannelPaneHandlers({
mediaTags,
channelId: channelId ?? undefined,
forceRest,
optimisticId,
});
},
[],
@@ -334,6 +336,7 @@ export function useChannelPaneHandlers({
threadHeadId: string | null;
} | null,
forceRest?: boolean,
optimisticId?: string,
) => {
// Resolve target using captured submit-time context (race-free) or live
// refs (legacy path). When threadContext is supplied, no live-ref reads
@@ -367,6 +370,7 @@ export function useChannelPaneHandlers({
mediaTags,
channelId: channelId ?? undefined,
forceRest,
optimisticId,
});
// Only update thread UI state if the user is still viewing the same
+117 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useEffectEvent } from "react";
import { useCallback, useEffect, useEffectEvent } from "react";
import {
type QueryClient,
useMutation,
@@ -76,10 +76,11 @@ import {
type MessageQueryContext = {
optimisticId: string;
previousMessages: RelayEvent[];
previousWindow: ChannelWindowStore | undefined;
previousMessages?: RelayEvent[];
previousWindow?: ChannelWindowStore;
channelId: string;
queryKey: ReturnType<typeof channelMessagesKey>;
adopted: boolean;
};
const CHANNEL_TIMELINE_KINDS = new Set<number>(CHANNEL_TIMELINE_CONTENT_KINDS);
@@ -434,13 +435,38 @@ export function useChannelSubscription(channel: Channel | null) {
}, [channelId, channelType]);
}
export function removeOptimisticChannelWindowMessage(
queryClient: QueryClient,
channelId: string,
optimisticId: string,
) {
const windowKey = channelWindowKey(channelId);
const current =
queryClient.getQueryData<ChannelWindowStore>(windowKey) ??
emptyChannelWindowStore();
queryClient.setQueryData<RelayEvent[]>(
channelMessagesKey(channelId),
(messages = []) =>
messages.filter(
(event) => event.id !== optimisticId && event.localKey !== optimisticId,
),
);
queryClient.setQueryData(windowKey, {
...current,
liveOverlay: current.liveOverlay.filter(
(event) => event.id !== optimisticId,
),
});
projectChannelWindowMessages(queryClient, channelId);
}
export function useSendMessageMutation(
channel: Channel | null,
identity: Identity | undefined,
) {
const queryClient = useQueryClient();
return useMutation<
const mutation = useMutation<
RelayEvent,
Error,
{
@@ -454,6 +480,8 @@ export function useSendMessageMutation(
sentFromThreadRootId?: string | null;
sentFromThreadRootExcerpt?: string | null;
transport?: "auto" | "http";
/** Adopt a send-scoped pending row that was inserted before preparation. */
optimisticId?: string;
},
MessageQueryContext | undefined
>({
@@ -610,6 +638,7 @@ export function useSendMessageMutation(
mediaTags,
sentFromThreadRootId,
sentFromThreadRootExcerpt,
optimisticId,
}) => {
// Mirror mutationFn's target resolution so the optimistic message lands
// in the cache for the same channel as the real send. A caller-supplied
@@ -632,6 +661,15 @@ export function useSendMessageMutation(
const queryKey = channelMessagesKey(effectiveChannel.id);
await queryClient.cancelQueries({ queryKey });
if (optimisticId) {
return {
optimisticId,
channelId: effectiveChannel.id,
queryKey,
adopted: true,
};
}
const previousMessages =
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
const windowKey = channelWindowKey(effectiveChannel.id);
@@ -662,6 +700,7 @@ export function useSendMessageMutation(
previousWindow,
channelId: effectiveChannel.id,
queryKey,
adopted: false,
};
},
onError: (error, _variables, context) => {
@@ -673,7 +712,19 @@ export function useSendMessageMutation(
return;
}
queryClient.setQueryData(context.queryKey, context.previousMessages);
if (context.adopted) {
removeOptimisticChannelWindowMessage(
queryClient,
context.channelId,
context.optimisticId,
);
return;
}
queryClient.setQueryData(
context.queryKey,
context.previousMessages ?? [],
);
queryClient.setQueryData(
channelWindowKey(context.channelId),
context.previousWindow,
@@ -705,6 +756,67 @@ export function useSendMessageMutation(
projectChannelWindowMessages(queryClient, context.channelId);
},
});
const stageOptimisticMessage = useCallback(
({
channelId: capturedChannelId,
content,
mentionPubkeys = [],
parentEventId = null,
mediaTags = [],
}: {
channelId?: string | null;
content: string;
mentionPubkeys?: string[];
parentEventId?: string | null;
mediaTags?: string[][];
}): string | null => {
const effectiveChannel = resolveSendChannel(
undefined,
capturedChannelId,
queryClient.getQueryData<Channel[]>(channelsQueryKey),
channel,
);
if (!effectiveChannel || !identity) return null;
const queryKey = channelMessagesKey(effectiveChannel.id);
const currentMessages =
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
const optimisticMessage = createOptimisticMessage(
effectiveChannel.id,
content.trim(),
identity,
currentMessages,
mentionPubkeys,
parentEventId,
mediaTags,
);
const windowKey = channelWindowKey(effectiveChannel.id);
const currentWindow =
queryClient.getQueryData<ChannelWindowStore>(windowKey) ??
emptyChannelWindowStore();
queryClient.setQueryData(
windowKey,
mergeLiveChannelWindowEvent(currentWindow, optimisticMessage),
);
projectChannelWindowMessages(queryClient, effectiveChannel.id);
return optimisticMessage.id;
},
[channel, identity, queryClient],
);
const removeOptimisticMessage = useCallback(
(channelId: string, optimisticId: string) => {
removeOptimisticChannelWindowMessage(
queryClient,
channelId,
optimisticId,
);
},
[queryClient],
);
return { ...mutation, removeOptimisticMessage, stageOptimisticMessage };
}
export function useToggleReactionMutation() {
@@ -2,7 +2,10 @@ import assert from "node:assert/strict";
import test from "node:test";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { reconcileFetchedChannelWindow } from "../hooks.ts";
import {
reconcileFetchedChannelWindow,
removeOptimisticChannelWindowMessage,
} from "../hooks.ts";
import { channelMessagesKey, channelWindowKey } from "./messageQueryKeys.ts";
import {
appendOlderChannelWindow,
@@ -234,6 +237,39 @@ test("test_reconciliation_retains_identical_pending_sends", () => {
);
});
test("test_failed_identical_pending_send_removes_only_its_stable_key", () => {
const harness = createHarness();
const older = {
...event("older-pending", 110),
content: "hello",
pending: true,
};
const newer = {
...event("newer-pending", 111),
content: "hello",
pending: true,
};
appendLiveEvent(harness, older);
appendLiveEvent(harness, newer);
removeOptimisticChannelWindowMessage(
harness.client,
harness.channelId,
older.id,
);
assert.deepEqual(
harness.client.getQueryData(harness.messagesKey).map((item) => item.id),
[event("initial", 100), newer].map((item) => item.id),
);
assert.deepEqual(
harness.client
.getQueryData(harness.windowKey)
.liveOverlay.map((item) => item.id),
[newer.id],
);
});
test("test_reconciliation_acknowledges_only_one_identical_pending_send", () => {
const harness = createHarness();
const first = {
@@ -83,6 +83,8 @@ function MessageComposerImpl({
onPrepareSendChannel,
onPreparingMentionSendChange,
onSend,
onStagePendingSend,
onRemovePendingSend,
placeholder,
profiles,
replyTarget = null,
@@ -312,6 +314,8 @@ function MessageComposerImpl({
mentions,
onPrepareSendChannel,
onSendRef,
onStagePendingSend,
onRemovePendingSend,
richText,
setContent: setComposerContent,
setIsEmojiPickerOpen,
@@ -808,11 +812,9 @@ function MessageComposerImpl({
media.pendingImeta.length === 0 &&
media.queuedAttachments.length === 0);
const handleCaptureSelection = React.useCallback(() => {}, []);
const handlePaperclipClick = React.useCallback(() => {
void media.handlePaperclip();
}, [media.handlePaperclip]);
const handleRemoveAttachment = React.useCallback(
(url: string) => {
setSpoileredAttachmentUrls((current) => {
@@ -825,14 +827,12 @@ function MessageComposerImpl({
},
[media.removeAttachment],
);
const { handleAttachmentEditSave, handleAttachmentRevert } =
useAttachmentEditing({
revertAttachment: media.revertAttachment,
setSpoileredAttachmentUrls,
uploadEditedAttachment: media.uploadEditedAttachment,
});
const handleToggleAttachmentSpoiler = React.useCallback((url: string) => {
setSpoileredAttachmentUrls((current) => {
const next = new Set(current);
@@ -93,7 +93,17 @@ export type MessageComposerProps = {
} | null,
/** Route through the REST publisher even when best-effort enrichment settled empty. */
forceRest?: boolean,
/** Stable key of a pending row inserted before preparation. */
optimisticId?: string,
) => Promise<void>;
onStagePendingSend?: (input: {
channelId: string;
content: string;
mentionPubkeys: string[];
parentEventId: string | null;
mediaTags: string[][];
}) => string | null;
onRemovePendingSend?: (channelId: string, optimisticId: string) => void;
placeholder?: string;
profiles?: UserProfileLookup;
replyTarget?: {
@@ -56,6 +56,7 @@ import {
} from "./MessageHeader";
import { MessageTimestamp } from "./MessageTimestamp";
import { SentFromThreadLine } from "./SentFromThreadLine";
import { PendingMessagePreparation } from "./PendingMessagePreparation";
import { WaveMessageAttachment } from "./WaveMessageAttachment";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
@@ -662,6 +663,7 @@ export const MessageRow = React.memo(
<>
<SentFromThreadLine channelId={channelId} tags={message.tags} />
{renderBody()}
<PendingMessagePreparation message={message} />
{continuationMetadataNode}
<MessageReactions
messageId={message.id}
@@ -1,6 +1,5 @@
import * as React from "react";
import { ArrowDown } from "lucide-react";
import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys";
import { HuddleTranscriptIntro } from "@/features/huddle/components/HuddleTranscriptIntro";
import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys";
@@ -15,7 +14,10 @@ import {
hasSameMessageAuthor,
isWithinGroupingWindow,
} from "@/features/messages/lib/messageGrouping";
import type { MessageComposerEditTarget } from "@/features/messages/ui/MessageComposer.types";
import type {
MessageComposerEditTarget,
MessageComposerProps,
} from "@/features/messages/ui/MessageComposer.types";
import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage";
import type { TimelineMessage } from "@/features/messages/types";
import type { VideoReviewPresentation } from "@/features/messages/lib/videoReviewContext";
@@ -52,7 +54,6 @@ import { useComposerHeightPadding } from "./useComposerHeightPadding";
import { useStableSendToChannel } from "./useStableSendToChannel";
import { useAnchoredScroll } from "./useAnchoredScroll";
import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot";
type MessageThreadPanelProps = ThreadPanelLayoutProps & {
channel: Channel | null;
channelId: string | null;
@@ -94,7 +95,10 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
threadHeadId: string | null;
} | null,
forceRest?: boolean,
optimisticId?: string,
) => Promise<void>;
onStagePendingSend?: MessageComposerProps["onStagePendingSend"];
onRemovePendingSend?: MessageComposerProps["onRemovePendingSend"];
onSendToChannel?: (
message: TimelineMessage,
threadRoot: TimelineMessage,
@@ -132,10 +136,8 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
/** Called when the thread-composer auto-submit fires so the parent can clear the trigger. */
onAutoSubmitComplete?: () => void;
};
const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = [];
const THREAD_PANEL_SUMMARY_INDENT_OFFSET_REM = 0;
function hasLaterVisibleSibling(
entries: readonly MainTimelineEntry[],
entryIndex: number,
@@ -144,17 +146,14 @@ function hasLaterVisibleSibling(
if (depth == null) {
return false;
}
for (let index = entryIndex + 1; index < entries.length; index += 1) {
const nextDepth = entries[index].message.depth;
if (nextDepth <= depth) {
return nextDepth === depth;
}
}
return false;
}
function getActiveContinuationDepths({
ancestors,
entries,
@@ -167,12 +166,10 @@ function getActiveContinuationDepths({
message: TimelineMessage;
}): number[] {
const depths: number[] = [];
for (const ancestor of ancestors) {
if (ancestor.message.depth === 0) {
continue;
}
const childDepth = ancestor.message.depth + 1;
const pathChild =
message.depth === childDepth
@@ -221,6 +218,8 @@ export function MessageThreadPanel({
onScrollTargetSettled,
onSelectReplyTarget,
onSend,
onStagePendingSend,
onRemovePendingSend,
onSendToChannel,
onToggleReaction,
onUnfollowThread,
@@ -911,6 +910,7 @@ export function MessageThreadPanel({
onEditLastOwnMessage={onEditLastOwnMessage}
onEditSave={onEditSave}
onSend={onSend}
{...{ onStagePendingSend, onRemovePendingSend }}
placeholder={
isHuddleTranscript
? "Message the huddle"
@@ -0,0 +1,43 @@
import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
type PendingMessagePreparationProps = {
message: TimelineMessage;
};
export const PendingMessagePreparation = React.memo(
function PendingMessagePreparation({
message,
}: PendingMessagePreparationProps) {
const pending = message.pending
? (message.tags ?? []).filter((tag) => tag[0] === "client-pending")
: [];
if (pending.length === 0) return null;
return (
<div
aria-live="polite"
className="mt-2 flex max-w-sm flex-col gap-1.5"
data-testid="message-preparation-status"
>
{pending.map((tag, index) => (
<div
className="flex items-center gap-2 rounded-lg border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground"
key={`${tag[1]}-${tag[2] ?? index}`}
>
<span
aria-hidden="true"
className="size-3 shrink-0 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground"
/>
<span className="truncate">
{tag[1] === "link-preview"
? "Preparing link preview…"
: `Preparing ${tag[2] || "attachment"}…`}
</span>
</div>
))}
</div>
);
},
);
@@ -20,21 +20,14 @@ import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/eff
import {
prepareBackgroundMediaUpload,
saveQueuedAttachmentsForDraft,
type QueuedMediaAttachment,
} from "@/features/messages/lib/backgroundMediaUploadStore";
import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks";
import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete";
import {
buildOutgoingMessage,
type ImetaMedia,
} from "@/features/messages/lib/imetaMediaMarkdown";
import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor";
import type { UseDraftsResult } from "@/features/messages/lib/useDrafts";
import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews";
import { invokeTauri } from "@/shared/api/tauri";
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types";
import type { AcpRuntime, ManagedAgent } from "@/shared/api/types";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags";
import {
@@ -48,50 +41,7 @@ import {
resolvePreviewTags,
uniqueNormalizedPubkeys,
} from "./useMentionSendFlow.helpers";
type UseMentionSendFlowOptions = {
channelId: string | null;
channelLinks: Pick<UseChannelLinksResult, "clearChannels">;
channelType: ChannelType | null;
contentRef: React.MutableRefObject<string>;
customEmoji: CustomEmoji[];
drafts: Pick<UseDraftsResult, "loadDraft" | "markDraftSent" | "persistDraft">;
emojiAutocomplete: Pick<UseEmojiAutocompleteResult, "clearEmojis">;
mentions: UseMentionsResult;
onPrepareSendChannel?: (pubkeys?: string[]) => Promise<string | null>;
onSendRef: React.MutableRefObject<
(
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
channelId?: string | null,
threadContext?: {
parentEventId: string | null;
threadHeadId: string | null;
} | null,
forceRest?: boolean,
) => Promise<void>
>;
richText: Pick<
UseRichTextEditorResult,
"clearContent" | "setContent" | "restorePlainTextAndFocusEnd"
>;
setContent: (content: string) => void;
setIsEmojiPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
setPendingImeta: (pendingImeta: ImetaMedia[]) => void;
hasUnsavedMedia: () => boolean;
clearQueuedAttachments: () => void;
restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void;
setSpoileredAttachmentUrls?: React.Dispatch<
React.SetStateAction<Set<string>>
>;
onSuccessfulExplicitAgentAudience?: (audience: {
channelId: string;
expectedGeneration: number;
expectedRevision: number | null;
explicitAgentPubkeys: string[];
}) => void;
resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string;
};
import type { UseMentionSendFlowOptions } from "./useMentionSendFlow.types";
export function useMentionSendFlow({
channelId,
channelLinks,
@@ -103,6 +53,8 @@ export function useMentionSendFlow({
mentions,
onPrepareSendChannel,
onSendRef,
onStagePendingSend,
onRemovePendingSend,
richText,
setContent,
setIsEmojiPickerOpen,
@@ -319,7 +271,6 @@ export function useMentionSendFlow({
provisionPersonaAgentMutation,
],
);
const clearComposer = React.useCallback(
(postSendContent = "") => {
setPendingNonMemberSend(null);
@@ -494,6 +445,18 @@ export function useMentionSendFlow({
mentionPubkeys,
);
const send = onSendRef.current;
let optimisticId: string | null = null;
const removePendingSend = () => {
if (optimisticId && sendChannelId) {
onRemovePendingSend?.(sendChannelId, optimisticId);
optimisticId = null;
}
};
const stopCancelledSend = () => {
if (!isSendCancelled()) return false;
removePendingSend();
return true;
};
const persistCanceledDraft = () => {
if (isSendCancelled() || !draft.recoveryDraftKey) return;
const existing = drafts.loadDraft(draft.recoveryDraftKey);
@@ -519,6 +482,7 @@ export function useMentionSendFlow({
);
};
const restoreComposerAfterFailure = () => {
removePendingSend();
if (isSendCancelled()) return;
persistCanceledDraft();
const canRestoreCurrentComposer =
@@ -567,11 +531,14 @@ export function useMentionSendFlow({
mediaTags,
outgoingTags,
);
if (!finalOutgoingTags || signal?.aborted || isSendCancelled())
if (!finalOutgoingTags) {
removePendingSend();
return;
}
if (signal?.aborted || stopCancelledSend()) return;
const revalidatedMentionPubkeys =
await mentions.revalidateMentionPubkeys(mentionPubkeys);
if (signal?.aborted || isSendCancelled()) return;
if (signal?.aborted || stopCancelledSend()) return;
const revalidatedExplicitAgentPubkeys =
filterEffectiveExplicitAgentPubkeys(
draft.explicitAgentPubkeys,
@@ -584,8 +551,9 @@ export function useMentionSendFlow({
sendChannelId,
draft.capturedThreadContext,
draft.preparedLinkPreviews != null,
optimisticId ?? undefined,
);
if (signal?.aborted || isSendCancelled()) return;
if (signal?.aborted || stopCancelledSend()) return;
if (revalidatedExplicitAgentPubkeys.length > 0) {
onSuccessfulExplicitAgentAudience?.({
channelId: sendChannelId ?? draft.capturedChannelId ?? "",
@@ -604,6 +572,36 @@ export function useMentionSendFlow({
);
}
};
if (!optimisticId && sendChannelId) {
const initialMessage = buildOutgoingMessage(
draft.trimmed,
draft.savedImeta,
draft.savedSpoileredAttachmentUrls,
);
const pendingPreparationTags = [
...(draft.preparedLinkPreviews
? [["client-pending", "link-preview"]]
: []),
...draft.queuedAttachments.map((attachment) => [
"client-pending",
"media",
attachment.file.name,
attachment.file.type,
]),
];
optimisticId =
onStagePendingSend?.({
channelId: sendChannelId,
content: initialMessage.content,
mentionPubkeys,
parentEventId: draft.capturedThreadContext?.parentEventId ?? null,
mediaTags: [
...(initialMessage.mediaTags ?? []),
...(outgoingTags ?? []),
...pendingPreparationTags,
],
}) ?? null;
}
if (preparedUpload) {
uploadStarted = preparedUpload.start({
onComplete: async (uploaded, signal) => {
@@ -665,6 +663,8 @@ export function useMentionSendFlow({
mentions.revalidateMentionPubkeys,
onPrepareSendChannel,
onSendRef,
onStagePendingSend,
onRemovePendingSend,
onSuccessfulExplicitAgentAudience,
resolvePostSendContent,
richText.setContent,
@@ -0,0 +1,65 @@
import type * as React from "react";
import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks";
import type { UseDraftsResult } from "@/features/messages/lib/useDrafts";
import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete";
import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor";
import type { ChannelType } from "@/shared/api/types";
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
export type UseMentionSendFlowOptions = {
channelId: string | null;
channelLinks: Pick<UseChannelLinksResult, "clearChannels">;
channelType: ChannelType | null;
contentRef: React.MutableRefObject<string>;
customEmoji: CustomEmoji[];
drafts: Pick<UseDraftsResult, "loadDraft" | "markDraftSent" | "persistDraft">;
emojiAutocomplete: Pick<UseEmojiAutocompleteResult, "clearEmojis">;
mentions: UseMentionsResult;
onPrepareSendChannel?: (pubkeys?: string[]) => Promise<string | null>;
onSendRef: React.MutableRefObject<
(
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
channelId?: string | null,
threadContext?: {
parentEventId: string | null;
threadHeadId: string | null;
} | null,
forceRest?: boolean,
optimisticId?: string,
) => Promise<void>
>;
onStagePendingSend?: (input: {
channelId: string;
content: string;
mentionPubkeys: string[];
parentEventId: string | null;
mediaTags: string[][];
}) => string | null;
onRemovePendingSend?: (channelId: string, optimisticId: string) => void;
richText: Pick<
UseRichTextEditorResult,
"clearContent" | "setContent" | "restorePlainTextAndFocusEnd"
>;
setContent: (content: string) => void;
setIsEmojiPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
setPendingImeta: (pendingImeta: ImetaMedia[]) => void;
hasUnsavedMedia: () => boolean;
clearQueuedAttachments: () => void;
restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void;
setSpoileredAttachmentUrls?: React.Dispatch<
React.SetStateAction<Set<string>>
>;
onSuccessfulExplicitAgentAudience?: (audience: {
channelId: string;
expectedGeneration: number;
expectedRevision: number | null;
explicitAgentPubkeys: string[];
}) => void;
resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string;
};
+9 -1
View File
@@ -386,8 +386,16 @@ test("canceling a background upload prevents the message from publishing", async
await chooseLargeVideo(page);
await page.getByTestId("send-message").click();
const pendingRow = page.getByTestId("message-row").last();
await expect(
pendingRow.getByTestId("message-preparation-status"),
).toContainText("Preparing large-video.mp4…");
const pendingMessageId = await pendingRow.getAttribute("data-message-id");
expect(pendingMessageId).not.toBeNull();
await page.getByTestId("composer-upload-cancel").click();
await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0);
await expect(
page.locator(`[data-message-id="${pendingMessageId}"]`),
).toHaveCount(0);
await page.waitForTimeout(1_100);
await expect(page.getByTestId("file-card")).toHaveCount(0);
});
+6 -1
View File
@@ -979,7 +979,12 @@ test("Enter during an in-flight snapshot upload hands off and sends once", async
await expect(card).toHaveAttribute("data-snapshot-tag-ready", "false");
await input.press("Enter");
await expect(input).toHaveText("");
const pendingRow = page.getByTestId("message-row").last();
await expect(pendingRow).toContainText(previewUrl);
await expect(
pendingRow.getByTestId("message-preparation-status"),
).toContainText("Preparing link preview…");
const progress = page.getByTestId("composer-upload-progress");
await expect(progress).toHaveAccessibleName("Preparing link preview");
await expect(page.getByTestId("composer-upload-cancel")).toHaveText("Skip");