Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 16 additions & 14 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -668,6 +669,7 @@ export function AppShell() {
void applyAgents(templateId, createdForum.id);
}}
onHideDm={handleHideDm}
onMarkChannelUnread={markChannelUnread}
onOpenBrowseChannels={handleOpenBrowseChannels}
onOpenBrowseForums={handleOpenBrowseForums}
onOpenDm={async ({ pubkeys }) => {
Expand Down
27 changes: 19 additions & 8 deletions desktop/src/features/channels/readState/readStateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -404,6 +414,7 @@ export class ReadStateManager {
);

this.lastPublishedContexts = contexts;
this.forcedContexts.clear();
this.maxFetchedCreatedAt = Math.max(
this.maxFetchedCreatedAt,
event.created_at,
Expand Down
10 changes: 10 additions & 0 deletions desktop/src/features/channels/readState/useReadState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -79,6 +87,7 @@ export function useReadState(
getEffectiveTimestamp: noopGetTimestamp,
isReady: false,
markContextRead: noopMarkRead,
markContextUnread: noopMarkUnread,
seedContextRead: noopMarkRead,
readStateVersion: 0,
};
Expand All @@ -88,6 +97,7 @@ export function useReadState(
getEffectiveTimestamp,
isReady,
markContextRead,
markContextUnread,
seedContextRead,
readStateVersion,
};
Expand Down
11 changes: 11 additions & 0 deletions desktop/src/features/channels/useUnreadChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function useUnreadChannels(
getEffectiveTimestamp,
isReady: isReadStateReady,
markContextRead,
markContextUnread,
readStateVersion,
seedContextRead,
} = useReadState(pubkey, relayClient);
Expand All @@ -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."
Expand Down Expand Up @@ -132,5 +142,6 @@ export function useUnreadChannels(
return {
unreadChannelIds,
markChannelRead,
markChannelUnread,
};
}
54 changes: 44 additions & 10 deletions desktop/src/features/sidebar/ui/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Activity,
Bot,
ChevronDown,
CircleDot,
FolderGit2,
Home,
PenSquare,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -204,6 +215,7 @@ function ChannelGroupSection({
listTestId,
onBrowse,
onCreateClick,
onMarkChannelUnread,
onSelectChannel,
onToggleCollapsed,
selectedChannelId,
Expand All @@ -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;
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -313,6 +343,7 @@ export function AppSidebar({
onOpenBrowseForums,
onOpenSearch,
onHideDm,
onMarkChannelUnread,
onOpenDm,
onUpdateWorkspace,
onRemoveWorkspace,
Expand Down Expand Up @@ -564,6 +595,7 @@ export function AppSidebar({
listTestId="stream-list"
onBrowse={onOpenBrowseChannels}
onCreateClick={() => setCreateDialogKind("stream")}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("channels")}
selectedChannelId={selectedChannelId}
Expand All @@ -580,6 +612,7 @@ export function AppSidebar({
listTestId="forum-list"
onBrowse={onOpenBrowseForums}
onCreateClick={() => setCreateDialogKind("forum")}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("forums")}
selectedChannelId={selectedChannelId}
Expand Down Expand Up @@ -610,6 +643,7 @@ export function AppSidebar({
items={directMessages}
channelLabels={dmChannelLabels}
onHideDm={onHideDm}
onMarkChannelUnread={onMarkChannelUnread}
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("directMessages")}
presenceByChannelId={dmPresenceByChannelId}
Expand Down
Loading
Loading