diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 780bb56dd8..6d8af80263 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -10,7 +10,8 @@ import { useChannelsQuery, useOpenDmMutation, } from "@/features/channels/hooks"; -import { useUserProfileQuery } from "@/features/profile/hooks"; +import { useProfileQuery, useUserProfileQuery } from "@/features/profile/hooks"; +import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; import { useRelayAgentsQuery, useManagedAgentsQuery, @@ -26,6 +27,7 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { ProfileAvatarWithStatus } from "@/features/profile/ui/ProfileAvatarWithStatus"; import { useAgentSession } from "@/shared/context/AgentSessionContext"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; +import { sendChannelMessage } from "@/shared/api/tauri"; import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; @@ -120,7 +122,7 @@ export function UserProfilePopover({ }: UserProfilePopoverProps) { const [open, setOpen] = React.useState(false); const [pendingAction, setPendingAction] = React.useState< - "message" | "huddle" | null + "message" | "huddle" | "wave" | null >(null); const isMountedRef = React.useRef(false); const hoverTimerRef = React.useRef | null>( @@ -159,11 +161,13 @@ export function UserProfilePopover({ // this only decides whether to paint the "View activity log" button. const isOwner = useIsManagedAgent(role === "bot" ? pubkey : null); const ownerPubkey = profile?.ownerPubkey ?? null; - const currentPubkey = useIdentityQuery().data?.pubkey; + const identityQuery = useIdentityQuery(); + const currentPubkey = identityQuery.data?.pubkey; const isSelf = currentPubkey !== undefined && currentPubkey.toLowerCase() === pubkey.toLowerCase(); const showProfileActions = currentPubkey !== undefined && !isSelf; + const selfProfileQuery = useProfileQuery(open && showProfileActions); const isCurrentUserOwner = currentPubkey !== undefined && ownerPubkey !== null && @@ -294,6 +298,46 @@ export function UserProfilePopover({ startHuddle, ]); + const handleWave = React.useCallback(async () => { + if (!showProfileActions || pendingAction !== null) return; + + clearHoverTimer(); + setPendingAction("wave"); + + try { + const dm = await openDmMutation.mutateAsync({ pubkeys: [pubkey] }); + const senderName = + selfProfileQuery.data?.displayName?.trim() || + (currentPubkey ? truncatePubkey(currentPubkey) : "Someone"); + await sendChannelMessage(dm.id, `${senderName} waved at you.`); + await queryClient.invalidateQueries({ + queryKey: channelMessagesKey(dm.id), + }); + await goChannel(dm.id); + if (isMountedRef.current) { + setOpen(false); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to send wave.", + ); + } finally { + if (isMountedRef.current) { + setPendingAction(null); + } + } + }, [ + clearHoverTimer, + currentPubkey, + goChannel, + openDmMutation, + pendingAction, + pubkey, + queryClient, + selfProfileQuery.data?.displayName, + showProfileActions, + ]); + React.useEffect(() => { isMountedRef.current = true; @@ -440,9 +484,9 @@ export function UserProfilePopover({ ) : null} {showProfileActions ? ( -
+
) : null} diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 1e73743e51..dff92c9726 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -42,6 +42,21 @@ async function readCommandLog(page: import("@playwright/test").Page) { }); } +async function readCommandPayloadLog(page: import("@playwright/test").Page) { + return page.evaluate(() => { + return ( + ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: unknown; + }>; + } + ).__BUZZ_E2E_COMMAND_LOG__ ?? [] + ); + }); +} + function commandCount(commands: string[], command: string) { return commands.filter((entry) => entry === command).length; } @@ -1171,3 +1186,41 @@ test("hovering avatar opens popover, clicking opens profile panel", async ({ await expect(profilePopover).toHaveCount(0); await expect(page.getByTestId("user-profile-panel")).toBeVisible(); }); + +test("profile popover wave sends a direct message", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const aliceMessage = page + .getByTestId("message-row") + .filter({ hasText: "Hey team — checking in." }) + .first(); + await aliceMessage.locator("button").first().hover(); + + const profilePopover = page.locator( + '[data-testid="user-profile-popover"][data-state="open"]', + ); + await expect(profilePopover).toBeVisible(); + await profilePopover + .getByTestId(`user-profile-popover-wave-${TEST_IDENTITIES.alice.pubkey}`) + .click(); + + await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); + await waitForTimelineSettled(page); + await expect(page.getByTestId("message-row").last()).toContainText( + "npub1mock... waved at you.", + ); + + const commandLog = await readCommandPayloadLog(page); + expect(commandLog).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + command: "send_channel_message", + payload: expect.objectContaining({ + content: "npub1mock... waved at you.", + }), + }), + ]), + ); +});