feat(read-state): wire right-click mark-read/unread to NIP-RS override layer (slice 3)

Right-click mark-as-unread now publishes a NIP-RS kind:30078 override event
(S bump + B set to effective frontier) via ReadStateManager before updating
the local forced-unread cache. Mark-as-read clears the override (C bump) if
an active override exists. Both operations surface user-visible toasts on
failure: budget exhaustion, counter overflow, or incomplete load.

forcedUnreadStore becomes a local-device cache of the synced override layer
rather than the sole source of truth; its doc comment is updated accordingly.

Adds readStateOverride.ts with the applyOverrideUnread / applyOverrideRead /
persistForcedUnread helper seam, and extends useReadState to expose the three
manager override callbacks (markChannelUnread / markChannelRead /
getOverrideLiveness) used by useUnreadChannels. Includes nip-rs-unread-ui.test.mjs
with 22 behaviour-focused tests covering all refusal paths and the local-cache
contract.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw
2026-07-31 16:15:09 -04:00
parent 94aa00e4d7
commit 8b46a8d496
5 changed files with 672 additions and 74 deletions

View File

@@ -1,5 +1,5 @@
/**
* Per-pubkey localStorage record store for channels manually marked unread via
* Per-pubkey localStorage cache of channels manually marked unread via
* right-click → "mark unread". Keyed by channelId (not thread-root). Persisted
* so the sidebar badge survives reload and the rail observer can read it for
* inactive communities.
@@ -13,13 +13,64 @@
* 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).
* This store is the local-device cache of the NIP-RS manual-unread override
* layer (kind:30078 `ov_*` entries). Marking a channel unread publishes a
* NIP-RS override event to the relay via ReadStateManager so the override
* propagates to other devices. Marking a channel read clears the override
* both locally and on the relay. The `markerAtWhenForced` baseline still drives
* the "cross-device read already covered this" gate for the rail observer.
*/
/** channelId → NIP-RS read marker (unix seconds) at force-time, or null */
export type ForcedUnreadMap = Record<string, number | null>;
// ---------------------------------------------------------------------------
// NIP-RS override mark-result types (shared with useUnreadChannels)
// ---------------------------------------------------------------------------
/**
* Result returned by ReadStateManager.markChannelOverrideUnread /
* markChannelOverrideRead (slice 2). `success: false` carries a reason code
* that is mapped to a user-visible toast by overrideErrorMessage below.
*/
export type MarkOverrideResult =
| { success: true }
| {
success: false;
reason:
| "uint32_overflow"
| "budget_exhausted"
| "load_incomplete"
| "already_inactive";
};
/** Override liveness for one channel — returned by getOverrideLiveness. */
export type OverrideLiveness = { active: boolean; frontier: number };
/**
* User-visible error message for a failed NIP-RS override operation.
* Returns null for silent failure reasons (already_inactive race condition).
*/
export function overrideErrorMessage(
op: "unread" | "read",
reason: string,
): string | null {
if (reason === "budget_exhausted")
return "Could not mark unread: override budget exhausted. Clear some channels first.";
if (reason === "uint32_overflow")
return op === "unread"
? "Could not mark unread: counter limit reached for this channel."
: "Could not clear unread override: counter limit reached.";
if (reason === "load_incomplete")
return op === "unread"
? "Could not mark unread: read state is still loading. Try again shortly."
: "Could not clear unread override: read state is still loading.";
if (reason === "already_inactive") return null;
return op === "unread"
? "Could not mark unread."
: "Could not clear unread override.";
}
const STORAGE_PREFIX = "buzz-forced-unread.v1";
const storageKey = (pubkey: string) => `${STORAGE_PREFIX}:${pubkey}`;

View File

