mirror of
https://github.com/block/buzz.git
synced 2026-11-07 09:52:03 +00:00
## Summary - replace the desktop's global kind-20001 presence subscription with one author-filtered subscription derived from active TanStack presence queries - reconcile changing demand without a delivery gap: promote only after relay EOSE, keep the last confirmed subscription on failure, discard stale opens, and close entirely when demand is empty - preserve REST presence as the initial seed and TTL/crash-recovery backstop - add transport-seam and lifecycle tests for readiness, normalization, churn, retries, close failures, reconnect ownership assumptions, and disposal ## Why The desktop currently receives presence heartbeats from every identity on the relay. A live tap measured roughly 2,700 events/minute (45/sec), about 1 MB/minute and 71.5% of readable traffic, from approximately 1,300 distinct fleet identities. Most are discarded only after WebSocket, Tauri IPC, and JS parsing. This change applies normal Nostr author filtering at relay fan-out, before those costs. It deliberately does not introduce a relay digest protocol or client-side event batching; relevant-author traffic should be small after scoping, and the existing signed-delta/REST-TTL model remains intact. ## Correctness model - active query observers are the demand source; inactive cached queries retain no authors - replacement opens before old closes and is promoted only after EOSE - timeout/CLOSED rejects and closes the candidate while preserving the last good subscription - rapid A→B→C and A→B→A churn cannot unseat current A with stale B - empty demand never sends an unfiltered subscription - RelayClient continues to own reconnect replay; the reconciler does not duplicate subscriptions on reconnect ## Validation Exact pushed head: `8845093aec0330be16efe52d3459ff67f1000ff4` Pre-push hooks passed: - desktop check and file-size ratchet - desktop TypeScript - desktop unit suite: 4,791/4,791 - branch-skew check Focused lifecycle/transport suite: 34/34 passed before commit. Independent Royal Court review found and blocked two prototype flaws (timeout-as-success and starvation-prone trailing debounce); both were fixed and the final worktree was cleared with no remaining correctness or lifecycle blockers. Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
107 lines
3.6 KiB
TypeScript
107 lines
3.6 KiB
TypeScript
import type { PresenceLookup, PresenceStatus } from "@/shared/api/types";
|
|
|
|
// Live kind:20001 events are self-signed by their author; the subject is
|
|
// always the event author. A p tag is NOT trusted here — a client could forge
|
|
// one to spoof another user. The relay-signed REST/seed path is the only place
|
|
// a p-tag subject is trusted. Returns null for unknown statuses.
|
|
export function parseLivePresenceEvent(event: {
|
|
pubkey: string;
|
|
content: string;
|
|
}): { pubkey: string; status: PresenceStatus } | null {
|
|
const status = event.content;
|
|
if (status !== "online" && status !== "away" && status !== "offline") {
|
|
return null;
|
|
}
|
|
return { pubkey: event.pubkey.toLowerCase(), status };
|
|
}
|
|
|
|
export function activePresencePubkeys(
|
|
queries: Array<{ queryKey: readonly unknown[]; isActive: () => boolean }>,
|
|
): string[] {
|
|
const pubkeys = new Set<string>();
|
|
for (const query of queries) {
|
|
if (!query.isActive() || query.queryKey[0] !== "presence") continue;
|
|
for (const value of query.queryKey.slice(1)) {
|
|
if (typeof value === "string" && value) pubkeys.add(value.toLowerCase());
|
|
}
|
|
}
|
|
return [...pubkeys].sort();
|
|
}
|
|
|
|
// Presence query keys are ["presence", ...normalizedSortedPubkeys]; a query
|
|
// "wants" an update only for a pubkey it actually requested.
|
|
export function presenceQueryWantsPubkey(
|
|
queryKey: readonly unknown[],
|
|
pubkey: string,
|
|
): boolean {
|
|
return queryKey.length > 1 && queryKey.includes(pubkey);
|
|
}
|
|
|
|
// get_presence omits offline/unknown pubkeys, so a live online event often
|
|
// targets a pubkey absent from the lookup — merge it in rather than dropping it.
|
|
export function mergePresenceUpdate(
|
|
old: PresenceLookup | undefined,
|
|
pubkey: string,
|
|
status: PresenceStatus,
|
|
): PresenceLookup | undefined {
|
|
if (!old) return old;
|
|
if (old[pubkey] === status) return old;
|
|
return { ...old, [pubkey]: status };
|
|
}
|
|
|
|
// Keep the local optimistic cache and relay expiry at three heartbeat windows.
|
|
// The relay owns the authoritative TTL; deploy its TTL increase before shipping
|
|
// a desktop build with a slower heartbeat.
|
|
export const PRESENCE_HEARTBEAT_INTERVAL_MS = 60_000;
|
|
export const PRESENCE_TTL_SECONDS = 3 * (PRESENCE_HEARTBEAT_INTERVAL_MS / 1000);
|
|
|
|
// Away means "human not at the machine" (Slack/Discord semantics), never
|
|
// "Buzz is not the focused window". OS-wide idle is authoritative when the
|
|
// platform exposes it; otherwise fall back to in-app activity.
|
|
export const PRESENCE_IDLE_TIMEOUT_MS = 10 * 60_000;
|
|
|
|
export function resolveAutomaticPresenceStatus(
|
|
osIdleSeconds: number | null,
|
|
lastActivityAt: number,
|
|
now: number,
|
|
): PresenceStatus {
|
|
if (osIdleSeconds !== null) {
|
|
return osIdleSeconds * 1000 >= PRESENCE_IDLE_TIMEOUT_MS ? "away" : "online";
|
|
}
|
|
return now - lastActivityAt >= PRESENCE_IDLE_TIMEOUT_MS ? "away" : "online";
|
|
}
|
|
|
|
export function getPresenceLabel(status: PresenceStatus) {
|
|
switch (status) {
|
|
case "online":
|
|
return "Online";
|
|
case "away":
|
|
return "Away";
|
|
case "offline":
|
|
return "Offline";
|
|
}
|
|
}
|
|
|
|
export function getPresenceDotClassName(status: PresenceStatus) {
|
|
switch (status) {
|
|
case "online":
|
|
return "bg-emerald-500";
|
|
case "away":
|
|
return "bg-amber-500";
|
|
case "offline":
|
|
return "bg-muted-foreground/35";
|
|
}
|
|
}
|
|
|
|
// Chip styling for the presence pill (colored fill + matching text, no dot).
|
|
export function getPresenceChipClassName(status: PresenceStatus) {
|
|
switch (status) {
|
|
case "online":
|
|
return "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400";
|
|
case "away":
|
|
return "bg-amber-500/15 text-amber-600 dark:text-amber-400";
|
|
case "offline":
|
|
return "bg-muted-foreground/15 text-muted-foreground";
|
|
}
|
|
}
|