mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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("Couldn’t 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("Couldn’t 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user