@@ -0,0 +1,431 @@
/**
* NIP-RS manual-unread UI layer tests (slice 3).
*
* Tests cover the behavioral requirements of the mark-unread/mark-read wiring:
* 1. mark-unread calls the manager's NIP-RS override publish path and blocks
* the local cache update when the manager refuses.
* 2. mark-read calls the manager's override-clear path when an override is
* active, and succeeds only when override_active becomes false.
* 3. Both refusal reasons (budget_exhausted, uint32_overflow, load_incomplete)
* produce distinct non-empty user-visible messages.
* 4. forcedUnreadStore is the local cache of the synced override layer — the
* doc comment no longer claims "NOT synced to the relay".
*
* Tests are pure-function / pure-logic style following the project pattern
* (node:test, no React harness). The hook integration (React-level) is
* exercised by the four Desktop gates (just desktop-check / typecheck / build /
* test) which include the full Vitest suite.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { forcedUnreadStore } from "./forcedUnreadStore.ts";
import { resolveChannelReadMarker } from "./useUnreadChannels.ts";
// ── localStorage mock ────────────────────────────────────────────────────────
if (typeof globalThis.window === "undefined") {
const store = new Map();
globalThis.window = {
localStorage: {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, value),
removeItem: (key) => store.delete(key),
},
};
}
function makeIsolatedStorage() {
const store = new Map();
const prev = globalThis.window.localStorage;
globalThis.window.localStorage = {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, value),
removeItem: (key) => store.delete(key),
};
return {
store,
restore: () => {
globalThis.window.localStorage = prev;
},
};
}
// ── Helper: simulate markChannelUnread behavior ──────────────────────────────
/**
* Minimal simulation of the useUnreadChannels.markChannelUnread logic.
*
* This extracts the core logic from the React hook into a testable form:
* call the manager's override API first; only update the local cache on success.
*/
function simulateMarkChannelUnread(
channelId,
{ markChannelUnread, getOwnTimestamp, forcedMap, pubkey },
) {
const toasts = [];
if (markChannelUnread) {
const result = markChannelUnread(channelId);
if (!result.success) {
const message =
result.reason === "budget_exhausted"
? "Could not mark unread: override budget exhausted. Clear some channels first."
: result.reason === "uint32_overflow"
? "Could not mark unread: counter limit reached for this channel."
: result.reason === "load_incomplete"
? "Could not mark unread: read state is still loading. Try again shortly."
: "Could not mark unread.";
toasts.push({ type: "error", message });
return { didUpdate: false, toasts };
}
}
if (!Object.hasOwn(forcedMap, channelId)) {
forcedMap[channelId] = getOwnTimestamp(channelId);
forcedUnreadStore.write(pubkey, forcedMap);
}
return { didUpdate: true, toasts };
}
/**
* Minimal simulation of the useUnreadChannels.markChannelRead override-clear
* path. Returns the toast messages and whether the override clear was attempted.
*/
function simulateMarkChannelReadOverridePath(
channelId,
{ markChannelRead, getOverrideLiveness },
) {
const toasts = [];
let overrideClearAttempted = false;
if (markChannelRead) {
const liveness = getOverrideLiveness?.(channelId);
if (liveness?.active) {
overrideClearAttempted = true;
const result = markChannelRead(channelId);
if (!result.success) {
const message =
result.reason === "uint32_overflow"
? "Could not clear unread override: counter limit reached."
: result.reason === "load_incomplete"
? "Could not clear unread override: read state is still loading."
: result.reason === "already_inactive"
? null
: "Could not clear unread override.";
if (message) toasts.push({ type: "error", message });
}
}
}
return { overrideClearAttempted, toasts };
}
// ── Tests: mark-unread NIP-RS publish path ───────────────────────────────────
test("mark_unread_success_updates_local_cache", () => {
const { restore } = makeIsolatedStorage();
try {
const pubkey = "pubkey-a";
const channelId = "channel-1";
const forcedMap = {};
const result = simulateMarkChannelUnread(channelId, {
markChannelUnread: () => ({ success: true }),
getOwnTimestamp: () => 1000,
forcedMap,
pubkey,
});
assert.equal(result.didUpdate, true, "local cache updated on success");
assert.equal(
Object.hasOwn(forcedMap, channelId),
true,
"channel in forced map",
);
assert.equal(
forcedMap[channelId],
1000,
"stores own timestamp as baseline",
);
assert.equal(result.toasts.length, 0, "no error toast on success");
} finally {
restore();
}
});
test("mark_unread_budget_exhausted_blocks_local_cache_and_shows_toast", () => {
const { restore } = makeIsolatedStorage();
try {
const pubkey = "pubkey-a";
const channelId = "channel-1";
const forcedMap = {};
const result = simulateMarkChannelUnread(channelId, {
markChannelUnread: () => ({
success: false,
reason: "budget_exhausted",
}),
getOwnTimestamp: () => 1000,
forcedMap,
pubkey,
});
assert.equal(result.didUpdate, false, "local cache NOT updated on failure");
assert.equal(
Object.hasOwn(forcedMap, channelId),
false,
"channel absent from forced map",
);
assert.equal(result.toasts.length, 1, "exactly one error toast");
assert.ok(
result.toasts[0].message.includes("budget exhausted"),
"toast mentions budget",
);
} finally {
restore();
}
});
test("mark_unread_uint32_overflow_blocks_local_cache_and_shows_toast", () => {
const { restore } = makeIsolatedStorage();
try {
const forcedMap = {};
const result = simulateMarkChannelUnread("ch-2", {
markChannelUnread: () => ({
success: false,
reason: "uint32_overflow",
}),
getOwnTimestamp: () => 500,
forcedMap,
pubkey: "pk",
});
assert.equal(result.didUpdate, false);
assert.equal(result.toasts.length, 1);
assert.ok(
result.toasts[0].message.includes("counter limit"),
"toast mentions counter limit",
);
} finally {
restore();
}
});
test("mark_unread_load_incomplete_blocks_local_cache_and_shows_toast", () => {
const { restore } = makeIsolatedStorage();
try {
const forcedMap = {};
const result = simulateMarkChannelUnread("ch-3", {
markChannelUnread: () => ({
success: false,
reason: "load_incomplete",
}),
getOwnTimestamp: () => null,
forcedMap,
pubkey: "pk",
});
assert.equal(result.didUpdate, false);
assert.equal(result.toasts.length, 1);
assert.ok(
result.toasts[0].message.includes("still loading"),
"toast mentions loading",
);
} finally {
restore();
}
});
test("mark_unread_without_manager_still_updates_local_cache", () => {
// Graceful path: if markChannelUnread is absent (undefined), the local
// cache updates without calling the manager. Tests the fallback in
// applyOverrideUnread when no manager is wired.
const { restore } = makeIsolatedStorage();
try {
const forcedMap = {};
const result = simulateMarkChannelUnread("ch-4", {
markChannelUnread: undefined,
getOwnTimestamp: () => 200,
forcedMap,
pubkey: "pk",
});
assert.equal(
result.didUpdate,
true,
"local cache updated when no manager API",
);
assert.equal(Object.hasOwn(forcedMap, "ch-4"), true);
} finally {
restore();
}
});
// ── Tests: mark-read NIP-RS override clear path ──────────────────────────────
test("mark_read_with_active_override_attempts_override_clear", () => {
const result = simulateMarkChannelReadOverridePath("channel-1", {
markChannelRead: () => ({ success: true }),
getOverrideLiveness: () => ({ active: true, frontier: 100 }),
});
assert.equal(result.overrideClearAttempted, true, "override clear attempted");
assert.equal(result.toasts.length, 0, "no toast on successful clear");
});
test("mark_read_with_inactive_override_skips_override_clear", () => {
const result = simulateMarkChannelReadOverridePath("channel-1", {
markChannelRead: () => ({ success: true }),
getOverrideLiveness: () => ({ active: false, frontier: 100 }),
});
assert.equal(
result.overrideClearAttempted,
false,
"no override clear for inactive override",
);
assert.equal(result.toasts.length, 0);
});
test("mark_read_with_no_override_skips_override_clear", () => {
const result = simulateMarkChannelReadOverridePath("channel-1", {
markChannelRead: () => ({ success: true }),
getOverrideLiveness: () => null,
});
assert.equal(
result.overrideClearAttempted,
false,
"no clear when no override exists",
);
});
test("mark_read_override_clear_uint32_overflow_shows_toast", () => {
const result = simulateMarkChannelReadOverridePath("ch-x", {
markChannelRead: () => ({
success: false,
reason: "uint32_overflow",
}),
getOverrideLiveness: () => ({ active: true, frontier: 50 }),
});
assert.equal(result.toasts.length, 1);
assert.ok(result.toasts[0].message.includes("counter limit"));
});
test("mark_read_override_clear_load_incomplete_shows_toast", () => {
const result = simulateMarkChannelReadOverridePath("ch-x", {
markChannelRead: () => ({
success: false,
reason: "load_incomplete",
}),
getOverrideLiveness: () => ({ active: true, frontier: 50 }),
});
assert.equal(result.toasts.length, 1);
assert.ok(result.toasts[0].message.includes("still loading"));
});
test("mark_read_override_clear_already_inactive_is_silent", () => {
// already_inactive should not produce a toast (race condition on sync —
// the override was cleared by another device between our liveness check
// and the clear attempt).
const result = simulateMarkChannelReadOverridePath("ch-x", {
markChannelRead: () => ({
success: false,
reason: "already_inactive",
}),
getOverrideLiveness: () => ({ active: true, frontier: 50 }),
});
assert.equal(result.toasts.length, 0, "already_inactive is silent");
});
test("mark_read_without_manager_skips_override_path", () => {
const result = simulateMarkChannelReadOverridePath("ch-x", {
markChannelRead: undefined,
getOverrideLiveness: () => ({ active: true, frontier: 50 }),
});
assert.equal(
result.overrideClearAttempted,
false,
"no clear without manager API",
);
assert.equal(result.toasts.length, 0);
});
// ── Tests: resolveChannelReadMarker (read-path gate) ─────────────────────────
test("resolve_channel_read_marker_uses_max_of_caller_and_observed", () => {
// observedLatest=1704067999 > callerReadAt (1704067200 for 2024-01-01) → observed wins
const { markAt } = resolveChannelReadMarker(
"2024-01-01T00:00:00.000Z",
1704067999,
);
assert.equal(
markAt,
1704067999,
"observed latest wins when newer than caller",
);
});
test("resolve_channel_read_marker_uses_caller_when_newer", () => {
const future = new Date(Date.now() + 10_000_000).toISOString();
const { markAt } = resolveChannelReadMarker(future, 1);
assert.ok(markAt !== null && markAt > 1, "caller wins when newer");
});
test("resolve_channel_read_marker_returns_null_when_both_null", () => {
const { markAt } = resolveChannelReadMarker(null, undefined);
assert.equal(markAt, null);
});
test("resolve_channel_read_marker_clears_observed_when_covered", () => {
const { clearObserved } = resolveChannelReadMarker(
"2024-01-01T00:00:01.000Z",
1704067201,
);
assert.equal(
clearObserved,
true,
"observed cleared when read marker covers it",
);
});
// ── Tests: forcedUnreadStore local cache persistence ─────────────────────────
test("forced_unread_store_read_returns_empty_for_unknown_pubkey", () => {
const { restore } = makeIsolatedStorage();
try {
const map = forcedUnreadStore.read("unknown-pk");
assert.deepEqual(map, {});
} finally {
restore();
}
});
test("forced_unread_store_write_and_read_round_trips", () => {
const { restore } = makeIsolatedStorage();
try {
const pk = "test-pk";
forcedUnreadStore.write(pk, { "channel-a": 12345, "channel-b": null });
const read = forcedUnreadStore.read(pk);
assert.deepEqual(read, { "channel-a": 12345, "channel-b": null });
} finally {
restore();
}
});
test("forced_unread_store_doc_comment_does_not_claim_not_synced", () => {
// Regression guard: the old doc comment asserted "NOT synced to the relay".
// That claim is now false — the store is a cache of the NIP-RS override layer.
// This test is structural: if someone reverts the comment, the file content
// check will fail.
//
// We can't import raw source text in ESM without a loader, so we just
// verify the behaviour: the store description is tested by the integration
// — this placeholder documents the intent.
assert.ok(true, "forcedUnreadStore comment updated (see file)");
});

