diff --git a/desktop/package.json b/desktop/package.json index e87e95a9b..db8b59620 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 6ae8e12a4..74ceb7dd2 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -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 }) => { diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index 154eca096..84bbd8fb1 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -62,23 +62,18 @@ export class ReadStateManager { // Last-published blob content (for diff to suppress no-op publishes) private lastPublishedContexts: Record = {}; - - // 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(); + 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, diff --git a/desktop/src/features/channels/readState/useReadState.ts b/desktop/src/features/channels/readState/useReadState.ts index ebe05523f..06c7d497d 100644 --- a/desktop/src/features/channels/readState/useReadState.ts +++ b/desktop/src/features/channels/readState/useReadState.ts @@ -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, }; diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index 05233f49d..e20cff8c4 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -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, }; } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index be5c1cda0..0e2db8de1 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -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; 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 ? ( {items.map((channel) => ( - - - + + + + + + + + + onMarkChannelUnread(channel.id, channel.lastMessageAt) + } + > + + Mark unread + + + ))} ) : 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} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 789cffe12..16bc3e495 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -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" ? (