feat(desktop): channel hover state and right-click mark-unread context menu (#578)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Toohey
2026-05-14 08:33:57 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent b176b52b70
commit f0549b5038
9 changed files with 396 additions and 66 deletions
+1
View File
@@ -25,6 +25,7 @@
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-focus-scope": "^1.1.8",
+16 -14
View File
@@ -264,20 +264,21 @@ export function AppShell() {
[channels, selectedChannelId],
);
const { markChannelRead, unreadChannelIds } = useUnreadChannels(
channels,
activeChannel,
// Wait for ChannelScreen to report the latest loaded message before
// advancing unread state for the active channel.
null,
{
pubkey: identityQuery.data?.pubkey,
relayClient,
currentPubkey: identityQuery.data?.pubkey,
onDmMessage: handleDmNotification,
onLiveMention: refetchHomeFeedOnLiveMention,
},
);
const { markChannelRead, markChannelUnread, unreadChannelIds } =
useUnreadChannels(
channels,
activeChannel,
// Wait for ChannelScreen to report the latest loaded message before
// advancing unread state for the active channel.
null,
{
pubkey: identityQuery.data?.pubkey,
relayClient,
currentPubkey: identityQuery.data?.pubkey,
onDmMessage: handleDmNotification,
onLiveMention: refetchHomeFeedOnLiveMention,
},
);
const createChannelMutation = useCreateChannelMutation();
const createForumMutation = useCreateChannelMutation();
@@ -668,6 +669,7 @@ export function AppShell() {
void applyAgents(templateId, createdForum.id);
}}
onHideDm={handleHideDm}
onMarkChannelUnread={markChannelUnread}
onOpenBrowseChannels={handleOpenBrowseChannels}
onOpenBrowseForums={handleOpenBrowseForums}
onOpenDm={async ({ pubkeys }) => {
@@ -62,23 +62,18 @@ export class ReadStateManager {
// Last-published blob content (for diff to suppress no-op publishes)
private lastPublishedContexts: Record<string, number> = {};
// Debounce timer
private debounceTimer: number | null = null;
// Subscribers for React integration
private listeners = new Set<() => void>();
// Live subscription teardown
private unsubscribeLive: (() => void) | null = null;
// Track whether we've initialized
private initialized = false;
// Track the max created_at seen from fetched blobs so publishes can satisfy
// NIP-RS clock-skew monotonicity for our d-tag coordinate.
private maxFetchedCreatedAt = 0;
// Contexts rolled back by mark-unread, protected from merge until next publish.
private forcedContexts = new Set<string>();
constructor(pubkey: string, relayClient: RelayClient) {
this.pubkey = pubkey;
this.relayClient = relayClient;
@@ -113,6 +108,19 @@ export class ReadStateManager {
this.advanceContext(contextId, unixTimestamp, { publishable: false });
}
markContextUnread(contextId: string, lastMessageUnix: number): void {
// Roll back the read timestamp to just before the last message so the
// channel appears unread. This is published via NIP-RS and syncs across
// devices.
const rollbackTo = lastMessageUnix - 1;
this.effectiveState.set(contextId, rollbackTo);
this.publishableContextIds.add(contextId);
this.forcedContexts.add(contextId);
this.persistLocalState();
this.notifyListeners();
this.schedulePublish();
}
private advanceContext(
contextId: string,
unixTimestamp: number,
@@ -230,6 +238,7 @@ export class ReadStateManager {
// Merge into effective state
for (const [ctx, ts] of Object.entries(blob.contexts)) {
if (this.forcedContexts.has(ctx)) continue;
const current = this.effectiveState.get(ctx) ?? 0;
if (ts > current) {
this.effectiveState.set(ctx, ts);
@@ -331,6 +340,7 @@ export class ReadStateManager {
// Merge into effective state
let anyAdvanced = false;
for (const [ctx, ts] of Object.entries(blob.contexts)) {
if (this.forcedContexts.has(ctx)) continue;
const current = this.effectiveState.get(ctx) ?? 0;
if (ts > current) {
this.effectiveState.set(ctx, ts);
@@ -404,6 +414,7 @@ export class ReadStateManager {
);
this.lastPublishedContexts = contexts;
this.forcedContexts.clear();
this.maxFetchedCreatedAt = Math.max(
this.maxFetchedCreatedAt,
event.created_at,
@@ -4,6 +4,7 @@ import type { RelayClient } from "@/shared/api/relayClientSession";
const noopGetTimestamp = () => null;
const noopMarkRead = () => {};
const noopMarkUnread = () => {};
/**
* React hook that creates and manages a ReadStateManager instance.
@@ -63,6 +64,13 @@ export function useReadState(
[],
);
const markContextUnread = React.useCallback(
(contextId: string, lastMessageUnix: number): void => {
managerRef.current?.markContextUnread(contextId, lastMessageUnix);
},
[],
);
const seedContextRead = React.useCallback(
(contextId: string, unixTimestamp: number): void => {
managerRef.current?.seedContextRead(contextId, unixTimestamp);
@@ -79,6 +87,7 @@ export function useReadState(
getEffectiveTimestamp: noopGetTimestamp,
isReady: false,
markContextRead: noopMarkRead,
markContextUnread: noopMarkUnread,
seedContextRead: noopMarkRead,
readStateVersion: 0,
};
@@ -88,6 +97,7 @@ export function useReadState(
getEffectiveTimestamp,
isReady,
markContextRead,
markContextUnread,
seedContextRead,
readStateVersion,
};
@@ -45,6 +45,7 @@ export function useUnreadChannels(
getEffectiveTimestamp,
isReady: isReadStateReady,
markContextRead,
markContextUnread,
readStateVersion,
seedContextRead,
} = useReadState(pubkey, relayClient);
@@ -61,6 +62,15 @@ export function useUnreadChannels(
[markContextRead],
);
const markChannelUnread = React.useCallback(
(channelId: string, lastMessageAt: string | null | undefined) => {
const unixSeconds = toUnixSeconds(lastMessageAt);
if (unixSeconds === null) return;
markContextUnread(channelId, unixSeconds);
},
[markContextUnread],
);
// Seed new channels so they don't flash as unread on first load.
// For channels the user hasn't read yet, initialize read-at to the
// channel's current lastMessageAt so they appear as "read."
@@ -132,5 +142,6 @@ export function useUnreadChannels(
return {
unreadChannelIds,
markChannelRead,
markChannelUnread,
};
}
+44 -10
View File
@@ -3,6 +3,7 @@ import {
Activity,
Bot,
ChevronDown,
CircleDot,
FolderGit2,
Home,
PenSquare,
@@ -37,6 +38,12 @@ import type {
} from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/shared/ui/context-menu";
import {
Sidebar,
SidebarContent,
@@ -119,6 +126,10 @@ type AppSidebarProps = {
onOpenBrowseForums: () => void;
onOpenSearch: () => void;
onHideDm: (channelId: string) => void;
onMarkChannelUnread: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
onUpdateWorkspace: (
id: string,
@@ -204,6 +215,7 @@ function ChannelGroupSection({
listTestId,
onBrowse,
onCreateClick,
onMarkChannelUnread,
onSelectChannel,
onToggleCollapsed,
selectedChannelId,
@@ -220,6 +232,10 @@ function ChannelGroupSection({
listTestId: string;
onBrowse: () => void;
onCreateClick: () => void;
onMarkChannelUnread: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onSelectChannel: (channelId: string) => void;
onToggleCollapsed: () => void;
selectedChannelId: string | null;
@@ -263,16 +279,30 @@ function ChannelGroupSection({
{items.length > 0 ? (
<SidebarMenu data-testid={listTestId}>
{items.map((channel) => (
<SidebarMenuItem key={channel.id}>
<ChannelMenuButton
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={
isActiveChannel && selectedChannelId === channel.id
}
onSelectChannel={onSelectChannel}
/>
</SidebarMenuItem>
<ContextMenu key={channel.id}>
<ContextMenuTrigger asChild>
<SidebarMenuItem>
<ChannelMenuButton
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={
isActiveChannel && selectedChannelId === channel.id
}
onSelectChannel={onSelectChannel}
/>
</SidebarMenuItem>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() =>
onMarkChannelUnread(channel.id, channel.lastMessageAt)
}
>
<CircleDot className="h-4 w-4" />
Mark unread
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</SidebarMenu>
) : null}
@@ -313,6 +343,7 @@ export function AppSidebar({
onOpenBrowseForums,
onOpenSearch,
onHideDm,
onMarkChannelUnread,
onOpenDm,
onUpdateWorkspace,
onRemoveWorkspace,
@@ -564,6 +595,7 @@ export function AppSidebar({
listTestId="stream-list"
onBrowse={onOpenBrowseChannels}
onCreateClick={() => setCreateDialogKind("stream")}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("channels")}
selectedChannelId={selectedChannelId}
@@ -580,6 +612,7 @@ export function AppSidebar({
listTestId="forum-list"
onBrowse={onOpenBrowseForums}
onCreateClick={() => setCreateDialogKind("forum")}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("forums")}
selectedChannelId={selectedChannelId}
@@ -610,6 +643,7 @@ export function AppSidebar({
items={directMessages}
channelLabels={dmChannelLabels}
onHideDm={onHideDm}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("directMessages")}
presenceByChannelId={dmPresenceByChannelId}
@@ -1,6 +1,13 @@
import type * as React from "react";
import { ChevronDown, CircleDot, FileText, Hash, Lock, X } from "lucide-react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/shared/ui/context-menu";
import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
@@ -178,7 +185,7 @@ export function ChannelMenuButton({
{hasUnread && !isActive && channel.channelType !== "dm" ? (
<span
aria-hidden="true"
className="ml-auto h-2.5 w-2.5 shrink-0 rounded-full bg-primary group-hover/menu-item:hidden"
className="ml-auto h-2.5 w-2.5 shrink-0 rounded-full bg-primary"
data-testid={`channel-unread-${channel.name}`}
/>
) : null}
@@ -200,6 +207,7 @@ export function SidebarSection({
testId,
unreadChannelIds,
onHideDm,
onMarkChannelUnread,
onSelectChannel,
onToggleCollapsed,
}: {
@@ -216,6 +224,10 @@ export function SidebarSection({
testId: string;
unreadChannelIds: Set<string>;
onHideDm?: (channelId: string) => void;
onMarkChannelUnread?: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onSelectChannel: (channelId: string) => void;
onToggleCollapsed?: () => void;
}) {
@@ -257,40 +269,63 @@ export function SidebarSection({
<SidebarGroupContent id={contentId}>
{items.length > 0 ? (
<SidebarMenu data-testid={testId}>
{items.map((channel) => (
<SidebarMenuItem className="group/menu-item" key={channel.id}>
<ChannelMenuButton
channel={channel}
dmParticipants={dmParticipantsByChannelId?.[channel.id]}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={
isActiveChannel && selectedChannelId === channel.id
}
label={channelLabels?.[channel.id] ?? channel.name}
presenceStatus={presenceByChannelId?.[channel.id]}
onSelectChannel={onSelectChannel}
/>
{channel.channelType === "dm" &&
unreadChannelIds.has(channel.id) &&
!(isActiveChannel && selectedChannelId === channel.id) ? (
<span
aria-hidden="true"
className="absolute right-[9px] top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full bg-primary group-hover/menu-item:hidden"
data-testid={`channel-unread-${channel.name}`}
{items.map((channel) => {
const menuItem = (
<SidebarMenuItem
key={onMarkChannelUnread ? undefined : channel.id}
className="group/menu-item"
>
<ChannelMenuButton
channel={channel}
dmParticipants={dmParticipantsByChannelId?.[channel.id]}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={
isActiveChannel && selectedChannelId === channel.id
}
label={channelLabels?.[channel.id] ?? channel.name}
presenceStatus={presenceByChannelId?.[channel.id]}
onSelectChannel={onSelectChannel}
/>
) : null}
{channel.channelType === "dm" && onHideDm ? (
<SidebarMenuAction
aria-label="Close direct message"
data-testid={`hide-dm-${channel.name}`}
onClick={() => onHideDm(channel.id)}
showOnHover
>
<X />
</SidebarMenuAction>
) : null}
</SidebarMenuItem>
))}
{channel.channelType === "dm" &&
unreadChannelIds.has(channel.id) &&
!(isActiveChannel && selectedChannelId === channel.id) ? (
<span
aria-hidden="true"
className="absolute right-[9px] top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full bg-primary"
data-testid={`channel-unread-${channel.name}`}
/>
) : null}
{channel.channelType === "dm" && onHideDm ? (
<SidebarMenuAction
aria-label="Close direct message"
data-testid={`hide-dm-${channel.name}`}
onClick={() => onHideDm(channel.id)}
showOnHover
>
<X />
</SidebarMenuAction>
) : null}
</SidebarMenuItem>
);
return onMarkChannelUnread ? (
<ContextMenu key={channel.id}>
<ContextMenuTrigger asChild>{menuItem}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() =>
onMarkChannelUnread(channel.id, channel.lastMessageAt)
}
>
<CircleDot className="h-4 w-4" />
Mark unread
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
) : (
menuItem
);
})}
</SidebarMenu>
) : emptyState ? (
<div
+196
View File
@@ -0,0 +1,196 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/shared/lib/cn";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 max-h-[var(--radix-context-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
+30
View File
@@ -38,6 +38,9 @@ importers:
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-context-menu':
specifier: ^2.2.16
version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -773,6 +776,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-context-menu@2.2.16':
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-context@1.1.2':
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
peerDependencies:
@@ -3423,6 +3439,20 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
react: 19.2.5