diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 02dcdcaf6d..485752cf66 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -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; + onRemindLater?: (message: TimelineMessage) => void; + onReply?: (message: TimelineMessage) => void; + onSendToChannel?: (message: TimelineMessage) => Promise; + 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; - onRemindLater?: (message: TimelineMessage) => void; - onReply?: (message: TimelineMessage) => void; - onSendToChannel?: (message: TimelineMessage) => Promise; - 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 ? ( + + ) : ( + + ); +}); + MessageActionBar.displayName = "MessageActionBar"; diff --git a/desktop/src/features/messages/ui/MessageActionBloomBar.tsx b/desktop/src/features/messages/ui/MessageActionBloomBar.tsx new file mode 100644 index 0000000000..1fb7f4c67e --- /dev/null +++ b/desktop/src/features/messages/ui/MessageActionBloomBar.tsx @@ -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(null); + const reduceMotion = useReducedMotion(); + const surfaceRef = React.useRef(null); + const toolbarRef = React.useRef(null); + const activeContentRef = React.useRef(null); + const anchorRectRef = React.useRef(null); + const demotionTimerRef = React.useRef(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(null); + const moreTriggerRef = React.useRef(null); + const lastSurfaceRef = React.useRef(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) => { + 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("[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 ( +
+ + + {hasReactionAction && quickReactionItems.length > 0 ? ( +
+ {quickReactionItems.map(({ customEmojiUrl, emoji }) => ( + + ))} +
+ ) : null} + {hasReactionAction ? ( + + + + + React + + ) : null} + {hasReactionAction && quickReactionItems.length > 0 ? ( + + ); +} diff --git a/desktop/src/features/messages/ui/MessageActionBloomSurface.tsx b/desktop/src/features/messages/ui/MessageActionBloomSurface.tsx new file mode 100644 index 0000000000..a1b82390f3 --- /dev/null +++ b/desktop/src/features/messages/ui/MessageActionBloomSurface.tsx @@ -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, + "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 ( + + {children} + + ); +}); diff --git a/desktop/src/features/messages/ui/MessageActionOverflowPanel.tsx b/desktop/src/features/messages/ui/MessageActionOverflowPanel.tsx new file mode 100644 index 0000000000..fca63499fc --- /dev/null +++ b/desktop/src/features/messages/ui/MessageActionOverflowPanel.tsx @@ -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; + 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(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('[role="menuitem"]:not([disabled])') + ?.focus(); + }, []); + + const handleMenuKeyDown = (event: React.KeyboardEvent) => { + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; + const items = Array.from( + event.currentTarget.querySelectorAll( + '[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 ( + <> +
+ {onEdit ? ( + + ) : null} + + {onMarkRead || onMarkUnread ? ( + + ) : null} + + {onFollowThread || onUnfollowThread ? ( + + ) : null} + + {hasCopyActions ? ( + + ) : null} + + {onRemindLater ? ( + + ) : null} + + {onSendToChannel ? ( + + ) : null} + + {canCopyMessageLink(message, channelId) ? ( + + ) : null} + + {canReport || onDelete ? ( +
+ ) : null} + {canReport ? ( + + ) : null} + {onDelete ? ( + + ) : null} + +
+ + {onDelete ? ( + onDelete(message)} + onOpenChange={(nextOpen) => { + setIsDeleteDialogOpen(nextOpen); + if (!nextOpen) onClose(); + }} + open={isDeleteDialogOpen} + /> + ) : null} + {canReport ? ( + { + setIsReportDialogOpen(nextOpen); + if (!nextOpen) onClose(); + }} + open={isReportDialogOpen} + /> + ) : null} + + ); +} diff --git a/desktop/src/features/messages/ui/MessageActionToolbarHelpers.tsx b/desktop/src/features/messages/ui/MessageActionToolbarHelpers.tsx new file mode 100644 index 0000000000..6df4ebe97a --- /dev/null +++ b/desktop/src/features/messages/ui/MessageActionToolbarHelpers.tsx @@ -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 ( + + + + + {displayName} + + ); +} + +export function isCustomEmojiShortcode(emoji: string) { + return emoji.startsWith(":") && emoji.endsWith(":"); +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 0f1ab3c981..9c83b12563 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -171,6 +171,7 @@ export const MessageRow = React.memo( const [expandedDiffId, setExpandedDiffId] = React.useState( 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 = (
) : null} -
); - // 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 && diff --git a/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx b/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx index e61f63b687..c524b0416f 100644 --- a/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx +++ b/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx @@ -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, 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 ( + <> +
+ {timedOut ? ( + + ) : ( + <> + + {showTimeouts ? ( +
+ {TIMEOUT_PRESETS.map((preset) => ( + + ))} +
+ ) : null} + + )} + + {channelId ? ( + + ) : null} + + + + ); +} diff --git a/desktop/tests/e2e/bestie-sidebar.spec.ts b/desktop/tests/e2e/bestie-sidebar.spec.ts index e408222532..f18c67281d 100644 --- a/desktop/tests/e2e/bestie-sidebar.spec.ts +++ b/desktop/tests/e2e/bestie-sidebar.spec.ts @@ -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); +});