mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
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:
@@ -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";
|
||||
|
||||
534
desktop/src/features/messages/ui/MessageActionBloomBar.tsx
Normal file
534
desktop/src/features/messages/ui/MessageActionBloomBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
287
desktop/src/features/messages/ui/MessageActionOverflowPanel.tsx
Normal file
287
desktop/src/features/messages/ui/MessageActionOverflowPanel.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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(":");
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user