Render wave messages as attachments

This commit is contained in:
klopez4212
2026-06-29 10:47:38 +01:00
parent 01290d3ba7
commit cbeecede0a
6 changed files with 364 additions and 30 deletions

View File

@@ -0,0 +1,26 @@
export const WAVE_MESSAGE_MARKER = "<!-- buzz:wave:v1 -->";
export type WaveMessageContent = {
fallbackText: string;
};
export function buildWaveMessageContent(senderName: string): string {
const trimmedName = senderName.trim() || "Someone";
return `${WAVE_MESSAGE_MARKER}\n${trimmedName} waved at you.`;
}
export function parseWaveMessageContent(
content: string,
): WaveMessageContent | null {
const trimmedContent = content.trimStart();
if (!trimmedContent.startsWith(WAVE_MESSAGE_MARKER)) {
return null;
}
const fallbackText = trimmedContent.slice(WAVE_MESSAGE_MARKER.length).trim();
return {
fallbackText: fallbackText || "Someone waved at you.",
};
}

View File

@@ -22,6 +22,7 @@ import { UserAvatar } from "@/shared/ui/UserAvatar";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
import { parseWaveMessageContent } from "@/features/messages/lib/waveMessage";
import {
resolveMentionNames,
resolveMentionPubkeysByName,
@@ -31,6 +32,7 @@ import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
import { MessageActionBar } from "./MessageActionBar";
import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader";
import { MessageTimestamp } from "./MessageTimestamp";
import { WaveMessageAttachment } from "./WaveMessageAttachment";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
const DiffMessage = React.lazy(() => import("./DiffMessage"));
@@ -275,6 +277,18 @@ export const MessageRow = React.memo(
</React.Suspense>
);
default:
{
const waveMessage = parseWaveMessageContent(message.body);
if (waveMessage) {
return (
<WaveMessageAttachment
channelId={channelId}
fallbackText={waveMessage.fallbackText}
/>
);
}
}
return (
<Markdown
channelNames={channelNames}

View File

@@ -0,0 +1,82 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { channelsQueryKey } from "@/features/channels/hooks";
import { useHuddle } from "@/features/huddle";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/shared/ui/attachment";
type WaveMessageAttachmentProps = {
channelId?: string | null;
fallbackText: string;
};
export function WaveMessageAttachment({
channelId,
fallbackText,
}: WaveMessageAttachmentProps) {
const queryClient = useQueryClient();
const { isStarting, startHuddle } = useHuddle();
const handleStartHuddle = React.useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (!channelId || isStarting) {
return;
}
try {
await startHuddle(channelId, []);
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to start huddle.",
);
}
},
[channelId, isStarting, queryClient, startHuddle],
);
return (
<Attachment
className="mt-1 max-w-md"
data-testid="message-wave-attachment"
size="default"
>
<AttachmentMedia
aria-hidden="true"
className="bg-primary/10 text-2xl text-foreground"
variant="image"
>
👋
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{fallbackText}</AttachmentTitle>
<AttachmentDescription>
Start a huddle to talk to them.
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
disabled={!channelId || isStarting}
onClick={handleStartHuddle}
size="xs"
type="button"
variant="outline"
>
Start huddle
</AttachmentAction>
</AttachmentActions>
</Attachment>
);
}

View File

