Show wave messages optimistically

This commit is contained in:
klopez4212
2026-06-29 11:11:21 +01:00
parent 1e9ed79042
commit 18951b957d
5 changed files with 120 additions and 12 deletions
+1 -1
View File
@@ -118,7 +118,7 @@ export function mergeTimelineCacheMessages(
);
}
function createOptimisticMessage(
export function createOptimisticMessage(
channelId: string,
content: string,
identity: Identity,
@@ -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<RelayEvent[]>(queryKey) ?? [];
const optimisticMessage = createOptimisticMessage(
dm.id,
content,
identity,
previousMessages,
);
queryClient.setQueryData<RelayEvent[]>(
queryKey,
mergeTimelineCacheMessages(previousMessages, optimisticMessage),
);
try {
await goChannel(dm.id);
if (isMountedRef.current) {
setOpen(false);
}
const result = await sendChannelMessage(dm.id, content);
queryClient.setQueryData<RelayEvent[]>(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<RelayEvent[]>(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,
+8
View File
@@ -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<RawSendChannelMessageResponse> {
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.
+5 -2
View File
@@ -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.");
+1
View File
@@ -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;