Files
buzz/desktop/src/features/presence/lib/presence.ts
Wes df9e773a13 Scope desktop presence subscriptions to active demand (#5830)
## 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>
2026-08-13 20:32:34 -07:00

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";
}
}