@@ -25,6 +25,7 @@ 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 { buildWaveMessageContent } from "@/features/messages/lib/waveMessage";
import { useAgentSession } from "@/shared/context/AgentSessionContext";
import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
import { sendChannelMessage } from "@/shared/api/tauri";
@@ -309,7 +310,7 @@ export function UserProfilePopover({
const senderName =
selfProfileQuery.data?.displayName?.trim() ||
(currentPubkey ? truncatePubkey(currentPubkey) : "Someone");
await sendChannelMessage(dm.id, `${senderName} waved at you.`);
await sendChannelMessage(dm.id, buildWaveMessageContent(senderName));
await queryClient.invalidateQueries({
queryKey: channelMessagesKey(dm.id),
});
@@ -485,6 +486,31 @@ export function UserProfilePopover({
) : null}
{showProfileActions ? (
<div className="flex gap-2">
<Button
aria-label="Wave"
className="shrink-0 px-3"
data-testid={`user-profile-popover-wave-${pubkey}`}
disabled={
pendingAction !== null || openDmMutation.isPending
}
onClick={() => {
void handleWave();
}}
size="sm"
type="button"
variant="outline"
>
{pendingAction === "wave" ? (
<Spinner
aria-hidden="true"
className="h-3.5 w-3.5 border-2"
/>
) : (
<span aria-hidden="true" className="text-sm leading-none">
👋
</span>
)}
</Button>
<Button
className="min-w-0 flex-1"
data-testid={`user-profile-popover-message-${pubkey}`}
@@ -533,31 +559,6 @@ export function UserProfilePopover({
)}
Huddle
</Button>
<Button
className="shrink-0 px-3"
data-testid={`user-profile-popover-wave-${pubkey}`}
disabled={
pendingAction !== null || openDmMutation.isPending
}
onClick={() => {
void handleWave();
}}
size="sm"
type="button"
variant="outline"
>
{pendingAction === "wave" ? (
<Spinner
aria-hidden="true"
className="h-3.5 w-3.5 border-2"
/>
) : (
<span aria-hidden="true" className="text-sm leading-none">
👋
</span>
)}
Wave
</Button>
</div>
) : null}
</>

View File

@@ -0,0 +1,196 @@
import type * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/shared/lib/cn";
import { Button, type ButtonProps } from "@/shared/ui/button";
type AttachmentProps = React.ComponentProps<"div"> & {
orientation?: "horizontal" | "vertical";
size?: "default" | "sm" | "xs";
state?: "idle" | "uploading" | "processing" | "error" | "done";
};
function Attachment({
className,
orientation = "horizontal",
size = "default",
state = "done",
...props
}: AttachmentProps) {
return (
<div
className={cn(
"group/attachment relative flex min-w-0 gap-3 overflow-hidden rounded-lg border border-border/70 bg-muted/30 text-left transition-colors",
"hover:border-border hover:bg-muted/50 data-[state=error]:border-destructive/40 data-[state=error]:bg-destructive/10",
"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
orientation === "horizontal" && "items-center",
orientation === "vertical" && "flex-col",
size === "default" && "px-3 py-2.5",
size === "sm" && "px-2.5 py-2",
size === "xs" && "gap-2 px-2 py-1.5",
className,
)}
data-orientation={orientation}
data-slot="attachment"
data-state={state}
{...props}
/>
);
}
type AttachmentMediaProps = React.ComponentProps<"div"> & {
variant?: "icon" | "image";
};
function AttachmentMedia({
className,
variant = "icon",
...props
}: AttachmentMediaProps) {
return (
<div
className={cn(
"flex shrink-0 items-center justify-center overflow-hidden rounded-md bg-background text-muted-foreground",
variant === "icon" && "h-9 w-9",
variant === "image" && "aspect-square h-12 w-12",
"[&_svg]:h-4 [&_svg]:w-4",
className,
)}
data-slot="attachment-media"
data-variant={variant}
{...props}
/>
);
}
function AttachmentContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("min-w-0 flex-1", className)}
data-slot="attachment-content"
{...props}
/>
);
}
function AttachmentTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"truncate text-sm font-semibold leading-5 text-foreground group-data-[state=processing]/attachment:animate-pulse group-data-[state=uploading]/attachment:animate-pulse",
className,
)}
data-slot="attachment-title"
{...props}
/>
);
}
function AttachmentDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn(
"truncate text-xs leading-4 text-muted-foreground",
className,
)}
data-slot="attachment-description"
{...props}
/>
);
}
function AttachmentActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn(
"relative z-20 flex shrink-0 items-center gap-1",
className,
)}
data-slot="attachment-actions"
{...props}
/>
);
}
type AttachmentActionProps = ButtonProps & {
asChild?: boolean;
};
function AttachmentAction({
className,
size = "icon-xs",
variant = "ghost",
...props
}: AttachmentActionProps) {
return (
<Button
className={cn(
"relative z-20 text-muted-foreground hover:bg-muted hover:text-foreground",
className,
)}
data-slot="attachment-action"
size={size}
variant={variant}
{...props}
/>
);
}
type AttachmentTriggerProps = React.ComponentProps<"button"> & {
asChild?: boolean;
};
function AttachmentTrigger({
asChild,
className,
type = "button",
...props
}: AttachmentTriggerProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(
"absolute inset-0 z-10 rounded-lg focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
className,
)}
data-slot="attachment-trigger"
type={asChild ? undefined : type}
{...props}
/>
);
}
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex min-w-0 gap-2 overflow-x-auto overscroll-x-contain pb-1",
className,
)}
data-slot="attachment-group"
{...props}
/>
);
}
export {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
};

View File

@@ -1208,9 +1208,14 @@ test("profile popover wave sends a direct message", async ({ page }) => {
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 waveAttachment = page.getByTestId("message-wave-attachment");
await expect(waveAttachment).toBeVisible();
await expect(waveAttachment).toContainText("👋");
await expect(waveAttachment).toContainText("npub1mock... waved at you.");
await expect(waveAttachment).toContainText("Start a huddle to talk to them.");
await expect(
waveAttachment.getByRole("button", { name: "Start huddle" }),
).toBeVisible();
const commandLog = await readCommandPayloadLog(page);
expect(commandLog).toEqual(
@@ -1218,7 +1223,17 @@ test("profile popover wave sends a direct message", async ({ page }) => {
expect.objectContaining({
command: "send_channel_message",
payload: expect.objectContaining({
content: "npub1mock... waved at you.",
content: expect.stringContaining("npub1mock... waved at you."),
}),
}),
]),
);
expect(commandLog).toEqual(
expect.arrayContaining([
expect.objectContaining({
command: "send_channel_message",
payload: expect.objectContaining({
content: expect.stringContaining("<!-- buzz:wave:v1 -->"),
}),
}),
]),