import { type ActionProps, canTogglePin, hasVisibleActions } from "./actions"; import type { MouseEvent, ReactNode } from "react"; import { useRef, useState } from "react"; import { ContextMenu } from "@base-ui/react/context-menu"; import { hitArea } from "../../lib/hit-area"; import { cn } from "../../lib/utils"; import { motion } from "../../ui/popup"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip"; import type { MoveTarget, SidebarRow } from "./session-item.types"; import { IconArchive as ArchiveContent2, IconExit as Leave, IconFork as Fork, IconInfo as Info, IconPencil as Pencil, IconPin as Pin, IconPinOff as PinSlash, IconRotate as Rebuild, IconTrash as Trash, } from "../../icons"; const MENU_POPUP = "z-50 max-block-(--available-height) min-inline-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-(--r-popover) squircle bg-popover p-1 text-popover-foreground shadow-popover outline-none"; const MENU_POPUP_FULL = `${MENU_POPUP} ${motion}`; const MENU_ITEM = "relative flex min-h-7 cursor-default items-center gap-2 rounded-md squircle px-2 py-1 text-xs/relaxed outline-hidden select-none focus:bg-secondary focus:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5"; const MENU_ITEM_DESTRUCTIVE = "text-destructive focus:bg-destructive/10 focus:text-destructive dark:focus:bg-destructive/20"; const MENU_ITEM_DESTRUCTIVE_FULL = `${MENU_ITEM} ${MENU_ITEM_DESTRUCTIVE}`; const MENU_LABEL = "px-2 py-1.5 text-xs text-muted-foreground"; const MENU_SEPARATOR = "-mx-1 my-1 h-px bg-border/50"; const ACTION_BUTTON = cn( hitArea, "grid size-6 place-items-center rounded-md squircle text-muted-foreground outline-2 outline-offset-[-1px] outline-transparent transition-[background-color,color,transform] duration-150 ease-out hover:bg-background/70 hover:text-foreground group-data-[primary=true]/menu:active:translate-y-px focus-visible:outline-ring/50", ); const SESSION_ACTIONS = "absolute top-1/2 right-1 z-20 inline-flex -translate-y-1/2 items-center gap-0 -space-x-0.5 opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none pointer-events-none group-hover/row:pointer-events-auto group-focus-within/row:pointer-events-auto group-data-[menu-open=true]/menu:pointer-events-auto group-hover/row:opacity-100 group-focus-within/row:opacity-100 group-data-[menu-open=true]/menu:opacity-100"; type SessionActionsProps = ActionProps & { data: SidebarRow; }; type RowMenuProps = { data: SidebarRow; onRename?: (data: SidebarRow) => void; onFork?: (data: SidebarRow) => void; onInfo?: (data: SidebarRow) => void; onLeave?: (data: SidebarRow) => void; onArchive?: (data: SidebarRow) => void; onDelete?: (data: SidebarRow) => void; onRebuild?: (data: SidebarRow) => void; onMoveTargets?: (data: SidebarRow) => Promise; onMove?: (data: SidebarRow, target: MoveTarget) => Promise; children: ReactNode; }; type Destinations = | { state: "loading" } | { state: "ready"; targets: MoveTarget[] } | { state: "error" }; function stop(event: MouseEvent) { event.stopPropagation(); } function ActionButton( { label, children, onClick, }: { label: string; children: ReactNode; onClick?: (event: MouseEvent) => void; }, ) { return ( } > {children} {label} ); } export function SessionActions( { data, pinned, onPin, onUnpin, onArchive, }: SessionActionsProps, ) { let canPin = canTogglePin({ pinned, onPin, onUnpin, onArchive }); if (!hasVisibleActions({ pinned, onPin, onUnpin, onArchive })) return null; function pin(event: MouseEvent) { stop(event); if (pinned) onUnpin?.(data); else onPin?.(data); } function archive(event: MouseEvent) { stop(event); onArchive?.(data); } return ( {canPin && ( {pinned ? : } )} {onArchive && ( )} ); } function MoveSection( { destinations, moving, onMove }: { destinations: Destinations; moving?: string; onMove: (target: MoveTarget) => void; }, ) { return ( <> Move to: {destinations.state === "loading" && ( Loading… )} {destinations.state === "error" && ( Couldn't load destinations )} {destinations.state === "ready" && destinations.targets.length === 0 && ( No available destinations )} {destinations.state === "ready" && destinations.targets.map((target) => ( { stop(event); onMove(target); }} > {target.label} {moving === target.target && ( Moving… )} ))} ); } export function RowMenu( { data, onRename, onFork, onInfo, onLeave, onArchive, onDelete, onRebuild, onMoveTargets, onMove, children, }: RowMenuProps, ) { let anchor = useRef(null); let request = useRef(0); let [open, setOpen] = useState(false); let [primary, setPrimary] = useState(true); let [destinations, setDestinations] = useState({ state: "loading" }); let [moving, setMoving] = useState(); let lobby = data.kind === "lobby"; let archived = data.lifecycle === "archived"; let movable = onMoveTargets && onMove; let leave = data.member ? onLeave : undefined; function rename(event: MouseEvent) { stop(event); onRename?.(data); } function fork(event: MouseEvent) { stop(event); onFork?.(data); } function info(event: MouseEvent) { stop(event); onInfo?.(data); } function exit(event: MouseEvent) { stop(event); leave?.(data); } function archive(event: MouseEvent) { stop(event); onArchive?.(data); } function del(event: MouseEvent) { stop(event); onDelete?.(data); } function rebuild(event: MouseEvent) { stop(event); onRebuild?.(data); } // Destinations depend on live services and idleness, so each opening asks again. function change(next: boolean) { setOpen(next); if (!next || !onMoveTargets) return; let id = ++request.current; setDestinations({ state: "loading" }); onMoveTargets(data).then( (targets) => { if (id === request.current) setDestinations({ state: "ready", targets }); }, () => { if (id === request.current) setDestinations({ state: "error" }); }, ); } function move(target: MoveTarget) { if (moving || !onMove) return; setMoving(target.target); onMove(data, target).finally(() => setMoving(undefined)); } return ( setPrimary(event.button === 0 && !event.ctrlKey)} onKeyDownCapture={() => setPrimary(true)} className="group/menu" /> } > {children} {!lobby && !archived && ( <> Rename channel Fork channel Channel info {data.member && ( Leave channel )} {movable && ( )} Archive channel )} {!lobby && archived && ( Delete channel )} {lobby && ( Rebuild Lobby )} ); }