mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
Render wave messages as attachments
This commit is contained in:
26
desktop/src/features/messages/lib/waveMessage.ts
Normal file
26
desktop/src/features/messages/lib/waveMessage.ts
Normal 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.",
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
82
desktop/src/features/messages/ui/WaveMessageAttachment.tsx
Normal file
82
desktop/src/features/messages/ui/WaveMessageAttachment.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
196
desktop/src/shared/ui/attachment.tsx
Normal file
196
desktop/src/shared/ui/attachment.tsx
Normal 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,
|
||||
};
|
||||
@@ -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 -->"),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
|
||||
Reference in New Issue
Block a user