) => {
+ 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 (
+
+
+ 👋
+
+
+ {fallbackText}
+
+ Start a huddle to talk to them.
+
+
+
+
+ Start huddle
+
+
+
+ );
+}
diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx
index 6d8af80263..f4ce80c759 100644
--- a/desktop/src/features/profile/ui/UserProfilePopover.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx
@@ -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 ? (
+
) : null}
>
diff --git a/desktop/src/shared/ui/attachment.tsx b/desktop/src/shared/ui/attachment.tsx
new file mode 100644
index 0000000000..b3765415d3
--- /dev/null
+++ b/desktop/src/shared/ui/attachment.tsx
@@ -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 (
+
+ );
+}
+
+type AttachmentMediaProps = React.ComponentProps<"div"> & {
+ variant?: "icon" | "image";
+};
+
+function AttachmentMedia({
+ className,
+ variant = "icon",
+ ...props
+}: AttachmentMediaProps) {
+ return (
+
+ );
+}
+
+function AttachmentContent({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AttachmentTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AttachmentDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AttachmentActions({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+type AttachmentActionProps = ButtonProps & {
+ asChild?: boolean;
+};
+
+function AttachmentAction({
+ className,
+ size = "icon-xs",
+ variant = "ghost",
+ ...props
+}: AttachmentActionProps) {
+ return (
+
+ );
+}
+
+type AttachmentTriggerProps = React.ComponentProps<"button"> & {
+ asChild?: boolean;
+};
+
+function AttachmentTrigger({
+ asChild,
+ className,
+ type = "button",
+ ...props
+}: AttachmentTriggerProps) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+
+ );
+}
+
+function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export {
+ Attachment,
+ AttachmentAction,
+ AttachmentActions,
+ AttachmentContent,
+ AttachmentDescription,
+ AttachmentGroup,
+ AttachmentMedia,
+ AttachmentTitle,
+ AttachmentTrigger,
+};
diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index dff92c9726..fe15027cef 100644
--- a/desktop/tests/e2e/mentions.spec.ts
+++ b/desktop/tests/e2e/mentions.spec.ts
@@ -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(""),
}),
}),
]),