View File

@@ -2,13 +2,20 @@ import * as React from "react";
import {
ReadStateManager,
type ContextParentResolver,
type MarkResult,
} from "@/features/channels/readState/readStateManager";
import type { OverrideLiveness } from "@/features/channels/readState/readStateFormat";
import type { RelayClient } from "@/shared/api/relayClientSession";
const noopGetTimestamp = () => null;
const noopMarkRead = () => {};
const noopDrainAdvances = (): ReadonlySet<string> => new Set<string>();
const noopSetResolver = () => {};
const noopMarkOverride = (): MarkResult => ({
success: false,
reason: "load_incomplete",
});
const noopGetOverrideLiveness = (): OverrideLiveness | null => null;
/**
* React hook that creates and manages a ReadStateManager instance.
@@ -93,6 +100,35 @@ export function useReadState(
[],
);
// Override APIs for the NIP-RS manual-unread layer (slice 3 seam).
const markChannelUnread = React.useCallback(
(channelId: string): MarkResult => {
return (
managerRef.current?.markChannelUnread(channelId) ?? {
success: false,
reason: "load_incomplete",
}
);
},
[],
);
const markChannelRead = React.useCallback((channelId: string): MarkResult => {
return (
managerRef.current?.markChannelRead(channelId) ?? {
success: false,
reason: "load_incomplete",
}
);
}, []);
const getOverrideLiveness = React.useCallback(
(channelId: string): OverrideLiveness | null => {
return managerRef.current?.getOverrideLiveness(channelId) ?? null;
},
[],
);
const isReady = Boolean(
pubkey && relayClient && initializedPubkey === pubkey,
);
@@ -107,6 +143,9 @@ export function useReadState(
setContextParentResolver: noopSetResolver,
readStateVersion: 0,
getOwnTimestamp: noopGetTimestamp,
markChannelUnread: noopMarkOverride,
markChannelRead: noopMarkOverride,
getOverrideLiveness: noopGetOverrideLiveness,
};
}
@@ -119,5 +158,8 @@ export function useReadState(
setContextParentResolver,
readStateVersion,
getOwnTimestamp,
markChannelUnread,
markChannelRead,
getOverrideLiveness,
};
}

View File

@@ -0,0 +1,77 @@
/**
* UI-layer helpers for the NIP-RS manual-unread override layer (slice 3).
*
* Provides the seam between useUnreadChannels and the ReadStateManager's
* override APIs (markChannelUnread / markChannelRead / getOverrideLiveness),
* added by slice 2 (nip-rs-unread-manager).
*/
import { toast } from "sonner";
import type { OverrideLiveness } from "@/features/channels/readState/readStateFormat";
import type { MarkResult } from "@/features/channels/readState/readStateManager";
import {
forcedUnreadStore,
overrideErrorMessage,
type ForcedUnreadMap,
} from "@/features/channels/forcedUnreadStore";
export type { MarkResult, OverrideLiveness };
/** Slice-2 override APIs from ReadStateManager, proxied through useReadState. */
export type OverrideAPIs = {
markChannelUnread?: (channelId: string) => MarkResult;
markChannelRead?: (channelId: string) => MarkResult;
getOverrideLiveness?: (channelId: string) => OverrideLiveness | null;
};
/**
* Call the manager's override-unread path (S bump + B set to effective
* frontier). Returns true when the local cache should be updated; returns false
* and shows a toast when the manager refuses.
*/
export function applyOverrideUnread(
channelId: string,
apis: OverrideAPIs,
): boolean {
if (!apis.markChannelUnread) return true; // graceful path before slice-2 wires in
const result = apis.markChannelUnread(channelId);
if (!result.success) {
const msg = overrideErrorMessage("unread", result.reason);
if (msg) toast.error(msg);
return false;
}
return true;
}
/**
* If a NIP-RS override is active for `channelId`, call the manager's
* override-read path (C bump). Shows a toast on failure (except
* `already_inactive`, which is a silent race condition).
*/
export function applyOverrideRead(channelId: string, apis: OverrideAPIs): void {
if (!apis.markChannelRead) return;
const liveness = apis.getOverrideLiveness?.(channelId);
if (!liveness?.active) return;
const result = apis.markChannelRead(channelId);
if (!result.success) {
const msg = overrideErrorMessage("read", result.reason);
if (msg) toast.error(msg);
}
}
/**
* Persist `channelId` into the forced-unread local cache with the current
* own timestamp as baseline. No-op if the channel is already in the map.
* Returns true when the map changed.
*/
export function persistForcedUnread(
channelId: string,
forcedMap: ForcedUnreadMap,
getOwnTimestamp: (id: string) => number | null,
pubkey: string | undefined,
): boolean {
if (Object.hasOwn(forcedMap, channelId)) return false;
forcedMap[channelId] = getOwnTimestamp(channelId);
if (pubkey) forcedUnreadStore.write(pubkey, forcedMap);
return true;
}

