refactor(readState): extract file-size ratchet overages and update forcedUnreadStore header

Extract helpers to bring three over-limit files within the repository
file-size ratchet:

- AppShell.tsx (1001→999 wc): extract inline error ternary to
  errorMessage() in AppShell.helpers.ts.

- useUnreadChannels.ts (1091→1018 wc): extract UseUnreadChannelsOptions,
  CATCH_UP_LIMIT, channelCatchUpEventKinds, the four root-id stores, and
  the pure resolve helpers to useUnreadChannelsHelpers.ts. Update import
  paths in nip-rs-unread-ui.test.mjs and unreadReadMarker.test.mjs.

- relayClientSession.ts (1103→1085 wc): extract subscribeToChannelLive
  and subscribeToTypingIndicators inline filter objects to
  buildChannelLiveFilter() and buildTypingIndicatorFilter() in
  relayChannelFilters.ts. Remove now-unused KIND_CHANNEL_THREAD_SUMMARY
  and CHANNEL_EVENT_KINDS from relayClientSession kinds import.

Also update the forcedUnreadStore.ts module header: the old "NOT synced
to the relay" claim is false after this PR's NIP-RS override layer is
in place; replace with an accurate description of the localStorage/
relay relationship (doc-only, no behavior change).

All moves are mechanical — zero semantic change in any moved region.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-05 15:09:13 -04:00
parent 1eb6a0f2e4
commit b67546c337
9 changed files with 153 additions and 116 deletions

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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";

View File

@@ -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 ────────────────────────────────────────────────────────

View File

@@ -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,

View File

@@ -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<string>;
};
// 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[],

View File

@@ -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<string>;
};
// 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;
}

View File

@@ -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,
};
}

View File

@@ -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) {