mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[],
|
||||
|
||||
89
desktop/src/features/channels/useUnreadChannelsHelpers.ts
Normal file
89
desktop/src/features/channels/useUnreadChannelsHelpers.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user