Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ export const ChannelPane = React.memo(function ChannelPane({
onResetThreadPanelWidth,
onSelectThreadReplyTarget,
onSendMessage,
onStagePendingSend,
onRemovePendingSend,
onSendToChannel,
onSendVideoReviewComment,
onSendThreadReply,
Expand Down Expand Up @@ -712,6 +714,8 @@ export const ChannelPane = React.memo(function ChannelPane({
: undefined
}
onSend={handleSendMessage}
onStagePendingSend={onStagePendingSend}
onRemovePendingSend={onRemovePendingSend}
profiles={profiles}
showBackgroundUploadProgress={false}
placeholder={
Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/features/channels/useChannelPaneHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,13 +289,15 @@ export function useChannelPaneHandlers({
threadHeadId: string | null;
} | null,
forceRest?: boolean,
optimisticId?: string,
) => {
await sendMutateRef.current({
content,
mentionPubkeys,
mediaTags,
channelId: channelId ?? undefined,
forceRest,
optimisticId,
});
},
[],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
122 changes: 117 additions & 5 deletions desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useEffectEvent } from "react";
import { useCallback, useEffect, useEffectEvent } from "react";
import {
type QueryClient,
useMutation,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
{
Expand All @@ -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
>({
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -662,6 +700,7 @@ export function useSendMessageMutation(
previousWindow,
channelId: effectiveChannel.id,
queryKey,
adopted: false,
};
},
onError: (error, _variables, context) => {
Expand All @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import {
cancelStartedMediaUploads,
dispatchTrackedMediaUpload,
prepareBackgroundMediaUpload,
} from "./backgroundMediaUploadStore.ts";

const descriptor = {
Expand All @@ -22,6 +23,27 @@ function deferred() {
return { promise, resolve };
}

test("reports cancellation before a prepared upload starts", () => {
const prepared = prepareBackgroundMediaUpload([
{
file: new File(["video"], "large-video.mp4", { type: "video/mp4" }),
id: 1,
spoilered: false,
},
]);

assert.equal(prepared.isCanceled(), false);
prepared.cancel();
assert.equal(prepared.isCanceled(), true);
assert.equal(
prepared.start({
onComplete: async () => {},
onError: () => {},
}),
false,
);
});

test("cancels only uploads whose native commands were dispatched", async () => {
const releaseUpload = deferred();
const startedProgressIds = new Map();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type StartBackgroundUploadOptions = Omit<

export type PreparedBackgroundMediaUpload = {
cancel: () => void;
isCanceled: () => boolean;
start: (options: StartBackgroundUploadOptions) => boolean;
};

Expand Down Expand Up @@ -243,6 +244,7 @@ export function prepareBackgroundMediaUpload(
let started = false;
return {
cancel: () => undefined,
isCanceled: () => false,
start: ({ onComplete, onError }) => {
if (started) return false;
started = true;
Expand Down Expand Up @@ -274,6 +276,7 @@ export function prepareBackgroundMediaUpload(
cancel: () => {
cancelTask(task);
},
isCanceled: () => task.canceled,
start: ({ onCancel, onComplete, onError }) => {
if (started || task.canceled) return false;
started = true;
Expand Down
8 changes: 4 additions & 4 deletions desktop/src/features/messages/lib/channelWindowStore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RelayEvent } from "@/shared/api/types";
import { reconcileIncomingMessage } from "./messageMerge";

export type ChannelWindowCursor = { createdAt: number; eventId: string };
export type ChannelWindowThreadSummary = {
Expand Down Expand Up @@ -214,10 +215,9 @@ export function mergeLiveChannelWindowEvent(
}
return {
...current,
liveOverlay: current.liveOverlay
.filter((candidate) => candidate.id !== event.id)
.concat(event)
.sort(compareRelayOrder),
liveOverlay: reconcileIncomingMessage(current.liveOverlay, event).sort(
compareRelayOrder,
),
};
}

Expand Down
Loading
Loading