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