mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user