diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index dd6b9195e8..f9a41ad6e9 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -193,3 +193,7 @@ export function deriveShellRoute(pathname: string): { selectedView: "home", }; } + +export function errorMessage(error: unknown): string | undefined { + return error instanceof Error ? error.message : undefined; +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 3b30caae4d..e5159c78a0 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Outlet, useLocation } from "@tanstack/react-router"; import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers"; +import { errorMessage } from "@/app/AppShell.helpers"; import { useTerminalContext } from "@/app/useTerminalContext"; import { AppShellProvider } from "@/app/AppShellContext"; import { AppShellOverlays, TerminalBootstrap } from "@/app/AppShellOverlays"; @@ -234,10 +235,7 @@ export function AppShell() { refetchHomeFeedFromLiveSignal, ); const { refetch: refetchChannels } = channelsQuery; - const channelsErrorMessage = - channelsQuery.error instanceof Error - ? channelsQuery.error.message - : undefined; + const channelsErrorMessage = errorMessage(channelsQuery.error); const relayConnectionCard = useSidebarRelayConnectionCard( channelsErrorMessage, communitiesHook.activeCommunity?.relayUrl, diff --git a/desktop/src/features/channels/forcedUnreadStore.ts b/desktop/src/features/channels/forcedUnreadStore.ts index c4c25dcecb..4d22eeabf2 100644 --- a/desktop/src/features/channels/forcedUnreadStore.ts +++ b/desktop/src/features/channels/forcedUnreadStore.ts @@ -16,8 +16,12 @@ import * as React from "react"; * On identity change, the in-memory map is swapped to the current pubkey's * persisted data. Old pubkey data is NOT wiped from localStorage. * - * NOT synced to the relay — NIP-RS markers are monotonic and cannot represent - * a retrograde "unread" state. localStorage is best-effort (per-device). + * The forced-unread state is backed by NIP-RS override registers synced to the + * relay. Marking a channel unread creates a register whose set-counter exceeds + * the clear-counter; clearing it (mark-read) advances the clear-counter via + * applyOverrideRead. localStorage stores the local evidence map so the badge + * survives reload and is readable for inactive communities without a live + * relay subscription. */ export type ForcedUnreadSource = "inbox" | "manual"; diff --git a/desktop/src/features/channels/nip-rs-unread-ui.test.mjs b/desktop/src/features/channels/nip-rs-unread-ui.test.mjs index f8483e7eb6..c55d56ab83 100644 --- a/desktop/src/features/channels/nip-rs-unread-ui.test.mjs +++ b/desktop/src/features/channels/nip-rs-unread-ui.test.mjs @@ -28,7 +28,7 @@ import { persistForcedUnread, } from "./readStateOverride.ts"; import { forcedUnreadStore } from "./forcedUnreadStore.ts"; -import { resolveChannelReadMarker } from "./useUnreadChannels.ts"; +import { resolveChannelReadMarker } from "./useUnreadChannelsHelpers.ts"; import { applyMarkUnreadDividerOverlay } from "./ui/useChannelUnreadState.ts"; // ── localStorage mock ──────────────────────────────────────────────────────── diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs index 660d4f5c61..474ba8136f 100644 --- a/desktop/src/features/channels/unreadReadMarker.test.mjs +++ b/desktop/src/features/channels/unreadReadMarker.test.mjs @@ -11,12 +11,12 @@ import { observedUnreadEventReadAt, recordObservedUnreadEvent, } from "./unreadChannelCounts.ts"; +import { addThreadActivityItems } from "./threadActivityStorage.ts"; import { - addThreadActivityItems, channelCatchUpEventKinds, resolveChannelReadMarker, resolveObservedUnreadRootId, -} from "./useUnreadChannels.ts"; +} from "./useUnreadChannelsHelpers.ts"; import { isChannelUnreadTriggerKind, trackSeenEvent, diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index 3df20c260a..d76e8df7af 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -2,7 +2,6 @@ import * as React from "react"; import { EMPTY_SET, useLiveChannelUpdates, - type UseLiveChannelUpdatesOptions, } from "@/features/channels/useLiveChannelUpdates"; import { countUnreadAppBadgeObservedEvents, @@ -16,7 +15,6 @@ import { type ObservedUnreadEvent, } from "@/features/channels/unreadChannelCounts"; import { useReadState } from "@/features/channels/readState/useReadState"; -import { makeRootIdStore } from "@/features/channels/unreadRootIdStore"; import { forcedUnreadStore, type ForcedUnreadMap, @@ -37,12 +35,9 @@ import { isHighPriorityEventForUser, shouldNotifyForEvent, } from "@/features/notifications/lib/shouldNotify"; -import type { RelayClient } from "@/shared/api/relayClientSession"; import type { Channel, RelayEvent } from "@/shared/api/types"; -import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds"; import { useStableMap, useStableSet } from "@/shared/hooks/useStableReference"; import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; -import { DM_NOTIFIABLE_EVENT_KINDS } from "./isDmNotifiableKind"; import { activityScopeKey, addThreadActivityItems, @@ -52,87 +47,19 @@ import { type ThreadActivityItem, } from "@/features/channels/threadActivityStorage"; export type { ThreadActivityItem } from "@/features/channels/threadActivityStorage"; -export { - activityScopeKey, - activityStorageKey, - addThreadActivityItems, - projectActivityForScope, - readActivityFromStorage, - writeActivityToStorage, -} from "@/features/channels/threadActivityStorage"; import { useObservedUnreadPersistence } from "@/features/channels/useObservedUnreadPersistence"; -type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & { - pubkey?: string; - relayClient?: RelayClient; - relayUrl?: string; - mutedChannelIds?: ReadonlySet; -}; - -// Per-channel cap on the catch-up REQ. We only consume the *max matching* -// event per channel, but the relay can return self-authored / non-trigger -// events that we discard client-side, so we need enough head-room for the -// filter to find one external trigger message. 1000 matches the live sub's -// per-channel limit elsewhere in the app. -const CATCH_UP_LIMIT = 1000; - -export function channelCatchUpEventKinds( - channelType: Channel["channelType"] | undefined, -) { - return channelType === "dm" - ? DM_NOTIFIABLE_EVENT_KINDS - : CHANNEL_MESSAGE_EVENT_KINDS; -} - -const participationStore = makeRootIdStore("buzz-thread-participation.v1"); -const authoredStore = makeRootIdStore("buzz-thread-authored.v1"); -// Thread roots where an external message @-mentioned the current user. The -// badge gate ORs this in so a mention recipient who never participated, -// authored, or followed still gets the thread-unread badge. -const mentionedStore = makeRootIdStore("buzz-thread-mentioned.v1"); -const mutedStore = makeRootIdStore("buzz-thread-muted.v1"); - -function parseTimestamp(value: string | null | undefined) { - if (!value) { - return null; - } - - const timestamp = Date.parse(value); - return Number.isNaN(timestamp) ? null : timestamp; -} - -function toUnixSeconds(isoOrMs: string | null | undefined): number | null { - const ms = parseTimestamp(isoOrMs); - return ms === null ? null : Math.floor(ms / 1_000); -} - -// Resolve where the read marker should land when a channel is marked read. -// Folds the caller's timeline position together with the newest event this -// client has observed live (`observedLatest`), so an explicit "mark read" still -// covers messages that arrived faster than channel metadata — this fold is -// load-bearing for the Esc shortcut, sidebar mark-read, and empty-channel open, -// all of which pass a null/stale caller value. `clearObserved` reports whether -// the resulting marker covers the observed timestamp, signalling the caller to -// drop its observed refs so the unread memo sees `latest === undefined` until a -// genuinely newer event arrives. -export function resolveChannelReadMarker( - callerReadAt: string | null | undefined, - observedLatest: number | undefined, -): { markAt: number | null; clearObserved: boolean } { - const callerUnix = toUnixSeconds(callerReadAt); - const markAt = Math.max(callerUnix ?? 0, observedLatest ?? 0) || null; - return { - markAt, - clearObserved: - markAt !== null && - observedLatest !== undefined && - observedLatest <= markAt, - }; -} - -export function resolveObservedUnreadRootId(tags: string[][]): string | null { - return isBroadcastReply(tags) ? null : getThreadReference(tags).rootId; -} +import { + type UseUnreadChannelsOptions, + CATCH_UP_LIMIT, + channelCatchUpEventKinds, + participationStore, + authoredStore, + mentionedStore, + mutedStore, + resolveChannelReadMarker, + resolveObservedUnreadRootId, +} from "@/features/channels/useUnreadChannelsHelpers"; export function useUnreadChannels( channels: Channel[], diff --git a/desktop/src/features/channels/useUnreadChannelsHelpers.ts b/desktop/src/features/channels/useUnreadChannelsHelpers.ts new file mode 100644 index 0000000000..1b8455c992 --- /dev/null +++ b/desktop/src/features/channels/useUnreadChannelsHelpers.ts @@ -0,0 +1,89 @@ +/** + * Shared constants, types, stores, and pure helpers extracted from + * useUnreadChannels to keep that file within the repository size ratchet. + * All exports are load-bearing for useUnreadChannels.ts. + */ +import type { UseLiveChannelUpdatesOptions } from "@/features/channels/useLiveChannelUpdates"; +import { makeRootIdStore } from "@/features/channels/unreadRootIdStore"; +import { + getThreadReference, + isBroadcastReply, +} from "@/features/messages/lib/threading"; +import type { RelayClient } from "@/shared/api/relayClientSession"; +import type { Channel } from "@/shared/api/types"; +import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds"; +import { DM_NOTIFIABLE_EVENT_KINDS } from "./isDmNotifiableKind"; + +export type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & { + pubkey?: string; + relayClient?: RelayClient; + relayUrl?: string; + mutedChannelIds?: ReadonlySet; +}; + +// Per-channel cap on the catch-up REQ. We only consume the *max matching* +// event per channel, but the relay can return self-authored / non-trigger +// events that we discard client-side, so we need enough head-room for the +// filter to find one external trigger message. 1000 matches the live sub's +// per-channel limit elsewhere in the app. +export const CATCH_UP_LIMIT = 1000; + +export function channelCatchUpEventKinds( + channelType: Channel["channelType"] | undefined, +) { + return channelType === "dm" + ? DM_NOTIFIABLE_EVENT_KINDS + : CHANNEL_MESSAGE_EVENT_KINDS; +} + +export const participationStore = makeRootIdStore( + "buzz-thread-participation.v1", +); +export const authoredStore = makeRootIdStore("buzz-thread-authored.v1"); +// Thread roots where an external message @-mentioned the current user. The +// badge gate ORs this in so a mention recipient who never participated, +// authored, or followed still gets the thread-unread badge. +export const mentionedStore = makeRootIdStore("buzz-thread-mentioned.v1"); +export const mutedStore = makeRootIdStore("buzz-thread-muted.v1"); + +function parseTimestamp(value: string | null | undefined) { + if (!value) { + return null; + } + + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? null : timestamp; +} + +function toUnixSeconds(isoOrMs: string | null | undefined): number | null { + const ms = parseTimestamp(isoOrMs); + return ms === null ? null : Math.floor(ms / 1_000); +} + +// Resolve where the read marker should land when a channel is marked read. +// Folds the caller's timeline position together with the newest event this +// client has observed live (`observedLatest`), so an explicit "mark read" still +// covers messages that arrived faster than channel metadata — this fold is +// load-bearing for the Esc shortcut, sidebar mark-read, and empty-channel open, +// all of which pass a null/stale caller value. `clearObserved` reports whether +// the resulting marker covers the observed timestamp, signalling the caller to +// drop its observed refs so the unread memo sees `latest === undefined` until a +// genuinely newer event arrives. +export function resolveChannelReadMarker( + callerReadAt: string | null | undefined, + observedLatest: number | undefined, +): { markAt: number | null; clearObserved: boolean } { + const callerUnix = toUnixSeconds(callerReadAt); + const markAt = Math.max(callerUnix ?? 0, observedLatest ?? 0) || null; + return { + markAt, + clearObserved: + markAt !== null && + observedLatest !== undefined && + observedLatest <= markAt, + }; +} + +export function resolveObservedUnreadRootId(tags: string[][]): string | null { + return isBroadcastReply(tags) ? null : getThreadReference(tags).rootId; +} diff --git a/desktop/src/shared/api/relayChannelFilters.ts b/desktop/src/shared/api/relayChannelFilters.ts index b9f623a75f..551f3c1a39 100644 --- a/desktop/src/shared/api/relayChannelFilters.ts +++ b/desktop/src/shared/api/relayChannelFilters.ts @@ -3,12 +3,14 @@ import { CHANNEL_EVENT_KINDS, CHANNEL_TIMELINE_CONTENT_KINDS, HOME_MENTION_EVENT_KINDS, + KIND_CHANNEL_THREAD_SUMMARY, KIND_DELETION, KIND_NIP29_DELETE_EVENT, KIND_REACTION, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, KIND_STREAM_MESSAGE_EDIT, + KIND_TYPING_INDICATOR, } from "@/shared/constants/kinds"; import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared"; @@ -175,3 +177,35 @@ export function buildChannelMentionFilter( since: Math.floor(Date.now() / 1_000), }; } + +/** + * Live-subscription filter for an open channel starting now, with no history + * replay. Includes {@link CHANNEL_EVENT_KINDS} plus KIND_CHANNEL_THREAD_SUMMARY + * (39005), which rides only this window-store subscription — the summary + * overlays must never reach other consumers (unread tracking, cache merges). + */ +export function buildChannelLiveFilter( + channelId: string, +): RelaySubscriptionFilter { + return { + kinds: [...CHANNEL_EVENT_KINDS, KIND_CHANNEL_THREAD_SUMMARY], + "#h": [channelId], + limit: 1000, + since: Math.floor(Date.now() / 1_000), + }; +} + +/** + * Typing-indicator subscription filter: recent indicators only (last 10 s), + * bounded by a small limit so the subscription stays lightweight. + */ +export function buildTypingIndicatorFilter( + channelId: string, +): RelaySubscriptionFilter { + return { + kinds: [KIND_TYPING_INDICATOR], + "#h": [channelId], + limit: 10, + since: Math.floor(Date.now() / 1_000) - 10, + }; +} diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 6adf40cbea..90bc6c0bf5 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -9,8 +9,6 @@ import { KIND_STREAM_MESSAGE, KIND_TYPING_INDICATOR, KIND_USER_STATUS, - CHANNEL_EVENT_KINDS, - KIND_CHANNEL_THREAD_SUMMARY, } from "@/shared/constants/kinds"; import { getTextPayload, @@ -25,8 +23,10 @@ import { buildChannelAuxDeletionFilter, buildChannelFilter, buildChannelHistoryFilter, + buildChannelLiveFilter, buildChannelMentionFilter, buildGlobalStreamFilter, + buildTypingIndicatorFilter, } from "@/shared/api/relayChannelFilters"; import { clearClosedRetry, @@ -334,18 +334,7 @@ export class RelayClient { channelId: string, onEvent: (event: RelayEvent) => void, ) { - // 39005 rides only this window-store subscription — CHANNEL_EVENT_KINDS' - // other consumers (unread tracking, cache merges) must never see - // summary overlays. - return this.subscribe( - { - kinds: [...CHANNEL_EVENT_KINDS, KIND_CHANNEL_THREAD_SUMMARY], - "#h": [channelId], - limit: 1000, - since: Math.floor(Date.now() / 1_000), - }, - onEvent, - ); + return this.subscribe(buildChannelLiveFilter(channelId), onEvent); } /** @@ -371,15 +360,7 @@ export class RelayClient { channelId: string, onEvent: (event: RelayEvent) => void, ) { - return this.subscribe( - { - kinds: [KIND_TYPING_INDICATOR], - "#h": [channelId], - limit: 10, - since: Math.floor(Date.now() / 1_000) - 10, - }, - onEvent, - ); + return this.subscribe(buildTypingIndicatorFilter(channelId), onEvent); } async subscribeToPresenceUpdates(onEvent: (event: RelayEvent) => void) {