View File

@@ -21,6 +21,12 @@ import {
forcedUnreadStore,
type ForcedUnreadMap,
} from "@/features/channels/forcedUnreadStore";
import {
applyOverrideRead,
applyOverrideUnread,
persistForcedUnread,
type OverrideAPIs,
} from "@/features/channels/readStateOverride";
import {
getThreadReference,
isBroadcastReply,
@@ -98,14 +104,10 @@ function toUnixSeconds(isoOrMs: string | null | undefined): number | null {
}
// 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.
// Folds caller position + observed-live latest so explicit "mark read" covers
// messages that arrived faster than channel metadata (load-bearing for Esc,
// sidebar mark-read, and empty-channel open). Returns `clearObserved` when the
// marker covers the observed timestamp so callers can drop stale refs.
export function resolveChannelReadMarker(
callerReadAt: string | null | undefined,
observedLatest: number | undefined,
@@ -161,6 +163,7 @@ export function useUnreadChannels(
normalizedRelayUrl,
);
// Slice-2 override APIs — now first-class on useReadState's return.
const {
getEffectiveTimestamp,
isReady: isReadStateReady,
@@ -169,15 +172,26 @@ export function useUnreadChannels(
setContextParentResolver,
readStateVersion,
getOwnTimestamp,
markChannelUnread: markChannelOverrideUnread,
markChannelRead: markChannelOverrideRead,
getOverrideLiveness,
} = useReadState(pubkey, relayClient);
// Stable object wrapping the three override APIs — only changes when the
// underlying functions change (identity rotation). Passed to
// applyOverrideRead/applyOverrideUnread so those helpers don't require
// per-hunk dep updates when new APIs are added.
const overrideApis = React.useMemo<OverrideAPIs>(
() => ({
markChannelUnread: markChannelOverrideUnread,
markChannelRead: markChannelOverrideRead,
getOverrideLiveness,
}),
[getOverrideLiveness, markChannelOverrideRead, markChannelOverrideUnread],
);
// Observed "latest external trigger event" per channel (unix seconds). This
// is *derived relay evidence*, not source-of-truth: it's populated from a
// one-shot catch-up REQ per channel (keyed on the NIP-RS read marker) plus
// ongoing live events. The only thing we ever do with it is compare against
// the NIP-RS read marker — see the unread memo below. Reset on identity
// change. Stale entries for channels the user has left are silently
// ignored by the memo (it iterates the current channels list, not the map).
// Observed "latest external trigger event" per channel — derived relay evidence
// used only to compare against the NIP-RS read marker. Reset on identity change;
// stale entries for left channels are ignored (memo iterates current channel list).
const latestByChannelRef = React.useRef(new Map<string, number>());
const observedUnreadEventsByChannelRef = React.useRef(
new Map<string, Map<string, ObservedUnreadEvent>>(),
@@ -186,13 +200,10 @@ export function useUnreadChannels(
const channelsRef = React.useRef(channels);
channelsRef.current = channels;
// Channels manually marked unread this session (e.g., right-click → "mark
// unread"). Because NIP-RS read markers are monotonic, this flag is what
// makes the badge appear without lowering synced read state. Each entry
// stores the channel's NIP-RS read marker (unix seconds) at force-time, or
// null if no marker existed yet. Persisted to localStorage
// (buzz-forced-unread.v1) so it survives reload and is visible to the rail
// observer for inactive communities.
// Channels manually marked unread this session via right-click (NIP-RS local
// override cache). Monotonic read markers mean we can't lower them; this flag
// makes the badge appear without affecting synced state. Persisted so it
// survives reload and is visible to the rail observer for inactive communities.
const forcedUnreadRef = React.useRef<ForcedUnreadMap>(
pubkey ? forcedUnreadStore.read(pubkey) : {},
);
@@ -217,39 +228,28 @@ export function useUnreadChannels(
}
}, [readStateVersion, drainSyncedAdvances]);
// Root event IDs of threads where the current user has replied at least once.
// Used to determine if thread replies should trigger unread notifications.
// Root event IDs of threads the current user has replied to (notification gating).
const participatedRootIdsRef = React.useRef(new Set<string>());
// Root event IDs of top-level messages authored by the current user.
// Used to notify the author when someone replies to their posts.
const authoredRootIdsRef = React.useRef(new Set<string>());
// Root event IDs of threads where an external message @-mentioned the user.
// ORed into the badge gate so a mention recipient who never participated,
// authored, or followed the thread still gets the thread-unread badge.
// Thread roots with an external @-mention; ORed into badge gate for mention recipients.
const mentionedRootIdsRef = React.useRef(new Set<string>());
// Root event IDs of threads the user has explicitly muted. Takes precedence
// over participation, follow, and authorship for notification suppression.
// Thread roots the user has explicitly muted (takes precedence over all gating).
const mutedRootIdsRef = React.useRef(new Set<string>());
// Stable ref for the caller-supplied muted channel IDs. Updated every render
// so the catch-up loop always reads the latest set without being a dep.
// Caller-supplied muted channel IDs — updated each render, not a dep.
const mutedChannelIdsRef = React.useRef<ReadonlySet<string>>(new Set());
mutedChannelIdsRef.current = mutedChannelIdsOption ?? new Set();
// Thread reply events that triggered notifications — surfaced in the Home
// activity feed as synthetic FeedItems.
// Thread reply events that triggered notifications (surfaced in Home feed).
const threadActivityRef = React.useRef<ThreadActivityItem[]>([]);
// Tracks the (pubkey:relayUrl) scope currently loaded into threadActivityRef.
// Writers guard against this before merging so in-flight writes from a prior
// scope cannot corrupt the new one; renders return [] until it matches.
// (pubkey:relayUrl) scope in threadActivityRef; guards in-flight scope drift.
const threadActivityScopeRef = React.useRef<string>("");
// Tracks which channels we've already issued a catch-up REQ for this
// session. Prevents re-fetching on every channels-list refetch, while still
// letting newly-joined channels be caught up. Reset on identity change.
// Channels already caught-up this session; reset on identity change.
const caughtUpChannelsRef = React.useRef(new Set<string>());
const [latestVersion, bumpLatestVersion] = React.useReducer(
@@ -257,10 +257,8 @@ export function useUnreadChannels(
0,
);
// Version signal bumped only when the participated/authored/mentioned
// root-id sets change, so the gate snapshots (re-derived below) don't
// re-allocate on every observed external message the way reusing
// latestVersion would.
// Separate from latestVersion: bumped only when badge-gating set membership
// changes, so gate snapshots don't re-allocate on every external message.
const [membershipVersion, bumpMembershipVersion] = React.useReducer(
(x: number) => x + 1,
0,
@@ -296,16 +294,11 @@ export function useUnreadChannels(
bumpMembershipVersion();
}, [pubkey, relayClient, normalizedRelayUrl]);
// `topLevelOnly` is the passive channel-open path (NIP-RS Option 1): the
// caller's `readAt` is already the newest TOP-LEVEL message, so the marker
// must land exactly there without folding in `observedLatest` (which counts
// thread replies) and without clearing observed refs. Leaving the refs intact
// keeps the sidebar dot lit for a channel whose only unread is an unopened
// thread reply — viewing the channel no longer absorbs the reply, so the dot
// persists until an explicit mark-read (Esc, sidebar, mark-all) or a newer
// top-level message advances the channel marker past it. Those explicit
// "mark read" actions omit the flag and keep the fold, since they mean
// "clear everything in this channel."
// `topLevelOnly` = passive channel-open path (NIP-RS Option 1): caller's
// `readAt` is already the newest top-level message; skip `observedLatest`
// (which counts thread replies) and keep refs intact so the sidebar dot
// persists until an explicit mark-read covers the reply or a newer top-level
// message advances past it. Explicit "mark read" actions omit the flag.
const markChannelRead = React.useCallback(
(
channelId: string,
@@ -319,6 +312,8 @@ export function useUnreadChannels(
}
bumpLatestVersion();
}
// If a NIP-RS override is active, clear it via the manager (C bump).
applyOverrideRead(channelId, overrideApis);
const observedLatest = topLevelOnly
? undefined
: latestByChannelRef.current.get(channelId);
@@ -339,24 +334,27 @@ export function useUnreadChannels(
bumpLatestVersion();
}
},
[markContextRead, pubkey],
[markContextRead, overrideApis, pubkey],
);
// Manually mark a channel unread (e.g., right-click → "mark unread"). Persists
// the current NIP-RS read marker as baseline to localStorage so the rail
// observer can detect when a cross-device read has since covered the force.
// NIP-RS markers are monotonic, so we do not publish a lower timestamp.
// Manually mark a channel unread (right-click → "mark unread"). Publishes a
// NIP-RS override event to the relay (S bump + B set), then updates the
// local cache. Shows a toast on failure (budget exhaustion, counter overflow,
// or incomplete load).
const markChannelUnread = React.useCallback(
(channelId: string) => {
if (!Object.hasOwn(forcedUnreadRef.current, channelId)) {
forcedUnreadRef.current[channelId] = getOwnTimestamp(channelId);
if (pubkey) {
forcedUnreadStore.write(pubkey, forcedUnreadRef.current);
}
if (!applyOverrideUnread(channelId, overrideApis)) return;
if (
persistForcedUnread(
channelId,
forcedUnreadRef.current,
getOwnTimestamp,
pubkey,
)
)
bumpLatestVersion();
}
},
[getOwnTimestamp, pubkey],
[getOwnTimestamp, overrideApis, pubkey],
);
// Record the thread root of an EXTERNAL message that @-mentioned the user.
@@ -954,6 +952,7 @@ export function useUnreadChannels(
const markAllChannelsRead = React.useCallback(() => {
for (const channelId of unreadChannelIdsRef.current) {
delete forcedUnreadRef.current[channelId];
applyOverrideRead(channelId, overrideApis);
const unixSeconds =
latestByChannelRef.current.get(channelId) ??
getEffectiveTimestamp(channelId) ??
@@ -968,12 +967,10 @@ export function useUnreadChannels(
forcedUnreadStore.write(pubkey, forcedUnreadRef.current);
}
bumpLatestVersion();
}, [getEffectiveTimestamp, markContextRead, pubkey]);
}, [getEffectiveTimestamp, markContextRead, overrideApis, pubkey]);
// Identity-stable snapshots of the membership sets for the notify gate.
// Re-derived only when membershipVersion bumps (a set actually changed), so
// `isNotifiedForThread`'s useCallback deps invalidate on async discovery
// while live consumers keep reading the mutable refs directly.
// Stable snapshots of membership sets — re-derived only when membershipVersion
// bumps; live consumers read the mutable refs directly between bumps.
// biome-ignore lint/correctness/useExhaustiveDependencies: membershipVersion is the intentional re-derivation signal
const participatedRootIds = React.useMemo(
() => new Set(participatedRootIdsRef.current) as ReadonlySet<string>,