feat(desktop): add experiment-gated Bloom actions

Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
Arjun Mahanti
2026-08-26 18:27:43 -04:00
committed by Fizz
parent f91e8009b0
commit 3072f8b0ff
8 changed files with 1255 additions and 31 deletions

View File

@@ -47,6 +47,8 @@ import {
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { isPositiveEmojiParticle } from "@/shared/ui/EmojiBurstProvider";
import { useFeatureEnabled } from "@/shared/features";
import { MessageActionBloomBar } from "./MessageActionBloomBar";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
@@ -381,7 +383,28 @@ function isCustomEmojiShortcode(emoji: string) {
return emoji.startsWith(":") && emoji.endsWith(":");
}
export const MessageActionBar = React.memo(function MessageActionBar({
export type MessageActionBarProps = {
channelId?: string | null;
message: TimelineMessage;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onExpandedChange?: (expanded: boolean) => void;
onFollowThread?: (message: TimelineMessage) => void;
onMarkUnread?: (message: TimelineMessage) => void;
onMarkRead?: (message: TimelineMessage) => void;
onReactionBadgeBurstRequest?: (emoji: string) => void;
onReactionSelect?: (emoji: string) => Promise<void>;
onRemindLater?: (message: TimelineMessage) => void;
onReply?: (message: TimelineMessage) => void;
onSendToChannel?: (message: TimelineMessage) => Promise<void>;
onUnfollowThread?: (message: TimelineMessage) => void;
reactionErrorMessage?: string | null;
reactions: TimelineReaction[];
isFollowingThread?: boolean;
isUnread?: boolean;
};
const LegacyMessageActionBar = React.memo(function LegacyMessageActionBar({
channelId,
message,
onDelete,
@@ -399,29 +422,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
reactions,
isFollowingThread,
isUnread,
}: {
/** Channel UUID — required for the "Copy link" action; when omitted the
* action is hidden (callers like the home inbox that lack the context). */
channelId?: string | null;
message: TimelineMessage;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onFollowThread?: (message: TimelineMessage) => void;
onMarkUnread?: (message: TimelineMessage) => void;
onMarkRead?: (message: TimelineMessage) => void;
onReactionBadgeBurstRequest?: (emoji: string) => void;
onReactionSelect?: (emoji: string) => Promise<void>;
onRemindLater?: (message: TimelineMessage) => void;
onReply?: (message: TimelineMessage) => void;
onSendToChannel?: (message: TimelineMessage) => Promise<void>;
onUnfollowThread?: (message: TimelineMessage) => void;
reactionErrorMessage?: string | null;
reactions: TimelineReaction[];
isFollowingThread?: boolean;
/** Current read state of the clicked message, from the same predicate the
* unread badge uses. Drives the single mark-read/unread toggle label. */
isUnread?: boolean;
}) {
}: MessageActionBarProps) {
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false);
const customEmoji = useCustomEmoji();
@@ -636,4 +637,17 @@ export const MessageActionBar = React.memo(function MessageActionBar({
);
});
LegacyMessageActionBar.displayName = "LegacyMessageActionBar";
export const MessageActionBar = React.memo(function MessageActionBar(
props: MessageActionBarProps,
) {
const bestieEnabled = useFeatureEnabled("bestie");
return bestieEnabled ? (
<MessageActionBloomBar {...props} />
) : (
<LegacyMessageActionBar {...props} />
);
});
MessageActionBar.displayName = "MessageActionBar";

View File

@@ -0,0 +1,534 @@
import { CornerUpLeft, EllipsisVertical, Link2, SmilePlus } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import { reactionEmojiUrl } from "@/shared/api/customEmoji";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { isPositiveEmojiParticle } from "@/shared/ui/EmojiBurstProvider";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import type { MessageActionBarProps } from "@/features/messages/ui/MessageActionBar";
import {
MESSAGE_ACTION_BLOOM_EASE_OUT,
MESSAGE_ACTION_BLOOM_SPEED_MULTIPLIER,
MESSAGE_ACTION_BLOOM_VISUAL_DURATION,
MessageActionBloomSurface,
} from "@/features/messages/ui/MessageActionBloomSurface";
import { MessageActionOverflowPanel } from "@/features/messages/ui/MessageActionOverflowPanel";
import {
canCopyMessageLink,
copyMessageLink,
isCustomEmojiShortcode,
QuickReactionButton,
} from "@/features/messages/ui/MessageActionToolbarHelpers";
import {
recordQuickReactionEmoji,
useQuickReactionEmojis,
} from "@/features/messages/ui/useQuickReactionEmojis";
const ACTION_BUTTON_CLASS = "h-8 w-8 rounded-full p-0";
const ACTION_ICON_CLASS = "!h-4 !w-4";
type ActiveSurface = "reactions" | "more" | null;
export function MessageActionBloomBar({
channelId,
message,
onDelete,
onEdit,
onExpandedChange,
onFollowThread,
onMarkUnread,
onMarkRead,
onReactionBadgeBurstRequest,
onReactionSelect,
onRemindLater,
onReply,
onSendToChannel,
onUnfollowThread,
reactionErrorMessage = null,
reactions,
isFollowingThread,
isUnread,
}: MessageActionBarProps) {
const [activeSurface, setActiveSurface] = React.useState<ActiveSurface>(null);
const reduceMotion = useReducedMotion();
const surfaceRef = React.useRef<HTMLDivElement>(null);
const toolbarRef = React.useRef<HTMLDivElement>(null);
const activeContentRef = React.useRef<HTMLDivElement>(null);
const anchorRectRef = React.useRef<DOMRect | null>(null);
const demotionTimerRef = React.useRef<number | null>(null);
const [closedSize, setClosedSize] = React.useState<{
height: number;
width: number;
} | null>(null);
const [openSize, setOpenSize] = React.useState<{
height: number;
width: number;
} | null>(null);
const [contentReady, setContentReady] = React.useState(false);
const [expansionDirection, setExpansionDirection] = React.useState<
"up" | "down"
>("up");
const reactionTriggerRef = React.useRef<HTMLButtonElement>(null);
const moreTriggerRef = React.useRef<HTMLButtonElement>(null);
const lastSurfaceRef = React.useRef<ActiveSurface>(null);
const customEmoji = useCustomEmoji();
const quickReactionEmojis = useQuickReactionEmojis(3, customEmoji);
const quickReactionItems = React.useMemo(
() =>
quickReactionEmojis
.map((emoji) => ({
customEmojiUrl: reactionEmojiUrl(emoji, customEmoji),
emoji,
}))
.filter(
(item) => !isCustomEmojiShortcode(item.emoji) || item.customEmojiUrl,
),
[customEmoji, quickReactionEmojis],
);
const hasReplyAction = Boolean(onReply);
const hasReactionAction = Boolean(onReactionSelect);
const hasMoreMenuActions =
Boolean(onEdit) ||
Boolean(onDelete) ||
Boolean(onMarkUnread) ||
Boolean(onMarkRead) ||
Boolean(onFollowThread) ||
Boolean(onUnfollowThread) ||
Boolean(onRemindLater) ||
Boolean(onSendToChannel) ||
!message.pending;
React.useEffect(() => {
onExpandedChange?.(activeSurface !== null);
return () => onExpandedChange?.(false);
}, [activeSurface, onExpandedChange]);
const wouldAddReaction = React.useCallback(
(emoji: string) =>
!reactions.some(
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
),
[reactions],
);
const handleReactionSelection = React.useCallback(
(emoji: string, closePicker = false) => {
if (!onReactionSelect) return;
if (wouldAddReaction(emoji) && isPositiveEmojiParticle(emoji)) {
onReactionBadgeBurstRequest?.(emoji);
}
void onReactionSelect(emoji)
.then(() => recordQuickReactionEmoji(emoji))
.catch(() => {})
.finally(() => {
if (closePicker) setActiveSurface(null);
});
},
[onReactionBadgeBurstRequest, onReactionSelect, wouldAddReaction],
);
const closeSurface = React.useCallback((restoreFocus = false) => {
const surface = lastSurfaceRef.current;
setActiveSurface(null);
if (!restoreFocus) return;
window.requestAnimationFrame(() => {
if (surface === "reactions") reactionTriggerRef.current?.focus();
if (surface === "more") moreTriggerRef.current?.focus();
});
}, []);
const openSurface = React.useCallback(
(surface: Exclude<ActiveSurface, null>) => {
const bloom = surfaceRef.current;
if (bloom && !bloom.matches(":popover-open")) {
const anchorRect = bloom.getBoundingClientRect();
anchorRectRef.current = anchorRect;
bloom.setAttribute("popover", "manual");
bloom.style.position = "fixed";
bloom.style.inset = "auto";
bloom.style.right = `${window.innerWidth - anchorRect.right}px`;
bloom.style.bottom = `${window.innerHeight - anchorRect.bottom}px`;
bloom.style.margin = "0";
bloom.showPopover();
}
if (demotionTimerRef.current !== null) {
window.clearTimeout(demotionTimerRef.current);
demotionTimerRef.current = null;
}
lastSurfaceRef.current = surface;
setContentReady(false);
setOpenSize(null);
setActiveSurface(surface);
},
[],
);
React.useLayoutEffect(() => {
if (!activeSurface) return;
const bloom = surfaceRef.current;
const anchorRect = anchorRectRef.current;
if (!bloom || !anchorRect) return;
bloom.style.right = `${window.innerWidth - anchorRect.right}px`;
if (expansionDirection === "up") {
bloom.style.top = "auto";
bloom.style.bottom = `${window.innerHeight - anchorRect.bottom}px`;
} else {
bloom.style.top = `${anchorRect.top}px`;
bloom.style.bottom = "auto";
}
}, [activeSurface, expansionDirection]);
React.useEffect(() => {
if (activeSurface) return;
const bloom = surfaceRef.current;
if (!bloom?.matches(":popover-open")) return;
demotionTimerRef.current = window.setTimeout(
() => {
if (bloom.matches(":popover-open")) bloom.hidePopover();
bloom.removeAttribute("popover");
for (const property of [
"position",
"inset",
"right",
"bottom",
"top",
"margin",
]) {
bloom.style.removeProperty(property);
}
anchorRectRef.current = null;
demotionTimerRef.current = null;
},
reduceMotion
? 0
: MESSAGE_ACTION_BLOOM_VISUAL_DURATION * 1000 +
20 / MESSAGE_ACTION_BLOOM_SPEED_MULTIPLIER,
);
return () => {
if (demotionTimerRef.current !== null) {
window.clearTimeout(demotionTimerRef.current);
demotionTimerRef.current = null;
}
};
}, [activeSurface, reduceMotion]);
React.useEffect(
() => () => {
if (demotionTimerRef.current !== null) {
window.clearTimeout(demotionTimerRef.current);
}
},
[],
);
React.useLayoutEffect(() => {
const toolbar = toolbarRef.current;
if (!toolbar) return;
const measure = () => {
const rect = toolbar.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
setClosedSize((current) =>
current?.height === rect.height && current.width === rect.width
? current
: { height: rect.height, width: rect.width },
);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(toolbar);
return () => observer.disconnect();
}, []);
React.useLayoutEffect(() => {
if (!activeSurface) return;
const content = activeContentRef.current;
if (!content) return;
let firstFrame = 0;
let settledFrame = 0;
const measure = () => {
window.cancelAnimationFrame(firstFrame);
window.cancelAnimationFrame(settledFrame);
firstFrame = window.requestAnimationFrame(() => {
settledFrame = window.requestAnimationFrame(() => {
const rect = content.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const surfaceRect = surfaceRef.current?.getBoundingClientRect();
if (surfaceRect) {
const roomAbove = surfaceRect.bottom - 64;
const roomBelow = window.innerHeight - surfaceRect.top - 64;
setExpansionDirection(
rect.height <= roomAbove || roomAbove >= roomBelow
? "up"
: "down",
);
}
const nextSize = {
height: Math.ceil(rect.height),
width: Math.ceil(rect.width),
};
setOpenSize((current) =>
current?.height === nextSize.height &&
current.width === nextSize.width
? current
: nextSize,
);
setContentReady(true);
});
});
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(content);
return () => {
observer.disconnect();
window.cancelAnimationFrame(firstFrame);
window.cancelAnimationFrame(settledFrame);
};
}, [activeSurface]);
React.useEffect(() => {
if (!activeSurface) return;
const currentBar = surfaceRef.current?.closest("[data-message-action-bar]");
const otherBars = Array.from(
document.querySelectorAll<HTMLElement>("[data-message-action-bar]"),
).filter((bar) => bar !== currentBar);
for (const bar of otherBars) bar.inert = true;
const handlePointerDown = (event: PointerEvent) => {
if (event.composedPath().includes(surfaceRef.current as EventTarget))
return;
const target = event.target;
if (
target instanceof Element &&
target.closest('[data-radix-popper-content-wrapper], [role="dialog"]')
) {
return;
}
closeSurface();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
closeSurface(true);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown, true);
return () => {
for (const bar of otherBars) bar.inert = false;
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown, true);
};
}, [activeSurface, closeSurface]);
if (!hasReplyAction && !hasReactionAction && !hasMoreMenuActions) return null;
const panelMotion = {
animate: {
filter: contentReady ? "blur(0px)" : "blur(6px)",
opacity: contentReady ? 1 : 0,
y: contentReady ? 0 : expansionDirection === "up" ? 8 : -8,
},
exit: {
filter: "blur(6px)",
opacity: 0,
y: expansionDirection === "up" ? 8 : -8,
},
transition: {
delay:
reduceMotion || !contentReady
? 0
: 0.01 / MESSAGE_ACTION_BLOOM_SPEED_MULTIPLIER,
duration: reduceMotion ? 0 : 0.1 / MESSAGE_ACTION_BLOOM_SPEED_MULTIPLIER,
ease: MESSAGE_ACTION_BLOOM_EASE_OUT,
},
} as const;
return (
<div
className={cn(
"relative h-10 w-0 transition-opacity duration-150 ease-out",
"opacity-100 sm:pointer-events-none sm:opacity-0",
"sm:group-hover/message:pointer-events-auto sm:group-hover/message:opacity-100",
"sm:group-focus-within/message:pointer-events-auto sm:group-focus-within/message:opacity-100",
activeSurface ? "sm:pointer-events-auto sm:opacity-100" : "",
)}
data-bloom-surface={activeSurface ?? "toolbar"}
data-message-action-bar
data-testid={`message-action-bar-${message.id}`}
>
<MessageActionBloomSurface
className={cn(
"absolute right-0 m-0 p-0 [&::backdrop]:hidden",
expansionDirection === "up" ? "bottom-0" : "top-0",
)}
data-testid={`message-action-bloom-container-${message.id}`}
expanded={activeSurface !== null && contentReady}
ref={surfaceRef}
size={activeSurface !== null && contentReady ? openSize : closedSize}
>
<motion.div
animate={{ opacity: activeSurface && contentReady ? 0 : 1 }}
className="flex w-max shrink-0 flex-nowrap items-center gap-0.5 p-1"
data-testid={`message-action-bloom-surface-${message.id}`}
initial={false}
ref={toolbarRef}
style={{ pointerEvents: activeSurface === null ? "auto" : "none" }}
transition={{ duration: reduceMotion ? 0 : 0.1 }}
>
{hasReactionAction && quickReactionItems.length > 0 ? (
<div className="hidden items-center gap-0.5 sm:flex">
{quickReactionItems.map(({ customEmojiUrl, emoji }) => (
<QuickReactionButton
customEmojiUrl={customEmojiUrl}
emoji={emoji}
key={emoji}
onSelect={handleReactionSelection}
/>
))}
</div>
) : null}
{hasReactionAction ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Open reactions"
className={ACTION_BUTTON_CLASS}
data-testid={`react-message-${message.id}`}
onClick={() => openSurface("reactions")}
ref={reactionTriggerRef}
size="sm"
type="button"
variant="ghost"
>
<SmilePlus className={ACTION_ICON_CLASS} />
</Button>
</TooltipTrigger>
<TooltipContent>React</TooltipContent>
</Tooltip>
) : null}
{hasReactionAction && quickReactionItems.length > 0 ? (
<div
aria-hidden="true"
className="mx-0.5 hidden h-4 w-px bg-border/70 sm:block"
data-testid="message-action-divider"
/>
) : null}
{hasReplyAction ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Reply"
className={ACTION_BUTTON_CLASS}
data-testid={`reply-message-${message.id}`}
onClick={() => onReply?.(message)}
size="sm"
type="button"
variant="ghost"
>
<CornerUpLeft className={ACTION_ICON_CLASS} />
</Button>
</TooltipTrigger>
<TooltipContent>Reply</TooltipContent>
</Tooltip>
) : null}
{canCopyMessageLink(message, channelId) ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Copy link"
className={ACTION_BUTTON_CLASS}
data-testid={`copy-link-message-${message.id}`}
onClick={() => copyMessageLink(channelId, message)}
size="sm"
type="button"
variant="ghost"
>
<Link2 className={ACTION_ICON_CLASS} />
</Button>
</TooltipTrigger>
<TooltipContent>Copy link</TooltipContent>
</Tooltip>
) : null}
{hasMoreMenuActions ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="More actions"
className={ACTION_BUTTON_CLASS}
data-testid={`more-actions-${message.id}`}
onClick={() => openSurface("more")}
ref={moreTriggerRef}
size="sm"
type="button"
variant="ghost"
>
<EllipsisVertical className={ACTION_ICON_CLASS} />
</Button>
</TooltipTrigger>
<TooltipContent>More actions</TooltipContent>
</Tooltip>
) : null}
</motion.div>
<AnimatePresence initial={false}>
{activeSurface === "reactions" ? (
<motion.div
{...panelMotion}
className={cn(
"absolute right-0",
expansionDirection === "up" ? "bottom-0" : "top-0",
)}
data-testid={`reaction-bloom-panel-${message.id}`}
initial={false}
key="reactions"
ref={activeContentRef}
style={{ pointerEvents: contentReady ? "auto" : "none" }}
>
{reactionErrorMessage ? (
<div className="px-3 pb-0 pt-3">
<p className="text-xs text-destructive">
{reactionErrorMessage}
</p>
</div>
) : null}
<EmojiPicker
autoFocus
onSelect={(value) => handleReactionSelection(value, true)}
/>
</motion.div>
) : null}
{activeSurface === "more" ? (
<motion.div
{...panelMotion}
className={cn(
"absolute right-0",
expansionDirection === "up" ? "bottom-0" : "top-0",
)}
initial={false}
key="more"
ref={activeContentRef}
style={{ pointerEvents: contentReady ? "auto" : "none" }}
>
<MessageActionOverflowPanel
channelId={channelId}
isFollowingThread={isFollowingThread}
isUnread={isUnread}
message={message}
onClose={() => closeSurface()}
onDelete={onDelete}
onEdit={onEdit}
onFollowThread={onFollowThread}
onMarkRead={onMarkRead}
onMarkUnread={onMarkUnread}
onRemindLater={onRemindLater}
onSendToChannel={onSendToChannel}
onUnfollowThread={onUnfollowThread}
/>
</motion.div>
) : null}
</AnimatePresence>
</MessageActionBloomSurface>
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
export const MESSAGE_ACTION_BLOOM_EASE_OUT = [0.23, 1, 0.32, 1] as const;
export const MESSAGE_ACTION_BLOOM_SPEED_MULTIPLIER = 1.8;
export const MESSAGE_ACTION_BLOOM_VISUAL_DURATION =
0.15 / MESSAGE_ACTION_BLOOM_SPEED_MULTIPLIER;
const MESSAGE_ACTION_RESTING_RADIUS = 32;
const MESSAGE_ACTION_EXPANDED_RADIUS = 24;
type MessageActionBloomSurfaceProps = Omit<
React.ComponentPropsWithoutRef<typeof motion.div>,
"children"
> & {
children: React.ReactNode;
expanded: boolean;
size?: { height: number; width: number } | null;
};
/** A persistent, measurement-driven surface that never scales its children. */
export const MessageActionBloomSurface = React.forwardRef<
HTMLDivElement,
MessageActionBloomSurfaceProps
>(function MessageActionBloomSurface(
{ children, className, expanded, size, style, ...props },
ref,
) {
const reduceMotion = useReducedMotion();
return (
<motion.div
animate={{
borderRadius: expanded
? MESSAGE_ACTION_EXPANDED_RADIUS
: MESSAGE_ACTION_RESTING_RADIUS,
...(size ? { height: size.height, width: size.width } : {}),
}}
className={cn(
"contain-layout contain-paint overflow-hidden border border-border/70 bg-background/95 shadow-xs backdrop-blur-sm supports-[backdrop-filter]:bg-background/85",
className,
)}
initial={false}
ref={ref}
style={{
...style,
transformOrigin: "bottom right",
willChange: "width, height, border-radius",
}}
transition={
reduceMotion
? { duration: 0 }
: {
borderRadius: {
bounce: 0,
type: "spring",
visualDuration: MESSAGE_ACTION_BLOOM_VISUAL_DURATION,
},
height: {
bounce: 0,
type: "spring",
visualDuration: MESSAGE_ACTION_BLOOM_VISUAL_DURATION,
},
width: {
bounce: 0,
type: "spring",
visualDuration: MESSAGE_ACTION_BLOOM_VISUAL_DURATION,
},
}
}
{...props}
>
{children}
</motion.div>
);
});

View File

@@ -0,0 +1,287 @@
import {
BellOff,
BellRing,
Clock,
Copy,
Flag,
Link2,
MailCheck,
MailOpen,
Pencil,
Trash2,
} from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import { HashArrowIn } from "@/shared/ui/icons";
import { MessageModerationInlineItems } from "@/features/moderation/ui/MessageModerationMenuItems";
import { ReportMessageDialog } from "@/features/moderation/ui/ReportMessageDialog";
import type { TimelineMessage } from "@/features/messages/types";
import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog";
import {
canCopyMessageLink,
copyMessageLink,
} from "@/features/messages/ui/MessageActionToolbarHelpers";
export type MessageActionOverflowPanelProps = {
channelId?: string | null;
message: TimelineMessage;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onFollowThread?: (message: TimelineMessage) => void;
onMarkUnread?: (message: TimelineMessage) => void;
onMarkRead?: (message: TimelineMessage) => void;
onClose: () => void;
onRemindLater?: (message: TimelineMessage) => void;
onSendToChannel?: (message: TimelineMessage) => Promise<void>;
onUnfollowThread?: (message: TimelineMessage) => void;
isFollowingThread?: boolean;
isUnread?: boolean;
};
export function MessageActionOverflowPanel({
channelId,
message,
onDelete,
onEdit,
onFollowThread,
onMarkUnread,
onMarkRead,
onClose,
onRemindLater,
onSendToChannel,
onUnfollowThread,
isFollowingThread,
isUnread,
}: MessageActionOverflowPanelProps) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [isReportDialogOpen, setIsReportDialogOpen] = React.useState(false);
const panelRef = React.useRef<HTMLDivElement>(null);
const hasCopyActions =
!message.pending && message.kind !== KIND_HUDDLE_STARTED;
const canReport =
!message.pending &&
message.kind !== KIND_HUDDLE_STARTED &&
Boolean(message.pubkey);
const itemClassName =
"flex min-h-9 w-full select-none items-center gap-2 rounded-lg py-2 pl-2 pr-4 text-left text-sm outline-hidden transition-colors hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring [&>svg]:size-4 [&>svg]:shrink-0";
React.useEffect(() => {
panelRef.current
?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])')
?.focus();
}, []);
const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>(
'[role="menuitem"]:not([disabled])',
),
);
if (items.length === 0) return;
event.preventDefault();
const currentIndex = items.indexOf(document.activeElement as HTMLElement);
const nextIndex =
event.key === "Home"
? 0
: event.key === "End"
? items.length - 1
: event.key === "ArrowDown"
? (currentIndex + 1) % items.length
: (currentIndex - 1 + items.length) % items.length;
items[nextIndex]?.focus();
};
return (
<>
<div
className="max-h-[min(420px,calc(100vh-3rem))] min-w-60 overflow-y-auto p-1"
data-testid={`more-actions-panel-${message.id}`}
onKeyDown={handleMenuKeyDown}
ref={panelRef}
role="menu"
>
{onEdit ? (
<button
className={itemClassName}
data-testid={`edit-message-${message.id}`}
onClick={() => {
onClose();
onEdit(message);
}}
role="menuitem"
type="button"
>
<Pencil />
Edit message
</button>
) : null}
{onMarkRead || onMarkUnread ? (
<button
className={itemClassName}
data-testid={`mark-read-toggle-${message.id}`}
onClick={() => {
if (isUnread) onMarkRead?.(message);
else onMarkUnread?.(message);
onClose();
}}
role="menuitem"
type="button"
>
{isUnread ? <MailCheck /> : <MailOpen />}
{isUnread ? "Mark read" : "Mark unread"}
</button>
) : null}
{onFollowThread || onUnfollowThread ? (
<button
className={itemClassName}
onClick={() => {
if (isFollowingThread) onUnfollowThread?.(message);
else onFollowThread?.(message);
onClose();
}}
role="menuitem"
type="button"
>
{isFollowingThread ? <BellOff /> : <BellRing />}
{isFollowingThread ? "Unfollow thread" : "Follow thread"}
</button>
) : null}
{hasCopyActions ? (
<button
className={itemClassName}
onClick={() => {
copyTextToClipboard(message.body, "Message copied to clipboard");
onClose();
}}
role="menuitem"
type="button"
>
<Copy />
Copy message
</button>
) : null}
{onRemindLater ? (
<button
className={itemClassName}
onClick={() => {
onRemindLater(message);
onClose();
}}
role="menuitem"
type="button"
>
<Clock />
Remind me later
</button>
) : null}
{onSendToChannel ? (
<button
aria-label="Send to channel"
className={itemClassName}
data-testid={`send-to-channel-${message.id}`}
onClick={() => {
onClose();
void onSendToChannel(message)
.then(() => toast.success("Sent to channel"))
.catch((error) => {
console.error(
"Failed to send thread message to channel",
error,
);
toast.error("Couldn't send to channel");
});
}}
role="menuitem"
type="button"
>
<HashArrowIn
aria-hidden="true"
data-testid="send-to-channel-icon"
/>
Send to channel
</button>
) : null}
{canCopyMessageLink(message, channelId) ? (
<button
className={itemClassName}
data-testid={`copy-message-link-${message.id}`}
onClick={() => {
copyMessageLink(channelId, message);
onClose();
}}
role="menuitem"
type="button"
>
<Link2 />
Copy link
</button>
) : null}
{canReport || onDelete ? (
<hr className="-mx-1 my-1 border-0 border-t border-muted" />
) : null}
{canReport ? (
<button
className={itemClassName}
data-testid={`report-message-${message.id}`}
onClick={() => setIsReportDialogOpen(true)}
role="menuitem"
type="button"
>
<Flag />
Report message
</button>
) : null}
{onDelete ? (
<button
className={`${itemClassName} text-destructive`}
data-testid={`delete-message-${message.id}`}
onClick={() => setIsDeleteDialogOpen(true)}
role="menuitem"
type="button"
>
<Trash2 />
Delete message
</button>
) : null}
<MessageModerationInlineItems
channelId={channelId}
message={message}
onAction={onClose}
/>
</div>
{onDelete ? (
<DeleteMessageConfirmDialog
onConfirm={() => onDelete(message)}
onOpenChange={(nextOpen) => {
setIsDeleteDialogOpen(nextOpen);
if (!nextOpen) onClose();
}}
open={isDeleteDialogOpen}
/>
) : null}
{canReport ? (
<ReportMessageDialog
authorPubkey={message.pubkey ?? ""}
eventId={message.id}
onOpenChange={(nextOpen) => {
setIsReportDialogOpen(nextOpen);
if (!nextOpen) onClose();
}}
open={isReportDialogOpen}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,74 @@
import { buildMessageLink } from "@/features/messages/lib/messageLink";
import { getThreadReference } from "@/features/messages/lib/threading";
import type { TimelineMessage } from "@/features/messages/types";
import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import { emojiDisplayName } from "@/shared/lib/emojiName";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
export function copyMessageLink(channelId: string, message: TimelineMessage) {
const { rootId } = getThreadReference(message.tags ?? []);
const link = buildMessageLink({
channelId,
messageId: message.id,
threadRootId: rootId,
});
copyTextToClipboard(link, "Link copied to clipboard");
}
export function canCopyMessageLink(
message: TimelineMessage,
channelId: string | null | undefined,
): channelId is string {
return (
!message.pending &&
message.kind !== KIND_HUDDLE_STARTED &&
Boolean(channelId)
);
}
export function QuickReactionButton({
customEmojiUrl,
emoji,
onSelect,
}: {
customEmojiUrl?: string;
emoji: string;
onSelect: (emoji: string) => void;
}) {
const displayName = emojiDisplayName(emoji);
const mediaUrl = customEmojiUrl ? rewriteRelayUrl(customEmojiUrl) : null;
return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={`React with ${displayName}`}
className="flex h-8 w-8 items-center justify-center rounded-full text-base leading-none text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
onClick={() => onSelect(emoji)}
title={displayName}
type="button"
>
{mediaUrl ? (
<img
alt={emoji}
className="h-5 w-5 object-contain"
draggable={false}
src={mediaUrl}
/>
) : (
<span aria-hidden="true" className="translate-y-px">
{emoji}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent>{displayName}</TooltipContent>
</Tooltip>
);
}
export function isCustomEmojiShortcode(emoji: string) {
return emoji.startsWith(":") && emoji.endsWith(":");
}

View File

@@ -171,6 +171,7 @@ export const MessageRow = React.memo(
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
null,
);
const [isActionBarExpanded, setIsActionBarExpanded] = React.useState(false);
const linkPreviewsSuppressed = hasLinkPreviewSuppression(message.tags);
const removeLinkPreviewsForEveryone =
channelId && onEdit && !message.pending && !linkPreviewsSuppressed
@@ -559,11 +560,11 @@ export const MessageRow = React.memo(
ownerPubkey={message.ownerPubkey}
/>
) : null;
const actionBarNode = (
<div
className={cn(
"absolute right-2 top-1 z-10 sm:pointer-events-none",
"absolute right-2 top-1 sm:pointer-events-none",
isActionBarExpanded ? "z-[60]" : "z-50",
actionBarPlacement === "floating"
? isContinuation
? "sm:-top-3 sm:-translate-y-1/2"
@@ -578,6 +579,7 @@ export const MessageRow = React.memo(
message={message}
onDelete={onDelete}
onEdit={onEdit}
onExpandedChange={setIsActionBarExpanded}
onFollowThread={onFollowThread}
onMarkUnread={onMarkUnread}
onMarkRead={onMarkRead}
@@ -882,10 +884,10 @@ export const MessageRow = React.memo(
}}
/>
) : null}
<article
className={cn(
"group/message relative z-10 rounded-2xl transition-colors",
"group/message relative rounded-2xl transition-colors",
isActionBarExpanded ? "z-[60]" : "z-10",
playEntrance && "motion-enter-conversation",
"py-conversation-row",
hoverBackground
@@ -929,8 +931,6 @@ export const MessageRow = React.memo(
</article>
</div>
);
// Callbacks (onReply, onToggleReaction) intentionally excluded: inline arrows
// from parent create new refs every render — including them defeats memo.
},
(prev, next) =>
prev.message.id === next.message.id &&

View File

@@ -1,4 +1,11 @@
import { Ban, CircleSlash, Clock, ShieldCheck, UserMinus } from "lucide-react";
import {
Ban,
ChevronRight,
CircleSlash,
Clock,
ShieldCheck,
UserMinus,
} from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
@@ -204,3 +211,174 @@ export function MessageModerationMenuItems({
</>
);
}
/** Same moderation capabilities for the in-tree message action surface. */
export function MessageModerationInlineItems({
channelId,
message,
onAction,
}: {
channelId?: string | null;
message: TimelineMessage;
onAction?: () => void;
}) {
const relayMembershipQuery = useMyRelayMembershipQuery();
const relayRole = relayMembershipQuery.data?.role;
const canModerate = relayRole === "owner" || relayRole === "admin";
const identityQuery = useIdentityQuery();
const targetPubkey = message.signerPubkey ?? null;
const isSelf =
targetPubkey != null &&
identityQuery.data?.pubkey != null &&
normalizePubkey(targetPubkey) ===
normalizePubkey(identityQuery.data.pubkey);
const enabled = canModerate && targetPubkey != null && !isSelf;
const restrictionsQuery = useModerationRestrictionsQuery(enabled);
const banMutation = useBanMemberMutation();
const unbanMutation = useUnbanMemberMutation();
const timeoutMutation = useTimeoutMemberMutation();
const untimeoutMutation = useUntimeoutMemberMutation();
const removeMutation = useRemoveChannelMemberMutation(channelId ?? null);
const [showTimeouts, setShowTimeouts] = React.useState(false);
const isPending =
banMutation.isPending ||
unbanMutation.isPending ||
timeoutMutation.isPending ||
untimeoutMutation.isPending ||
removeMutation.isPending;
const restriction = React.useMemo(() => {
if (targetPubkey == null) return null;
const key = normalizePubkey(targetPubkey);
return (
restrictionsQuery.data?.find((r) => normalizePubkey(r.pubkey) === key) ??
null
);
}, [restrictionsQuery.data, targetPubkey]);
const isBanned = restriction?.banned ?? false;
const timedOut = isTimedOut(restriction?.mutedUntil ?? null);
const itemClassName =
"flex min-h-9 w-full select-none items-center gap-2 rounded-lg py-2 pl-2 pr-4 text-left text-sm outline-hidden transition-colors hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0";
const run = async (action: () => Promise<unknown>, success: string) => {
try {
await action();
toast.success(success);
onAction?.();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Moderation action failed",
);
}
};
if (!enabled || targetPubkey == null) return null;
return (
<>
<hr className="-mx-1 my-1 border-0 border-t border-muted" />
{timedOut ? (
<button
className={itemClassName}
data-testid={`message-untimeout-${message.id}`}
disabled={isPending}
onClick={() =>
void run(
() => untimeoutMutation.mutateAsync(targetPubkey),
"Timeout lifted",
)
}
role="menuitem"
type="button"
>
<ShieldCheck />
Lift timeout
</button>
) : (
<>
<button
aria-expanded={showTimeouts}
className={itemClassName}
data-testid={`message-timeout-${message.id}`}
disabled={isPending}
onClick={() => setShowTimeouts((current) => !current)}
role="menuitem"
type="button"
>
<Clock />
Time out author
<ChevronRight
className={`ml-auto transition-transform ${showTimeouts ? "rotate-90" : ""}`}
/>
</button>
{showTimeouts ? (
<div className="pl-6">
{TIMEOUT_PRESETS.map((preset) => (
<button
className={itemClassName}
data-testid={`message-timeout-${preset.seconds}-${message.id}`}
disabled={isPending}
key={preset.seconds}
onClick={() =>
void run(
() =>
timeoutMutation.mutateAsync({
pubkey: targetPubkey,
expiresAt:
Math.floor(Date.now() / 1000) + preset.seconds,
}),
"Author timed out",
)
}
role="menuitem"
type="button"
>
{preset.label}
</button>
))}
</div>
) : null}
</>
)}
{channelId ? (
<button
className={`${itemClassName} text-destructive`}
data-testid={`message-kick-${message.id}`}
disabled={isPending}
onClick={() =>
void run(
() => removeMutation.mutateAsync(targetPubkey),
"Author removed from channel",
)
}
role="menuitem"
type="button"
>
<UserMinus />
Kick from channel
</button>
) : null}
<button
className={`${itemClassName} ${isBanned ? "" : "text-destructive"}`}
data-testid={`message-${isBanned ? "unban" : "ban"}-${message.id}`}
disabled={isPending}
onClick={() =>
void run(
() =>
isBanned
? unbanMutation.mutateAsync(targetPubkey)
: banMutation.mutateAsync({ pubkey: targetPubkey }),
isBanned ? "Ban lifted" : "Author banned",
)
}
role="menuitem"
type="button"
>
{isBanned ? <CircleSlash /> : <Ban />}
{isBanned ? "Lift ban" : "Ban author from community"}
</button>
</>
);
}

View File

@@ -317,3 +317,62 @@ test("a delayed Bestie open is scoped to its rendered community and signer", asy
await expect(page.getByTestId("chat-title")).toHaveCount(0);
await expect(page.getByTestId("open-bestie-dm")).toHaveCount(0);
});
test("emoji and overflow expand from one measured message toolbar surface", async ({
page,
}) => {
await installMockBridge(page, { managedAgents: [bestie] });
await page.setViewportSize({ width: 1000, height: 760 });
await page.goto("/");
await page.getByTestId("channel-general").click();
const messageId = await page.evaluate(() => {
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) throw new Error("Mock message emitter is unavailable.");
return emit({
channelName: "general",
content: "Bloom architecture check",
id: "c".repeat(64),
}).id;
});
const row = page.locator(`[data-message-id="${messageId}"]`);
await expect(row).toContainText("Bloom architecture check");
await row.hover();
const container = row.getByTestId(
`message-action-bloom-container-${messageId}`,
);
const closedBox = await container.boundingBox();
expect(closedBox).not.toBeNull();
await row.getByTestId(`react-message-${messageId}`).click();
await expect(
row.getByTestId(`reaction-bloom-panel-${messageId}`),
).toBeVisible();
await expect(container).toHaveAttribute("popover", "manual");
await expect(
row.locator(`[data-testid="message-action-bloom-container-${messageId}"]`),
).toHaveCount(1);
const reactionBox = await container.boundingBox();
expect(reactionBox).not.toBeNull();
expect(
Math.abs(
(closedBox?.x ?? 0) +
(closedBox?.width ?? 0) -
((reactionBox?.x ?? 0) + (reactionBox?.width ?? 0)),
),
).toBeLessThanOrEqual(1);
await page.keyboard.press("Escape");
await row.getByTestId(`more-actions-${messageId}`).click();
await expect(
row.getByTestId(`more-actions-panel-${messageId}`),
).toBeVisible();
const overflowBox = await container.boundingBox();
expect(overflowBox).not.toBeNull();
expect(
Math.abs(
(closedBox?.x ?? 0) +
(closedBox?.width ?? 0) -
((overflowBox?.x ?? 0) + (overflowBox?.width ?? 0)),
),
).toBeLessThanOrEqual(1);
});