mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
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:
parent
94aa00e4d7
commit
8b46a8d496
@@ -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}`;
|
||||
|
||||
|
||||
431
desktop/src/features/channels/nip-rs-unread-ui.test.mjs
Normal file
431
desktop/src/features/channels/nip-rs-unread-ui.test.mjs
Normal 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)");
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
77
desktop/src/features/channels/readStateOverride.ts
Normal file
77
desktop/src/features/channels/readStateOverride.ts
Normal 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;
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user