mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
refactor(desktop): reuse composer in Bestie handoff
Co-authored-by: Arjun Mahanti <arjun@squareup.com> Signed-off-by: Arjun Mahanti <arjun@squareup.com> Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { Send } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { pickBestieAgent } from "@/features/agents/lib/bestie";
|
||||
@@ -10,12 +9,14 @@ import { buildMessageLink } from "@/features/messages/lib/messageLink";
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
import { useSendMessageMutation } from "@/features/messages/hooks";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { VideoReviewCommentMarkdown } from "@/shared/ui/VideoReviewCommentMarkdown";
|
||||
|
||||
const ACTION_BUTTON_CLASS = "h-8 w-8 rounded-full p-0";
|
||||
|
||||
@@ -35,7 +36,6 @@ export function BestieMessagePopover({
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const openDmMutation = useOpenDmMutation();
|
||||
const sendMessageMutation = useSendMessageMutation(null, identityQuery.data);
|
||||
const [note, setNote] = React.useState("");
|
||||
const bestie = React.useMemo(
|
||||
() =>
|
||||
pickBestieAgent(managedAgentsQuery.data ?? [], activeCommunity?.relayUrl),
|
||||
@@ -45,7 +45,11 @@ export function BestieMessagePopover({
|
||||
|
||||
if (!bestie) return null;
|
||||
|
||||
const submit = async () => {
|
||||
const submit = async (
|
||||
note: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
) => {
|
||||
const { rootId } = getThreadReference(message.tags ?? []);
|
||||
const link = buildMessageLink({
|
||||
channelId,
|
||||
@@ -65,14 +69,16 @@ export function BestieMessagePopover({
|
||||
});
|
||||
await sendMessageMutation.mutateAsync({
|
||||
content,
|
||||
mediaTags,
|
||||
mentionPubkeys,
|
||||
targetChannel: dm,
|
||||
});
|
||||
setNote("");
|
||||
onOpenChange(false);
|
||||
toast.success(`Sent to ${bestie.name}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to send message to Bestie", error);
|
||||
toast.error(`Couldn't send to ${bestie.name}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,64 +109,66 @@ export function BestieMessagePopover({
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-[min(22rem,calc(100vw-2rem))] rounded-2xl p-0 shadow-lg"
|
||||
className="w-[min(30rem,calc(100vw-2rem))] rounded-3xl p-0 shadow-xl"
|
||||
data-testid={`bestie-popover-${message.id}`}
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
>
|
||||
<form
|
||||
className="space-y-4 p-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3.5 p-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ProfileAvatar
|
||||
avatarUrl={bestie.avatarUrl}
|
||||
className="size-8 text-xs"
|
||||
label={bestie.name}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">Send to Bestie</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
Share this message with {bestie.name}
|
||||
</p>
|
||||
<p className="min-w-0 truncate text-sm font-semibold">Bestie</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-2xl border border-border/60 bg-background px-3 py-3 shadow-md"
|
||||
data-testid={`bestie-message-snapshot-${message.id}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<UserAvatar
|
||||
avatarUrl={message.avatarUrl ?? null}
|
||||
className="size-7 shrink-0"
|
||||
displayName={message.author}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-baseline gap-1.5">
|
||||
<p className="truncate text-xs font-semibold leading-4">
|
||||
{message.author}
|
||||
</p>
|
||||
<p className="shrink-0 text-[10px] leading-4 text-muted-foreground/70">
|
||||
{message.time}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-0.5 max-h-12 overflow-hidden">
|
||||
<VideoReviewCommentMarkdown
|
||||
className="line-clamp-3 text-xs leading-4 text-foreground/80 [&_p]:leading-4"
|
||||
content={message.body}
|
||||
interactive={false}
|
||||
messageId={message.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/70 bg-muted/45 px-3 py-2.5">
|
||||
<p className="line-clamp-3 text-sm leading-5 text-foreground/80">
|
||||
{message.body}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
aria-label="Note for Bestie"
|
||||
autoFocus
|
||||
className="min-h-24 resize-none"
|
||||
<MessageComposer
|
||||
allowEmptySend
|
||||
channelName="Bestie"
|
||||
channelType="dm"
|
||||
containerClassName="px-0 pb-0"
|
||||
disabled={isPending}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
draftKey={`bestie-share:${message.id}`}
|
||||
isSending={isPending}
|
||||
onSend={submit}
|
||||
placeholder="Add a note (optional)"
|
||||
value={note}
|
||||
showBackgroundUploadProgress={false}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
disabled={isPending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={isPending} size="sm" type="submit">
|
||||
<Send aria-hidden className="size-4" />
|
||||
{isPending ? "Sending…" : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -69,6 +69,7 @@ import { useComposerLinkPreviews } from "./useComposerLinkPreviews";
|
||||
import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit";
|
||||
import type { MessageComposerProps } from "./MessageComposer.types";
|
||||
function MessageComposerImpl({
|
||||
allowEmptySend = false,
|
||||
audienceContext = null,
|
||||
channelId = null,
|
||||
channelName,
|
||||
@@ -597,7 +598,7 @@ function MessageComposerImpl({
|
||||
const hasMedia =
|
||||
currentPendingImeta.length > 0 || currentQueuedAttachments.length > 0;
|
||||
if (
|
||||
(!trimmed && !hasMedia) ||
|
||||
(!allowEmptySend && !trimmed && !hasMedia) ||
|
||||
disabledRef.current ||
|
||||
isSendingRef.current ||
|
||||
isSubmitLockedRef.current ||
|
||||
@@ -644,6 +645,7 @@ function MessageComposerImpl({
|
||||
onPreparingMentionSendChange?.(false);
|
||||
}
|
||||
}, [
|
||||
allowEmptySend,
|
||||
channelId,
|
||||
channelLinks.clearChannels,
|
||||
customEmoji,
|
||||
@@ -775,7 +777,8 @@ function MessageComposerImpl({
|
||||
composerDisabled ||
|
||||
media.isUploading ||
|
||||
mentionSendFlow.isPreparingMentionSend ||
|
||||
(isContentEmpty &&
|
||||
(!allowEmptySend &&
|
||||
isContentEmpty &&
|
||||
media.pendingImeta.length === 0 &&
|
||||
media.queuedAttachments.length === 0);
|
||||
const handleCaptureSelection = React.useCallback(() => {}, []);
|
||||
|
||||
@@ -24,6 +24,11 @@ export type MessageComposerEditTarget = {
|
||||
};
|
||||
|
||||
export type MessageComposerProps = {
|
||||
/**
|
||||
* Keeps the send control available without authored text. The owner must
|
||||
* supply meaningful fallback content in onSend.
|
||||
*/
|
||||
allowEmptySend?: boolean;
|
||||
audienceContext?: {
|
||||
type: "channel" | "thread";
|
||||
} | null;
|
||||
|
||||
@@ -55,7 +55,8 @@ test("message action sends a message link and optional note to Bestie", async ({
|
||||
await page.evaluate((id) => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: "Please fold this launch decision into tomorrow's priorities.",
|
||||
content:
|
||||
"Please fold this launch decision into tomorrow's priorities. Product and engineering need one owner, a clear deadline, and a short note explaining the tradeoff before the review begins.",
|
||||
id,
|
||||
});
|
||||
}, messageId);
|
||||
@@ -67,17 +68,25 @@ test("message action sends a message link and optional note to Bestie", async ({
|
||||
|
||||
const popover = page.getByTestId(`bestie-popover-${messageId}`);
|
||||
await expect(popover).toBeVisible();
|
||||
await expect(popover).toContainText("Bestie");
|
||||
await expect(popover).not.toContainText("Share this message with Bestie");
|
||||
await expect(popover).toContainText("Please fold this launch decision");
|
||||
await expect(
|
||||
popover.getByTestId(`bestie-message-snapshot-${messageId}`),
|
||||
).toBeVisible();
|
||||
const composer = popover.getByTestId("message-composer");
|
||||
await expect(composer).toBeVisible();
|
||||
await expect(composer.getByRole("button", { name: "Send" })).toBeEnabled();
|
||||
if (process.env.BUZZ_BESTIE_POPOVER_SCREENSHOT) {
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: process.env.BUZZ_BESTIE_POPOVER_SCREENSHOT,
|
||||
});
|
||||
}
|
||||
await popover
|
||||
.getByRole("textbox", { name: "Note for Bestie" })
|
||||
await composer
|
||||
.locator('[contenteditable="true"]')
|
||||
.fill("Make sure product and engineering agree on the owner.");
|
||||
await popover.getByRole("button", { name: "Send" }).click();
|
||||
await composer.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
await expect(popover).toBeHidden();
|
||||
await page.getByTestId("open-bestie-dm").click();
|
||||
|
||||
Reference in New Issue
Block a user