From 18951b957deb0bfb7910012447739ba3bda7aebc Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 29 Jun 2026 11:11:21 +0100 Subject: [PATCH] Show wave messages optimistically --- desktop/src/features/messages/hooks.ts | 2 +- .../profile/ui/UserProfilePopover.tsx | 114 ++++++++++++++++-- desktop/src/testing/e2eBridge.ts | 8 ++ desktop/tests/e2e/mentions.spec.ts | 7 +- desktop/tests/helpers/bridge.ts | 1 + 5 files changed, 120 insertions(+), 12 deletions(-) diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 4151b1b0b..5d62f1e70 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -118,7 +118,7 @@ export function mergeTimelineCacheMessages( ); } -function createOptimisticMessage( +export function createOptimisticMessage( channelId: string, content: string, identity: Identity, diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 6d48657f9..1d05176d7 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -25,10 +25,17 @@ import { usePresenceQuery } from "@/features/presence/hooks"; import { useUserStatusQuery } from "@/features/user-status/hooks"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { ProfileAvatarWithStatus } from "@/features/profile/ui/ProfileAvatarWithStatus"; +import { + createOptimisticMessage, + mergeTimelineCacheMessages, +} from "@/features/messages/hooks"; import { buildWaveMessageContent } from "@/features/messages/lib/waveMessage"; import { useAgentSession } from "@/shared/context/AgentSessionContext"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { sendChannelMessage } from "@/shared/api/tauri"; +import type { Channel, RelayEvent } from "@/shared/api/types"; +import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; @@ -70,6 +77,43 @@ function InfoBadge({ children }: { children: React.ReactNode }) { ); } +function findCachedOneToOneDm( + channels: Channel[] | undefined, + targetPubkey: string, + currentPubkey: string | undefined, +) { + const normalizedTargetPubkey = normalizePubkey(targetPubkey); + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + + return ( + channels?.find((channel) => { + if (channel.channelType !== "dm") { + return false; + } + + const participantPubkeys = + channel.participantPubkeys.map(normalizePubkey); + if (!participantPubkeys.includes(normalizedTargetPubkey)) { + return false; + } + + const otherParticipantPubkeys = normalizedCurrentPubkey + ? participantPubkeys.filter( + (participantPubkey) => + participantPubkey !== normalizedCurrentPubkey, + ) + : participantPubkeys; + + return ( + otherParticipantPubkeys.length === 1 && + otherParticipantPubkeys[0] === normalizedTargetPubkey + ); + }) ?? null + ); +} + const TEXT_SWAP_BASE_CLASS = "col-start-1 row-start-1 min-w-0 truncate transition-[opacity,filter] duration-[250ms] ease-in-out motion-reduce:transition-none"; const TEXT_SWAP_VISIBLE_CLASS = "opacity-100 blur-0"; @@ -306,17 +350,67 @@ export function UserProfilePopover({ setPendingAction("wave"); try { - const dm = await openDmMutation.mutateAsync({ pubkeys: [pubkey] }); + const identity = identityQuery.data; + if (!identity) { + throw new Error("No identity available for sending messages."); + } + + const dm = + findCachedOneToOneDm(channelsQuery.data, pubkey, currentPubkey) ?? + (await openDmMutation.mutateAsync({ pubkeys: [pubkey] })); const senderName = selfProfileQuery.data?.displayName?.trim() || - (currentPubkey ? truncatePubkey(currentPubkey) : "Someone"); - await sendChannelMessage(dm.id, buildWaveMessageContent(senderName)); - await queryClient.invalidateQueries({ - queryKey: channelMessagesKey(dm.id), - }); - await goChannel(dm.id); - if (isMountedRef.current) { - setOpen(false); + identity.displayName.trim() || + truncatePubkey(identity.pubkey); + const content = buildWaveMessageContent(senderName); + const queryKey = channelMessagesKey(dm.id); + + await queryClient.cancelQueries({ queryKey }); + const previousMessages = + queryClient.getQueryData(queryKey) ?? []; + const optimisticMessage = createOptimisticMessage( + dm.id, + content, + identity, + previousMessages, + ); + + queryClient.setQueryData( + queryKey, + mergeTimelineCacheMessages(previousMessages, optimisticMessage), + ); + + try { + await goChannel(dm.id); + if (isMountedRef.current) { + setOpen(false); + } + + const result = await sendChannelMessage(dm.id, content); + queryClient.setQueryData(queryKey, (current = []) => + mergeTimelineCacheMessages(current, { + id: result.eventId, + localKey: optimisticMessage.id, + pubkey: identity.pubkey, + created_at: result.createdAt, + kind: KIND_STREAM_MESSAGE, + tags: [ + ["h", dm.id], + ["p", identity.pubkey], + ], + content: content.trim(), + sig: "", + }), + ); + } catch (error) { + queryClient.setQueryData(queryKey, (current = []) => + current.filter( + (message) => + message.id !== optimisticMessage.id && + message.localKey !== optimisticMessage.localKey, + ), + ); + throw error; } } catch (error) { toast.error( @@ -328,9 +422,11 @@ export function UserProfilePopover({ } } }, [ + channelsQuery.data, clearHoverTimer, currentPubkey, goChannel, + identityQuery.data, openDmMutation, pendingAction, pubkey, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 8c9d82558..12ad7f13a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -86,6 +86,7 @@ type E2eConfig = { feedReadError?: string; canvasReadError?: string; openDmDelayMs?: number; + sendMessageDelayMs?: number; /** Delay (ms) applied to older-history (`history-` subId) fetches so e2e * tests can observe the in-flight prepend window. 0/undefined = instant. */ historyDelayMs?: number; @@ -5554,6 +5555,13 @@ async function handleSendChannelMessage( config: E2eConfig | undefined, ): Promise { const kind = args.kind ?? 9; + const sendMessageDelayMs = config?.mock?.sendMessageDelayMs ?? 0; + if (sendMessageDelayMs > 0) { + await new Promise((resolve) => + window.setTimeout(resolve, sendMessageDelayMs), + ); + } + // NIP-92 imeta attachments. The real relay echoes these back on the stored // event; mirror that here so attachment renderers (FileCard, images, video) // have the imeta tags they key on. `null`/empty → no extra tags. diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index fe15027ce..c2cb40b92 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1188,6 +1188,8 @@ test("hovering avatar opens popover, clicking opens profile panel", async ({ }); test("profile popover wave sends a direct message", async ({ page }) => { + await installMockBridge(page, { sendMessageDelayMs: 2_500 }); + await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1207,9 +1209,10 @@ test("profile popover wave sends a direct message", async ({ page }) => { .click(); await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); - await waitForTimelineSettled(page); const waveAttachment = page.getByTestId("message-wave-attachment"); - await expect(waveAttachment).toBeVisible(); + await expect(waveAttachment).toBeVisible({ timeout: 1_500 }); + await expect(page.getByText("Sending")).toHaveCount(0, { timeout: 4_000 }); + await waitForTimelineSettled(page); await expect(waveAttachment).toContainText("👋"); await expect(waveAttachment).toContainText("npub1mock... waved at you."); await expect(waveAttachment).toContainText("Start a huddle to talk to them."); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 8d317d7fe..e1340e896 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -110,6 +110,7 @@ type MockBridgeOptions = { feedReadError?: string; canvasReadError?: string; openDmDelayMs?: number; + sendMessageDelayMs?: number; /** Delay (ms) for older-history fetches; see e2eBridge mock config. */ historyDelayMs?: number; profileReadDelayMs?: number;