Alert community owners and admins when a new key joins (#4900)

Owners and admins of a Buzz community get a desktop notification the
first time a new key joins their community. Requested by Tyler in
buzz-development ("we already have this [roster] — can we alert owners
and admins when a new key joins for the first time?"); design and
verification thread: channel `community-members-visibility`.

## Why the shape is what it is

- **kind:13534 membership snapshot is the alerting signal, not the
kind:8000 delta.** 8000 is leaky on two independent axes: its fan-out is
pod-local (no Redis hop — being fixed separately in #4887), and
`buzz-admin add-member` publishes no 8000 at all by documented design.
The 13534 snapshot is the only signal covering every production join
path with cross-pod delivery (completeness audit: every
membership-insertion path enumerated at base `8342dfcc5`, all emit
13534).
- **This adds Desktop's first live 13534 subscription** — deliberate
line item. The existing read (`relayMembers.ts`) is a one-shot fetch;
without a live subscription no snapshot ever arrives passively and
nothing could fire.
- **8000 is subscribed only as a latency accelerator** and it *refetches
the authoritative snapshot* rather than alerting from its own payload,
so one ledger governs both signals and they cannot double-alert.
- **Persisted per-community/per-viewer ledger, written before the
notification fires.** Snapshot publication is eventual (60s reconciler
repairs failed best-effort publishes) and a reconciler-republished
snapshot is indistinguishable from a fresh one — only a durable record
answers "is this new". Also what makes reconnect replay (`since - 5s`
skew; `since === undefined` full-backlog edge) safe.
- **First snapshot per community seeds silently** (no notification storm
for existing members), and `seeded` is an explicit persisted bit — not
inferred from ledger non-emptiness, which would swallow the first
genuine join in a community whose only member is the viewer.
- Mounted in `useAppShellDesktopNotifications` (owns the
notifications-enabled precondition; `AppShell.tsx` is at the file-size
ratchet ceiling — net growth zero).

5 files, +3103 (production +752, tests +2,351), desktop-only. No relay
changes.

## Verification

**Current reviewed tip: `1854c4a5` — review-blessed code at
`d992ed295ead8c8423f81a752f4ad614718d85c6`** (clean tree, HEAD checked
in the same shell as each gate; history is `0e791f2d3` → merge of main
`2034e693a` → `fdeda44f0` → `5d0d2b4c3` → `a20a7d8cb` → merge of main
`0cfe4832` → `d992ed29` → `1854c4a5`, all fast-forward, no rebase or
force). Independently gated by Eva, Wren, and Sami: typecheck rc=0,
`pnpm check` rc=0 (pre-existing 1 warning / 2 infos), full Desktop unit
package **4431/4431**; push hooks pass. Wren's adversarial verdict at
`d992ed29`: APPROVE — minimalness 9, elegance 9, correctness 9, all four
cancellation seams plus 1b re-derived independently. `1854c4a5` is
assertions and comments only — no production behaviour change, so the
test count is unchanged.

**Remediation commits (review thread `community-members-visibility`):**
- `fdeda44f0` — authorization read from the signed snapshot being
reconciled (a demoting/removing snapshot fails closed before it can
disclose the joins it carries); >3 joins collapse to one summary;
8000-triggered refetches coalesce on a 500ms trailing window.
- `5d0d2b4c3` — join alerts coalesce **across** snapshots, not just
within one: a live burst arrives as several growing rosters, so delivery
defers onto a 1.5s trailing quiet window while ledger persistence and
dedupe stay synchronous per snapshot. Max measured 10 banners from 50
real joins before this; the same shape now produces one.
- `a20a7d8cb` — cancellation covers flushes already in flight, not just
queued timers: a generation token (bumped only by `clearPending`) is
rechecked after the profile lookup and before every send, so
demotion/removal/unmount/community-switch landing mid-flush suppresses
delivery; the notification title is captured with the batch rather than
read at send time. Concurrent-flush semantics pinned: a newer authorized
batch neither cancels nor is cancelled by an in-flight flush.
- `d992ed29` — the stale-authorized-frame disclosure, independently
reproduced at `0cfe4832` (held-open refetch released after a newer
demoting frame: `notifications=1`, body naming the joiner, where 0 is
required). Three fixes in one shape: every callback acts on a
per-effect-run session object (community id, viewer, ledger, ordering
state) instead of ambient current values, closing the community-switch
window; a `created_at` fence plus a fail-closed revocation latch, as one
mechanism, because the relay can publish two snapshots in the same
second so neither `<` nor `<=` alone is safe — the invariant is
“revocation wins”, not “newest wins”; and a 5s clamp on the 1.5s
trailing window so a sustained drip cannot defer delivery without bound.
Red-first: the four new arms fail at `0cfe4832` (25/29) and pass after
(29/29).
- `1854c4a5` — the privacy arm now asserts the persisted ledger is
unchanged across the delayed frame's release, not only the notification
count. Mutation-checked: moving the revoked check after the ledger
advance keeps notifications at 0 and passes the old assertion, and is
killed by the new one. Assertions and comments only.

**Mutation testing:** 9/9 mounted-hook mutants killed at `a20a7d8cb`,
each with a control row before and after — role/enabled gates,
reconnect, 8000 authority, failed-write handling and ref ordering,
community re-key/read, and query invalidation. The reducer/storage fix
separately killed 6/6 mutants with 15/0 controls; the foundational
ledger suite killed 9/9. At `d992ed29`: spelling the fence `<=` kills 5
arms; moving the empty-roster guard after the fence advance kills
exactly the fence-advance arm and nothing else (28/29). One
qualification stated rather than buried — moving the fence advance
itself up to the comparison SURVIVES the whole suite. That is an
equivalent mutant, not a coverage gap: the empty-roster guard returns
before the comparison, and authorization rejection latches `revoked` so
a later frame having moved the fence is unobservable. The scope is
written into the test's docstring. At `1854c4a5`: the
revoked-check-after-ledger-advance mutant is killed by the new ledger
assertion (and by the 1b arm).

**Scale/storage correction in `f6e5a3c57`:** the original 5,000-key cap
could evict members still present in a 5,001+ roster, causing them to
re-alert on every snapshot; read-time truncation reopened the same loop
after reload; and a raw quota exception could reject before notification
dispatch. The fix retains every on-roster key, caps only departed keys,
removes read-time truncation, and uses the app's quota-aware writer.
**Final ordering correction in `0e791f2d3`:** a failed post-recovery
write now skips notification and leaves the in-memory ledger unchanged,
so the next snapshot retries and delivers only after persistence
succeeds.

**Live-local matrix vs a real relay, executed at exact unchanged
`d75cc6cd9` and transferred to the current tip:** a 4,800-sequence
differential found zero old/new reducer divergences below the cap while
exercising the positive alert path; its negative control diverged as
required at 5,100 members (old re-alerts 100; new re-alerts 0). The
final hook change affects only the newly tested failed-write branch;
successful writes follow the same alert path exercised live. The live
communities were sub-cap and persisted successfully, so the matrix
remains applicable without a redundant rerun.
- Invite claim: owner and admin each exactly one notification; plain
member zero; 1.5s quiet window held (8000+13534 deduped); both open
clients live-refreshed the roster. Screenshot receipts SHA-256-pinned
and independently replicated.
- **CLI `buzz-admin add-member` (13534-only path):** DB counts moved
8000 `9→9`, 13534 `15→16` — zero accelerator events, exactly one alert
per manager. Proves snapshot-diff alone alerts.
- Plain member: zero notifications **and** zero
`buzz-community-join-seen.v1:*` localStorage keys before/after the join
(gate sits before the ledger).
- Staggered reload + replay dedupe: no alerts from startup
refetch/replay; republished already-seen snapshot produced zero through
a 2s quiet window.
- Community switch: independent per-community seed state; effect
re-keys; one alert per community, quiet window held at exactly two.

**Live re-verification at `d992ed29` is in progress** (Max; the
after-fix matrix leads with the delayed-refetch demotion arm, A→B switch
ledger isolation, the 5s sustained-drip timing, and packaged-app click
routing behind the positive/NIP-43 controls); earlier receipts at
`a20a7d8cb` cover the instrumented storm and cap-boundary re-drive;
earlier live receipts at `fdeda44f0` — privacy matrix
(demote/remove/promote), summary click-through — transfer where the diff
left those paths untouched.

## Known and accepted

- **8000 cross-pod fan-out is broken relay-side** — fixed in #4887
(separate lane, not a blocker here): on a multi-pod relay the
accelerator only fires on the claim-handling pod; 13534 still covers
everyone, just not instantly.
- **Late-not-lost semantics.** A live frame missed during a
reload/socket gap is recovered by the next snapshot, reconnect refetch,
or remount backfill (`limit: 1`) diffed against the persisted ledger.
One live-run observation of an admin missing an immediate post-reload
fresh join is attributed to harness rate limiting; the recovery paths
above bound the damage to lateness, never duplicates.
- **Remote promotion activates on reload, not on the next snapshot**
(measured by Sami at `fdeda44f0`): the subscriptions are mounted from
the cached membership lookup, so a viewer promoted to admin by someone
else starts receiving join alerts only after a reload, community switch,
or local membership mutation refreshes that cache. Fails safe
(under-notify). Ruled accepted for v1 by Eva; the fix direction
(subscribing before authorization) is a deliberate design change
deferred to a follow-up if product wants instant activation.
- **Cross-user live-delivery staleness reproduced at the PR's own base**
(`2034e693a`, clean relay): a persisted send can fail to appear in an
already-open recipient timeline. Detached from this PR by a pinned-base
discriminator (identical failure with zero PR code) and tracked
separately in issue `6e2bda3092fa`; current main passes 4/4.
- **A stale demoting frame latches a genuine admin until reload or
community switch** (reverse ordering of the stale-frame privacy race,
`d992ed29`): if a snapshot that does not list the viewer as a manager
arrives out of order, the fail-closed revocation latch trips even though
the viewer is still an admin. The invalidation the latch fires refetches
the membership lookup, which correctly returns admin, so `active` stays
true, the effect deps do not change, and the session stays latched.
Fails safe (under-notify, never over-disclose) and consistent with the
promotion-on-reload semantics above. Ruled accepted for v1 by Eva;
self-clearing the latch would cost a third piece of timing state. Pinned
as documented behaviour in `useCommunityJoinAlerts.test.mjs` — and the
suppressed join is re-announced rather than lost, because a latched
session never records it in the ledger.
- **Lifetime-first-only semantics:** ever-seen ledger means
remove→re-add does not re-alert. Flagged for product ruling; one-line
change if re-adds should ping.

---------

Signed-off-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
This commit is contained in:
Tyler
2026-08-06 17:28:25 -04:00
committed by GitHub
co-authored by Sami Eva
parent b08c8b126c
commit 1399ec1d13
5 changed files with 3274 additions and 0 deletions
@@ -5,6 +5,7 @@ import {
toSearchHit,
} from "@/app/AppShell.helpers";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts";
import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify";
import type { NotificationSettings } from "@/features/notifications/hooks";
import {
@@ -45,6 +46,13 @@ export function useAppShellDesktopNotifications({
pubkey?: string;
silentChannelIds?: ReadonlySet<string>;
}) {
// Roster alerts are owner/admin-only and self-gating; mounted here because
// it shares this hook's "desktop notifications are on" precondition and
// AppShell sits at the file-size ratchet ceiling.
useCommunityJoinAlerts({
enabled: enabled && notificationSettings.desktopEnabled,
});
const handleChannelNotification = React.useEffectEvent(
(_channelId: string, event: RelayEvent) => {
if (!enabled) return;
@@ -0,0 +1,265 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
EMPTY_JOIN_ALERT_LEDGER,
JOIN_ALERT_DEPARTED_MAX_ITEMS,
joinAlertBody,
joinAlertTitle,
readJoinAlertLedger,
reconcileJoinAlertLedger,
writeJoinAlertLedger,
} from "./joinAlerts.ts";
const COMMUNITY = "community-1";
const OWNER = "a".repeat(64);
const ALICE = "b".repeat(64);
const BOB = "c".repeat(64);
function installLocalStorage({ throwOnSet = false } = {}) {
const values = new Map();
globalThis.window = {
localStorage: {
get length() {
return values.size;
},
key: (index) => [...values.keys()][index] ?? null,
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => {
if (throwOnSet) {
const error = new Error("quota exceeded");
error.name = "QuotaExceededError";
throw error;
}
values.set(key, value);
},
removeItem: (key) => values.delete(key),
},
};
return values;
}
/** Fold a roster in and persist, the way the hook does. */
function applySnapshot(ledger, rosterPubkeys) {
const result = reconcileJoinAlertLedger({
ledger,
rosterPubkeys,
viewerPubkey: OWNER,
});
if (result.changed) {
writeJoinAlertLedger(COMMUNITY, OWNER, result.ledger);
}
return result;
}
test("first snapshot seeds an existing roster without alerting", () => {
installLocalStorage();
const result = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER, ALICE, BOB]);
assert.deepEqual(result.alerts, []);
assert.equal(result.ledger.seeded, true);
assert.deepEqual(result.ledger.pubkeys, [ALICE, BOB]);
});
test("a key joining after the seed alerts exactly once", () => {
installLocalStorage();
const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER, ALICE]).ledger;
const joined = applySnapshot(seeded, [OWNER, ALICE, BOB]);
assert.deepEqual(joined.alerts, [BOB]);
// A redelivered identical snapshot must not re-alert or rewrite.
const redelivered = applySnapshot(joined.ledger, [OWNER, ALICE, BOB]);
assert.deepEqual(redelivered.alerts, []);
assert.equal(redelivered.changed, false);
});
test("a community seeded with only the viewer still alerts on the first join", () => {
// Regression: inferring "seeded" from a non-empty ledger classified this
// first genuine join as the seeding run and dropped the alert silently.
installLocalStorage();
const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]);
assert.deepEqual(seeded.alerts, []);
assert.deepEqual(seeded.ledger.pubkeys, []);
assert.equal(seeded.ledger.seeded, true);
const joined = applySnapshot(seeded.ledger, [OWNER, ALICE]);
assert.deepEqual(joined.alerts, [ALICE]);
});
test("the seeded flag survives a reload through storage", () => {
installLocalStorage();
applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]);
const reloaded = readJoinAlertLedger(COMMUNITY, OWNER);
assert.equal(reloaded.seeded, true);
assert.deepEqual(reloaded.pubkeys, []);
assert.deepEqual(applySnapshot(reloaded, [OWNER, ALICE]).alerts, [ALICE]);
});
test("remove then re-add does not alert a second time", () => {
installLocalStorage();
const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]).ledger;
assert.deepEqual(applySnapshot(seeded, [OWNER, ALICE]).alerts, [ALICE]);
const afterRemoval = applySnapshot(readJoinAlertLedger(COMMUNITY, OWNER), [
OWNER,
]);
assert.deepEqual(afterRemoval.alerts, []);
const afterReAdd = applySnapshot(readJoinAlertLedger(COMMUNITY, OWNER), [
OWNER,
ALICE,
]);
assert.deepEqual(afterReAdd.alerts, []);
});
test("the kind:8000 accelerator and the live snapshot yield one alert", () => {
installLocalStorage();
const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]).ledger;
// Delta arrives first and triggers a snapshot refetch...
const viaDelta = applySnapshot(seeded, [OWNER, ALICE]);
assert.deepEqual(viaDelta.alerts, [ALICE]);
// ...then the live 13534 for the same join lands.
const viaLive = applySnapshot(readJoinAlertLedger(COMMUNITY, OWNER), [
OWNER,
ALICE,
]);
assert.deepEqual(viaLive.alerts, []);
});
test("the viewer is never alerted on or recorded", () => {
installLocalStorage();
const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [ALICE]).ledger;
const result = applySnapshot(seeded, [ALICE, OWNER]);
assert.deepEqual(result.alerts, []);
assert.equal(result.changed, false);
assert.equal(result.ledger.pubkeys.includes(OWNER), false);
});
test("roster pubkeys are matched case-insensitively", () => {
installLocalStorage();
const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]).ledger;
const joined = applySnapshot(seeded, [OWNER, ALICE.toUpperCase()]);
assert.deepEqual(joined.alerts, [ALICE]);
assert.deepEqual(applySnapshot(joined.ledger, [OWNER, ALICE]).alerts, []);
});
test("a duplicated pubkey in one snapshot alerts once", () => {
installLocalStorage();
const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, [OWNER]).ledger;
const joined = applySnapshot(seeded, [OWNER, ALICE, ALICE]);
assert.deepEqual(joined.alerts, [ALICE]);
assert.deepEqual(joined.ledger.pubkeys, [ALICE]);
});
test("a ledger stored before the seeded flag existed is treated as seeded", () => {
const values = installLocalStorage();
const [key] = [...values.keys()];
writeJoinAlertLedger(COMMUNITY, OWNER, { seeded: true, pubkeys: [ALICE] });
const storageKey = key ?? [...values.keys()][0];
values.set(storageKey, JSON.stringify({ pubkeys: [ALICE] }));
const ledger = readJoinAlertLedger(COMMUNITY, OWNER);
assert.equal(ledger.seeded, true);
assert.deepEqual(applySnapshot(ledger, [OWNER, ALICE, BOB]).alerts, [BOB]);
});
test("unreadable storage reads as an unseeded ledger", () => {
const values = installLocalStorage();
writeJoinAlertLedger(COMMUNITY, OWNER, { seeded: true, pubkeys: [ALICE] });
values.set([...values.keys()][0], "{not json");
assert.deepEqual(readJoinAlertLedger(COMMUNITY, OWNER), {
seeded: false,
pubkeys: [],
});
});
test("a roster larger than the departed cap never re-alerts its own members", () => {
// Regression: capping *all* retained keys shed pubkeys that were still on the
// roster, so the next snapshot saw them as unknown and alerted again — every
// snapshot, forever, for any community past the cap.
installLocalStorage();
const roster = Array.from(
{ length: JOIN_ALERT_DEPARTED_MAX_ITEMS + 100 },
(_unused, index) => index.toString(16).padStart(64, "0"),
);
const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, roster);
assert.deepEqual(seeded.alerts, []);
assert.equal(seeded.ledger.pubkeys.length, roster.length);
for (let pass = 0; pass < 3; pass++) {
const repeat = applySnapshot(readJoinAlertLedger(COMMUNITY, OWNER), roster);
assert.deepEqual(repeat.alerts, []);
assert.equal(repeat.changed, false);
}
// The read path must not truncate either: a stored ledger above the cap has
// to come back whole or the same re-alert loop reopens on reload.
assert.equal(
readJoinAlertLedger(COMMUNITY, OWNER).pubkeys.length,
roster.length,
);
});
test("the cap sheds only departed pubkeys, oldest first", () => {
installLocalStorage();
const roster = Array.from(
{ length: JOIN_ALERT_DEPARTED_MAX_ITEMS + 10 },
(_unused, index) => index.toString(16).padStart(64, "0"),
);
const seeded = applySnapshot(EMPTY_JOIN_ALERT_LEDGER, roster).ledger;
// Everyone leaves except the newest member; one new key joins.
const survivor = roster.at(-1);
const shrunk = applySnapshot(seeded, [OWNER, survivor, BOB]);
assert.deepEqual(shrunk.alerts, [BOB]);
// 5010 retained - 9 departed over the cap, plus BOB.
assert.equal(shrunk.ledger.pubkeys.length, roster.length - 9 + 1);
assert.equal(shrunk.ledger.pubkeys.includes(roster[0]), false);
assert.equal(shrunk.ledger.pubkeys.includes(roster[8]), false);
assert.equal(shrunk.ledger.pubkeys.includes(roster[9]), true);
// The on-roster key is retained no matter where it sits in insertion order.
assert.equal(shrunk.ledger.pubkeys.includes(survivor), true);
});
test("a write that cannot land is reported, not thrown", () => {
// The writer runs inside an async snapshot handler: a raw QuotaExceededError
// would reject before the notification is sent, on every snapshot.
installLocalStorage({ throwOnSet: true });
assert.equal(
writeJoinAlertLedger(COMMUNITY, OWNER, { seeded: true, pubkeys: [ALICE] }),
false,
);
assert.deepEqual(readJoinAlertLedger(COMMUNITY, OWNER), {
seeded: false,
pubkeys: [],
});
});
test("notification copy names the community when known", () => {
assert.equal(joinAlertTitle("Buzz HQ"), "New member in Buzz HQ");
assert.equal(joinAlertTitle(" "), "New community member");
assert.equal(joinAlertTitle(null), "New community member");
assert.equal(joinAlertBody("Alice"), "Alice joined");
});
@@ -0,0 +1,227 @@
/**
* First-join alert bookkeeping for community owners/admins.
*
* # Why the roster snapshot is the source of truth, not the kind:8000 delta
*
* The relay emits a kind:8000 "member-added" delta on the invite-claim and
* relay-admin paths, but `buzz-admin add-member` deliberately emits none
* (`crates/buzz-admin/src/main.rs:6-13`), and kind:8000 fan-out is pod-local
* (`fan_out_event_to_local_subscribers` never calls `publish_event`, unlike
* `dispatch_persistent_event_inner`). The kind:13534 membership snapshot is the
* only signal that covers every join path *and* propagates across pods, so it
* is the correctness signal here; kind:8000 is a latency accelerator only.
*
* # Why a persisted ledger rather than snapshot-to-snapshot diffing
*
* Snapshot publication is eventual, not transactional: a failed post-commit
* publish is repaired by the relay's periodic reconciler, so the same member
* can first appear in a snapshot arriving up to a reconcile interval late, and
* a reconciler-published snapshot is indistinguishable from a fresh one. Only a
* ledger of pubkeys we have already alerted on can answer "is this new to the
* user", which is the question the notification actually asks. The ledger also
* absorbs kind:8000 redelivery on reconnect, where the replay filter re-sends
* events at or after `lastSeenCreatedAt - skew` and can repeat a seen delta.
*/
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
const JOIN_ALERT_STORAGE_PREFIX = "buzz-community-join-seen.v1";
/**
* Cap on *departed* pubkeys retained per community.
*
* A pubkey still on the roster can never be shed: the next snapshot presents it
* again, the ledger no longer recognizes it, and it is alerted as a fresh join
* — on every snapshot, forever. So the cap bounds only the tail of keys that
* have left, and the ledger's real ceiling is the roster the relay can deliver
* (a kind:13534 snapshot larger than `BUZZ_MAX_FRAME_BYTES` never arrives).
*/
export const JOIN_ALERT_DEPARTED_MAX_ITEMS = 5_000;
export type JoinAlertLedger = {
/**
* Whether a roster snapshot has already been folded in for this community.
*
* Tracked explicitly rather than inferred from `pubkeys.length > 0`, because
* the two are not the same proposition: a community whose only member is the
* viewer seeds to an *empty* pubkey list (the viewer is never recorded), and
* inferring from emptiness would then classify the first genuine join as the
* seeding run and silently swallow the very alert this feature exists for.
*/
seeded: boolean;
/** Pubkeys already alerted on, oldest first. */
pubkeys: string[];
};
export const EMPTY_JOIN_ALERT_LEDGER: JoinAlertLedger = {
seeded: false,
pubkeys: [],
};
export function joinAlertStorageKey(communityId: string, viewerPubkey: string) {
return `${JOIN_ALERT_STORAGE_PREFIX}:${communityId}:${viewerPubkey}`;
}
export function normalizeJoinPubkey(pubkey: string): string {
return pubkey.trim().toLowerCase();
}
export function readJoinAlertLedger(
communityId: string,
viewerPubkey: string,
): JoinAlertLedger {
if (
typeof window === "undefined" ||
communityId.length === 0 ||
viewerPubkey.length === 0
) {
return EMPTY_JOIN_ALERT_LEDGER;
}
const rawValue = window.localStorage.getItem(
joinAlertStorageKey(communityId, viewerPubkey),
);
if (!rawValue) {
return EMPTY_JOIN_ALERT_LEDGER;
}
try {
const parsed: unknown = JSON.parse(rawValue);
if (parsed === null || typeof parsed !== "object") {
return EMPTY_JOIN_ALERT_LEDGER;
}
const { pubkeys, seeded } = parsed as Partial<JoinAlertLedger>;
if (!Array.isArray(pubkeys)) {
return EMPTY_JOIN_ALERT_LEDGER;
}
return {
// A stored ledger is by definition the residue of a snapshot we already
// folded in, so unreadable/absent `seeded` reads as true. Defaulting the
// other way would re-seed and drop a real join.
seeded: seeded !== false,
pubkeys: pubkeys.filter(
(value): value is string => typeof value === "string",
),
};
} catch {
return EMPTY_JOIN_ALERT_LEDGER;
}
}
/**
* Persist the ledger. Returns false when the write did not land.
*
* Routed through the quota-aware writer rather than `localStorage.setItem`:
* this runs inside an async snapshot handler, where a raw QuotaExceededError
* would reject before the notification is ever sent, and it would do so on
* every subsequent snapshot too.
*/
export function writeJoinAlertLedger(
communityId: string,
viewerPubkey: string,
ledger: JoinAlertLedger,
): boolean {
if (
typeof window === "undefined" ||
communityId.length === 0 ||
viewerPubkey.length === 0
) {
return false;
}
return setLocalStorageItemWithRecovery(
joinAlertStorageKey(communityId, viewerPubkey),
JSON.stringify(ledger satisfies JoinAlertLedger),
);
}
/**
* Fold a roster snapshot into the ledger, returning the pubkeys to alert on.
*
* The viewer's own pubkey is never alerted on or recorded: an owner does not
* need to be told they joined their own community.
*
* `alerts` is empty on the seeding run — the first snapshot for a community
* records every existing member silently, so installing the app against an
* established roster does not produce a notification per member.
*/
export function reconcileJoinAlertLedger({
ledger,
rosterPubkeys,
viewerPubkey,
}: {
ledger: JoinAlertLedger;
rosterPubkeys: readonly string[];
viewerPubkey: string;
}): { alerts: string[]; changed: boolean; ledger: JoinAlertLedger } {
const normalizedViewer = normalizeJoinPubkey(viewerPubkey);
const seen = new Set(ledger.pubkeys);
const roster = new Set<string>();
const fresh: string[] = [];
for (const rawPubkey of rosterPubkeys) {
const pubkey = normalizeJoinPubkey(rawPubkey);
if (pubkey.length === 0) continue;
if (pubkey === normalizedViewer) continue;
roster.add(pubkey);
if (seen.has(pubkey)) continue;
seen.add(pubkey);
fresh.push(pubkey);
}
if (fresh.length === 0 && ledger.seeded) {
return { alerts: [], changed: false, ledger };
}
// Shed only pubkeys absent from the roster we were just handed. Capping the
// whole ledger instead would evict keys that are still members, and every
// later snapshot would then re-alert them — permanently, once the roster
// passes the cap.
const departed = ledger.pubkeys.filter((pubkey) => !roster.has(pubkey));
const shedCount = departed.length - JOIN_ALERT_DEPARTED_MAX_ITEMS;
const shed = shedCount > 0 ? new Set(departed.slice(0, shedCount)) : null;
const retained =
shed === null
? ledger.pubkeys
: ledger.pubkeys.filter((pubkey) => !shed.has(pubkey));
return {
alerts: ledger.seeded ? fresh : [],
changed: true,
ledger: {
seeded: true,
pubkeys: [...retained, ...fresh],
},
};
}
/** Notification copy for a single first join. */
export function joinAlertTitle(communityName: string | null | undefined) {
const trimmed = communityName?.trim();
return trimmed && trimmed.length > 0
? `New member in ${trimmed}`
: "New community member";
}
export function joinAlertBody(displayName: string) {
return `${displayName} joined`;
}
/**
* Most per-key notifications emitted for a single snapshot.
*
* Above this, one summary replaces the batch. A snapshot is a whole roster, not
* an event per join, so a bulk import or an invite link shared into a group
* chat lands every new key at once: without a cap that is one OS notification
* per member (measured: a 250-key snapshot emitted 248 banners in a serial
* loop). The cap is deliberately small — past a handful the individual
* identities are unreadable as notifications anyway, and the useful signal is
* that a batch arrived.
*/
export const JOIN_ALERT_MAX_INDIVIDUAL = 3;
export function joinAlertSummaryBody(count: number) {
return `${count} new members joined`;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,538 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
myRelayMembershipLookupQueryKey,
relayMembersQueryKey,
} from "@/features/community-members/hooks";
import { useMyRelayMembershipLookupQuery } from "@/features/community-members/hooks";
import { useCommunities } from "@/features/communities/useCommunities";
import {
joinAlertBody,
joinAlertSummaryBody,
joinAlertTitle,
normalizeJoinPubkey,
readJoinAlertLedger,
reconcileJoinAlertLedger,
writeJoinAlertLedger,
type JoinAlertLedger,
JOIN_ALERT_MAX_INDIVIDUAL,
} from "@/features/community-members/lib/joinAlerts";
import { sendDesktopNotification } from "@/features/notifications/lib/desktop";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { relayClient } from "@/shared/api/relayClient";
import { useIdentityQuery } from "@/shared/api/hooks";
import {
canManageCommunityMembers,
relayMembersFromEvent,
} from "@/shared/api/relayMembers";
import { getUsersBatch } from "@/shared/api/tauriProfiles";
import type { RelayEvent, RelayMember } from "@/shared/api/types";
const KIND_NIP43_MEMBERSHIP_LIST = 13534;
const KIND_NIP43_MEMBER_ADDED = 8000;
/**
* Trailing window for coalescing kind:8000-triggered snapshot refetches.
*
* Long enough that a bulk add collapses to a single REQ, short enough that a
* lone join still feels immediate the accelerator exists only to beat the
* live snapshot's own arrival, so sub-second is the whole budget.
*/
const MEMBER_REFRESH_DEBOUNCE_MS = 500;
/**
* Everything one mounted effect run is allowed to act on.
*
* The subscription callbacks that deliver snapshots belong to the effect run
* that registered them, but `handleSnapshot` is a `useEffectEvent` and so reads
* whatever is *currently* rendered. Between the re-render that switches
* community and that effect's cleanup, those two disagree and a snapshot from
* the old community would be folded into the new community's ledger under the
* new community's storage key.
*
* Binding the identity, the ledger, and the ordering state into one object
* created by the effect run turns those scattered ambient reads into a single
* value with an identity that can be compared. `handleSnapshot` still reads
* `sessionRef.current`, so it is the surrounding ordering that makes the bug
* unreachable: cleanup retires the session before the next run installs its
* own, each retired callback is stopped by its run's `disposed` flag, and every
* send boundary re-checks that the session it captured is still the live one.
*/
type JoinAlertSession = {
communityId: string;
viewerPubkey: string;
ledger: JoinAlertLedger;
/**
* `created_at` of the newest snapshot already folded in.
*
* A snapshot older than this is a stale view of the roster an in-flight
* refetch that resolves after a newer live frame and must not be treated as
* current. Without this, an older frame can re-alert a departed key or, worse,
* re-assert an authorization a newer frame just revoked.
*/
newestSnapshotAt: number;
/**
* Latched once a snapshot shows the viewer is no longer owner/admin.
*
* Fail-closed, and deliberately stronger than the `newestSnapshotAt` fence:
* the relay can publish two snapshots within the same second, so an
* equal-`created_at` stale frame passes a strictly-older fence. Dropping
* equal timestamps instead would discard legitimate same-second joins. The
* latch removes the timestamp from the safety argument entirely once
* revocation is observed, this session is done disclosing, whatever order the
* remaining frames arrive in.
*
* Re-promotion is unaffected: nothing invalidates the membership lookup on
* promotion, so regaining the panel already requires a reload today.
*/
revoked: boolean;
};
/**
* Trailing quiet window for coalescing join alerts ACROSS snapshots.
*
* The per-snapshot cap bounds "one snapshot, many keys". It does nothing for
* "one burst, many snapshots": the relay republishes the whole 13534 as each
* concurrent add commits, so a 50-join storm arrives as a handful of growing
* rosters and each one independently emitted its own capped batch. Max measured
* 10 banners from 50 real joins at `fdeda44f0` for exactly this reason.
*
* Sized above the observed intermediate-snapshot cadence so a burst lands in
* one batch, and above MEMBER_REFRESH_DEBOUNCE_MS so an 8000-triggered refetch
* folds into the same window rather than flushing behind it.
*/
const JOIN_ALERT_NOTIFY_WINDOW_MS = 1_500;
/**
* Ceiling on how long a batch may be deferred by the trailing window.
*
* `JOIN_ALERT_NOTIFY_WINDOW_MS` is a pure trailing debounce: every snapshot
* re-arms it, so a join cadence faster than the window defers delivery for as
* long as the joins keep coming. Measured before this clamp existed: 13 joins at
* ~700ms intervals produced zero notifications across 9.1 continuous seconds.
*
* That is the wrong shape for an alerting feature, and it is worse than mere
* lateness the ledger is persisted per snapshot while delivery waits, so a
* quit or community switch mid-drip drops a batch the ledger already recorded as
* alerted, and it is never re-announced. Clamping bounds both the silence and
* that loss window.
*
* Sized against both ends rather than picked round: it must exceed the span a
* bulk add's intermediate snapshots occupy, or the clamp would split the burst
* this window exists to collapse, and it must sit BELOW the measured drip above,
* or it would leave the case that motivated it unchanged. A burst's snapshots
* arrive within a second or two of each other; the drip ran 9.1s. Five seconds
* clears the first by a wide margin and cuts the second roughly in half.
*/
const JOIN_ALERT_MAX_DEFERRAL_MS = 5_000;
/**
* Notify community owners/admins the first time a key appears in their roster.
*
* Delivery rests on a live kind:13534 subscription because that snapshot is the
* only membership signal covering every join path with cross-pod propagation;
* see `lib/joinAlerts.ts` for the full rationale. Desktop's other 13534 read
* (`relayMembers.ts`) is a one-shot fetch, so without this subscription no
* snapshot ever arrives passively and nothing could fire.
*
* The kind:8000 delta is subscribed purely to shorten latency on the paths that
* emit one. It refreshes the authoritative snapshot rather than alerting from
* the delta's own payload, so one ledger governs both signals and the pair
* cannot double-alert.
*
* Viewer, community, and role are read from context rather than passed in:
* `AppShell` is at the file-size ratchet ceiling, so the mount has to stay a
* single call.
*/
export function useCommunityJoinAlerts({ enabled }: { enabled: boolean }) {
const queryClient = useQueryClient();
const { activeCommunity } = useCommunities();
const identityQuery = useIdentityQuery();
const membershipQuery = useMyRelayMembershipLookupQuery();
const communityId = activeCommunity?.id ?? null;
const communityName = activeCommunity?.name ?? null;
const normalizedViewer = normalizeJoinPubkey(
identityQuery.data?.pubkey ?? "",
);
const active =
enabled &&
canManageCommunityMembers(membershipQuery.data) &&
communityId !== null &&
normalizedViewer.length > 0;
// Session for the current effect run. Callbacks read it through this ref so
// they stay stable — re-subscribing on every roster change would drop deltas
// in the gap between REQ and CLOSE — but every read is validated against the
// session's own bound community, never against ambient render state.
const sessionRef = React.useRef<JoinAlertSession | null>(null);
// Community name is read fresh rather than captured, because a rename does not
// re-key the effect and a captured name would go stale. Guarded by id at use
// time so it can only ever label its own community.
const communityNameRef = React.useRef<{ id: string; name: string } | null>(
null,
);
communityNameRef.current =
communityId === null
? null
: { id: communityId, name: communityName ?? "" };
const resolveTitle = React.useCallback((session: JoinAlertSession) => {
const named = communityNameRef.current;
// Fall back to the generic title rather than a name belonging to a
// different community.
return joinAlertTitle(
named?.id === session.communityId ? named.name : null,
);
}, []);
// Pending cross-snapshot batch. A burst arrives as several growing rosters,
// so alerts accumulate here and flush once the roster stops moving.
//
// `pendingEventRef` holds the LATEST snapshot only, as the notification's
// click target. Every key in the batch is present in that roster (the ledger
// is monotonic within a burst), so the newest snapshot is the accurate
// referent for the whole batch.
const pendingRef = React.useRef<string[]>([]);
const pendingEventRef = React.useRef<RelayEvent | null>(null);
const notifyTimerRef = React.useRef<number | null>(null);
// When the batch currently pending first enqueued, for the deferral clamp.
const pendingSinceRef = React.useRef<number | null>(null);
// Cancellation token for flushes already past the refs.
//
// Clearing the refs cannot stop a flush that has already consumed them and
// is parked on an await, and every send in `flushPending` sits behind one:
// the profile lookup, and each notification itself. A demotion, removal,
// unmount, or community switch landing in that window would otherwise still
// deliver — Max and Wren both found this at 5d0d2b4c.
//
// Bumped ONLY by `clearPending`, never by an ordinary enqueue, so an
// authorized batch queued while an earlier flush's lookup is in flight
// neither cancels it nor is cancelled by it: both deliver. Cancellation is
// the only thing that invalidates a claim.
const flushGenerationRef = React.useRef(0);
/** Drop anything queued but not yet delivered, in flight or not. */
const clearPending = React.useCallback(() => {
pendingRef.current = [];
pendingEventRef.current = null;
pendingSinceRef.current = null;
flushGenerationRef.current += 1;
if (notifyTimerRef.current !== null) {
window.clearTimeout(notifyTimerRef.current);
notifyTimerRef.current = null;
}
}, []);
const flushPending = React.useEffectEvent(async () => {
const session = sessionRef.current;
const alerts = pendingRef.current;
const event = pendingEventRef.current;
pendingRef.current = [];
pendingEventRef.current = null;
pendingSinceRef.current = null;
if (alerts.length === 0 || !event || !session) return;
// A session that observed revocation never delivers, even if a batch was
// queued before the latch closed.
if (session.revoked) return;
// Claim this batch. Checked again at every side-effect boundary below —
// not merely after the awaits that exist today, so that adding an await
// later cannot silently reopen the disclosure.
const generation = flushGenerationRef.current;
const cancelled = () =>
flushGenerationRef.current !== generation ||
sessionRef.current !== session ||
session.revoked;
// Bind the title to the community these keys were queued under, not to
// whatever is active when the send resolves.
const title = resolveTitle(session);
// Resolve display names so the alert reads "Alice joined" rather than a
// truncated key; a lookup failure degrades to the key, it does not skip.
//
// Above the cap the batch collapses into one summary, so skip the profile
// fetch entirely — it would be a 250-key request whose result is unused.
if (alerts.length > JOIN_ALERT_MAX_INDIVIDUAL) {
if (cancelled()) return;
await sendDesktopNotification({
body: joinAlertSummaryBody(alerts.length),
target: {
channelId: null,
eventId: event.id,
kind: event.kind,
pubkey: undefined,
},
title,
});
return;
}
let profiles: UserProfileLookup | undefined;
try {
profiles = (await getUsersBatch(alerts)).profiles;
} catch {
profiles = undefined;
}
for (const pubkey of alerts) {
// Per-send, not once after the lookup: a demotion landing between two
// named sends must suppress the rest of the batch, not just the batch
// that had not started.
if (cancelled()) return;
await sendDesktopNotification({
body: joinAlertBody(
resolveUserLabel({ preferResolvedSelfLabel: true, profiles, pubkey }),
),
target: {
channelId: null,
eventId: event.id,
kind: event.kind,
pubkey,
},
title,
});
}
});
const handleSnapshot = React.useEffectEvent(async (event: RelayEvent) => {
const session = sessionRef.current;
if (!session) return;
// Already revoked: this session neither alerts nor learns anything further.
if (session.revoked) return;
const roster = relayMembersFromEvent(event);
const rosterPubkeys = roster.map((member) => member.pubkey);
if (rosterPubkeys.length === 0) return;
// Drop a stale view of the roster before it can be treated as current.
//
// An in-flight refetch (kind:8000 accelerator or reconnect) can resolve
// AFTER a newer live frame. Processing it would fold a superseded roster in
// as authoritative — re-alerting a departed key, and re-asserting an
// authorization the newer frame revoked. Strictly older only: two snapshots
// can share a second, and dropping equal timestamps would discard real
// joins. The revocation latch, not this fence, is what makes the privacy
// arm safe at equal timestamps.
const snapshotAt = event.created_at;
if (snapshotAt < session.newestSnapshotAt) return;
// The roster can change shape without anything being new to us (a removal
// or a role change), so refresh the panel regardless of alert eligibility.
//
// Written directly rather than invalidated. `invalidateQueries` refetches
// every ACTIVE observer, and `listRelayMembers` is a REQ frame
// (`fetchFirstEvent({ kinds: [13534], limit: 1 })`), so with the members
// panel open this path emitted one REQ per accepted snapshot — measured
// 1:1 across 20 snapshots, live and in unit, against a documented budget
// of limit x window = 50 REQ per 5s (`default_human_ws()` = 10/s,
// `WS_BURST_WINDOW_SECS` = 5; REQ is billed as `WsEvents`). A join burst
// large enough to matter would rate-limit the owner out of their own app,
// and unlike the kind:8000 accelerator this path is not behind
// `MEMBER_REFRESH_DEBOUNCE_MS`.
//
// The refetch was never load-bearing: `roster` above is the output of the
// same `relayMembersFromEvent` parser `listRelayMembers` feeds the query
// with (`relayMembers.ts:125-127`), from a snapshot this session has
// already accepted as current — so the write is the identical shape and
// strictly fresher than a refetch, which would race the stream that
// triggered it. The stale fence above guarantees no superseded roster
// reaches here, and the query client is per-community
// (`CommunityQueryProvider key={communityKey}`, `App.tsx:556`), so this
// non-community-scoped key cannot be written across a switch.
queryClient.setQueryData<RelayMember[]>(relayMembersQueryKey, roster);
// Authorize against the snapshot in hand, not the cached role that mounted
// this effect. `useMyRelayMembershipLookupQuery` is only invalidated by this
// client's own membership mutations, and `staleTime` marks data stale
// without scheduling a refetch, so a viewer demoted by another admin keeps
// a cached owner/admin role for as long as the app stays open — and would
// otherwise keep learning every later joiner's identity from a role they no
// longer hold. The snapshot carries the viewer's own role
// (`["member", pubkey, role]`, relay-signed in `publish_nip43_membership_locked`),
// so the event that revokes authorization is the same event that would
// disclose the join. Checking it here closes that race in one read rather
// than racing an async invalidation.
//
// Fail closed: a snapshot that does not list the viewer at all means they
// were removed outright.
const viewerEntry = roster.find(
(member) => member.pubkey === session.viewerPubkey,
);
if (viewerEntry?.role !== "owner" && viewerEntry?.role !== "admin") {
// Latch, so no later frame — including an older authorized snapshot still
// in flight — can re-open disclosure for this session.
session.revoked = true;
// Revocation must also drop anything queued but not yet delivered.
// Batching across snapshots would otherwise reopen the disclosure Wren
// found as a *delayed* one: joins accumulated while authorized would
// still fire from a timer after the snapshot that revoked the role.
clearPending();
// Refresh the mount gate so the subscriptions themselves tear down.
void queryClient.invalidateQueries({
queryKey: myRelayMembershipLookupQueryKey,
});
return;
}
// Fence advances only here: past the roster and authorization checks, on a
// frame this session actually accepts as its current view. Advancing it at
// the comparison instead would let a frame rejected for some *other* reason
// push the fence past a legitimate frame still in flight, dropping a real
// snapshot as though it were stale.
session.newestSnapshotAt = snapshotAt;
const { alerts, changed, ledger } = reconcileJoinAlertLedger({
ledger: session.ledger,
rosterPubkeys,
viewerPubkey: session.viewerPubkey,
});
if (!changed) return;
// Persisted before notifying, never after: a crash between the two must
// lose the notification rather than repeat it on every later snapshot.
//
// A write that cannot land (quota still exceeded after cache eviction)
// leaves the session's ledger alone deliberately. Advancing it would mark
// these keys seen in memory while nothing reached storage, so the alert
// would be lost until a reload; leaving it means the next snapshot retries
// the write and the alert survives to whichever attempt lands. The notify is
// skipped either way — a false return means nothing was persisted, so
// notifying here is exactly the "repeat on every later snapshot" this
// ordering exists to prevent.
if (
!writeJoinAlertLedger(session.communityId, session.viewerPubkey, ledger)
) {
return;
}
session.ledger = ledger;
if (alerts.length === 0) return;
// Queue rather than notify. Persistence and the ledger advance stay
// synchronous per snapshot (above), so cross-snapshot dedupe still holds
// and a crash before the flush loses the alert rather than repeating it —
// the ordering invariant this feature already committed to. Only the
// delivery is deferred, onto a trailing quiet window, so one burst
// produces one alert instead of one per intermediate snapshot.
pendingRef.current.push(...alerts);
pendingEventRef.current = event;
if (notifyTimerRef.current !== null) {
window.clearTimeout(notifyTimerRef.current);
}
const now = Date.now();
if (pendingSinceRef.current === null) pendingSinceRef.current = now;
// Clamp the trailing window so a sustained drip cannot defer delivery (and
// the ledger-already-written loss window) without bound.
const deadline = pendingSinceRef.current + JOIN_ALERT_MAX_DEFERRAL_MS;
const delay = Math.max(
0,
Math.min(JOIN_ALERT_NOTIFY_WINDOW_MS, deadline - now),
);
notifyTimerRef.current = window.setTimeout(() => {
notifyTimerRef.current = null;
void flushPending();
}, delay);
});
React.useEffect(() => {
if (!active || communityId === null) return;
// One session per effect run. Every callback below reaches this community's
// ledger and this viewer's role through it and cannot reach any other, so a
// switch mid-flight is a cancelled session rather than a mislabeled alert.
const session: JoinAlertSession = {
communityId,
ledger: readJoinAlertLedger(communityId, normalizedViewer),
newestSnapshotAt: 0,
revoked: false,
viewerPubkey: normalizedViewer,
};
sessionRef.current = session;
let disposed = false;
const disposers: Array<() => Promise<void>> = [];
let refreshTimeout: number | null = null;
const track = (unsubscribe: () => Promise<void>) => {
if (disposed) {
void unsubscribe();
return;
}
disposers.push(unsubscribe);
};
const fetchSnapshot = () => {
void relayClient
.fetchFirstEvent({ kinds: [KIND_NIP43_MEMBERSHIP_LIST], limit: 1 })
.then((snapshot) => {
if (!disposed && snapshot) void handleSnapshot(snapshot);
})
.catch(() => {
// Best effort: the live 13534 subscription still delivers.
});
};
/**
* Coalesce refetches on a trailing window.
*
* Each refetch is a REQ frame, and REQ is billed against the same per-
* principal `WsEvents` budget as the user's own sends (default 10/s over a
* 5s window). A bulk add emits one kind:8000 per member, so an uncoalesced
* 1:1 refetch would spend the budget the owner needs for messages and
* channel opens rate-limiting them out of their own app. One snapshot is
* authoritative for the whole burst, so the trailing edge loses nothing.
*/
const refreshSnapshot = () => {
if (disposed || refreshTimeout !== null) return;
refreshTimeout = window.setTimeout(() => {
refreshTimeout = null;
if (!disposed) fetchSnapshot();
}, MEMBER_REFRESH_DEBOUNCE_MS);
};
void relayClient
.subscribeLive({ kinds: [KIND_NIP43_MEMBERSHIP_LIST], limit: 1 }, (e) => {
if (!disposed) void handleSnapshot(e);
})
.then(track)
.catch((error) => {
console.error("Couldnt subscribe to community membership", error);
});
// Accelerator only: refetch the authoritative snapshot instead of trusting
// the delta, so the ledger only ever sees one consistent roster view.
void relayClient
.subscribeLive({ kinds: [KIND_NIP43_MEMBER_ADDED], limit: 0 }, () => {
if (!disposed) refreshSnapshot();
})
.then(track)
.catch((error) => {
console.error("Couldnt subscribe to community joins", error);
});
// A reconnect can span joins that landed while the socket was down, and
// `limit: 1` backfill is not guaranteed to redeliver them.
const unsubscribeReconnect =
relayClient.subscribeToReconnects(refreshSnapshot);
return () => {
disposed = true;
if (refreshTimeout !== null) window.clearTimeout(refreshTimeout);
// Retire the session before dropping the batch, so any flush already past
// the refs sees `sessionRef.current !== session` and stops. Guarded in
// case a later run has already installed its own.
if (sessionRef.current === session) sessionRef.current = null;
// Drop the queued batch too, not just its timer: on a community switch
// this effect re-keys, and keys accumulated for the old community must
// not flush against the new one.
clearPending();
unsubscribeReconnect();
for (const dispose of disposers) void dispose();
};
}, [active, communityId, normalizedViewer, clearPending]);
}