Files
buzz/desktop/src/features/presence/hooks.ts
Will Pfleger 258cc92064 fix(desktop): survive degraded networks with rate-limit-aware relay client (#2197)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co>
2026-07-21 12:18:20 -04:00

404 lines
13 KiB
TypeScript

import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { relayClient } from "@/shared/api/relayClient";
import { isRateLimited } from "@/shared/api/relayRateLimitGate";
import { useRelayConnection } from "@/shared/api/useRelayConnection";
import { getOsIdleSeconds } from "@/shared/api/osIdle";
import { getPresence } from "@/shared/api/tauri";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
mergePresenceUpdate,
parseLivePresenceEvent,
presenceQueryWantsPubkey,
resolveAutomaticPresenceStatus,
} from "@/features/presence/lib/presence";
import type { PresenceLookup, PresenceStatus } from "@/shared/api/types";
const PRESENCE_HEARTBEAT_INTERVAL_MS = 30_000;
const PRESENCE_STATUS_TICK_INTERVAL_MS = 30_000;
const PRESENCE_ACTIVITY_THROTTLE_MS = 1_000;
const PRESENCE_TTL_SECONDS = 90;
const PRESENCE_PREFERENCE_STORAGE_KEY = "buzz-presence-preference";
type PresencePreference = "auto" | "away" | "offline" | null;
function normalizePubkeys(pubkeys: string[]) {
return [...new Set(pubkeys.map((pubkey) => normalizePubkey(pubkey)))]
.filter((pubkey) => pubkey.length > 0)
.sort();
}
function presenceQueryKey(pubkeys: string[]) {
return ["presence", ...normalizePubkeys(pubkeys)] as const;
}
function presencePreferenceStorageKey(pubkey: string) {
return `${PRESENCE_PREFERENCE_STORAGE_KEY}:${pubkey}`;
}
function readStoredPresencePreference(pubkey: string): PresencePreference {
if (typeof window === "undefined" || pubkey.length === 0) {
return null;
}
const value = window.localStorage.getItem(
presencePreferenceStorageKey(pubkey),
);
return value === "auto" || value === "away" || value === "offline"
? value
: null;
}
function writeStoredPresencePreference(
pubkey: string,
preference: PresencePreference,
) {
if (typeof window === "undefined" || pubkey.length === 0) {
return;
}
if (preference === null) {
window.localStorage.removeItem(presencePreferenceStorageKey(pubkey));
return;
}
window.localStorage.setItem(presencePreferenceStorageKey(pubkey), preference);
}
function resolveAutomaticPresenceStatusSync(
lastActivityAt: number,
now: number,
): PresenceStatus {
return resolveAutomaticPresenceStatus(null, lastActivityAt, now);
}
export function usePresenceQuery(
pubkeys: string[],
options?: {
enabled?: boolean;
},
) {
const normalizedPubkeys = normalizePubkeys(pubkeys);
const enabled = (options?.enabled ?? true) && normalizedPubkeys.length > 0;
const connectionState = useRelayConnection();
const connected = connectionState === "connected";
return useQuery<PresenceLookup>({
enabled,
queryKey: presenceQueryKey(normalizedPubkeys),
queryFn: () => getPresence(normalizedPubkeys),
staleTime: 30_000,
// Backstop poll: catches REST-only writers (ACP agents) and TTL expiry
// (crashed clients). WS events handle the fast path. Pause on degraded
// connections — HTTP presence calls fail anyway and consume relay quota.
refetchInterval: connected ? 60_000 : false,
});
}
/**
* Subscribe to kind:20001 presence events over WebSocket and update the
* TanStack Query presence cache in-place when updates arrive. Call once
* in AppShell. Uses setQueriesData for targeted per-pubkey updates without
* triggering refetches. Retries with exponential backoff on failure.
*/
export function usePresenceSubscription() {
const queryClient = useQueryClient();
React.useEffect(() => {
let unsub: (() => Promise<void>) | null = null;
let isCancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
function handlePresenceEvent(event: { pubkey: string; content: string }) {
if (isCancelled) return;
const parsed = parseLivePresenceEvent(event);
if (!parsed) return;
const { pubkey, status } = parsed;
queryClient.setQueriesData<PresenceLookup>(
{
queryKey: ["presence"],
predicate: (query) =>
presenceQueryWantsPubkey(query.queryKey, pubkey),
},
(old) => mergePresenceUpdate(old, pubkey, status),
);
}
function subscribeWithRetry(attempt = 0) {
if (isCancelled) return;
void relayClient
.subscribeToPresenceUpdates(handlePresenceEvent)
.then((unsubFn) => {
if (isCancelled) {
void unsubFn();
return;
}
unsub = unsubFn;
})
.catch(() => {
if (!isCancelled) {
const delay = Math.min(1000 * 2 ** attempt, 30_000);
retryTimer = setTimeout(
() => subscribeWithRetry(attempt + 1),
delay,
);
}
});
}
subscribeWithRetry();
const unsubReconnect = relayClient.subscribeToReconnects(() => {
if (!isCancelled)
void queryClient.invalidateQueries({ queryKey: ["presence"] });
});
return () => {
isCancelled = true;
unsubReconnect();
if (retryTimer) clearTimeout(retryTimer);
if (unsub) void unsub();
};
}, [queryClient]);
}
export function useSetPresenceMutation(pubkey?: string) {
const queryClient = useQueryClient();
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
return useMutation({
mutationFn: async (status: PresenceStatus) => {
await relayClient.sendPresence(status);
return {
status,
ttlSeconds: status === "offline" ? 0 : PRESENCE_TTL_SECONDS,
};
},
onSuccess: ({ status }) => {
if (normalizedPubkey.length === 0) return;
queryClient.setQueriesData<PresenceLookup>(
{
queryKey: ["presence"],
predicate: (query) =>
presenceQueryWantsPubkey(query.queryKey, normalizedPubkey),
},
(old) => mergePresenceUpdate(old, normalizedPubkey, status),
);
},
});
}
export function usePresenceSession(pubkey?: string) {
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
const presenceQuery = usePresenceQuery(
normalizedPubkey.length > 0 ? [normalizedPubkey] : [],
{ enabled: normalizedPubkey.length > 0 },
);
const setPresenceMutation = useSetPresenceMutation(normalizedPubkey);
const [presencePreference, setPresencePreference] =
React.useState<PresencePreference>(() =>
readStoredPresencePreference(normalizedPubkey),
);
// Activity is tracked in a REF, and React state holds only the DERIVED
// status. The previous shape stored raw `Date.now()` timestamps in state
// and bumped them from a capture-phase `keydown`/`pointerdown` listener —
// which re-rendered this hook's host (AppShell, the app root) on every
// keystroke the user typed anywhere in the app. Presence only needs a
// render when the automatic status actually flips (online <-> away), so
// activity updates the ref and re-derives; setState fires on transitions
// only (see typing-latency.perf.ts / keystroke input-to-paint).
const lastActivityAtRef = React.useRef(Date.now());
const [automaticStatus, setAutomaticStatus] = React.useState<PresenceStatus>(
() =>
resolveAutomaticPresenceStatusSync(lastActivityAtRef.current, Date.now()),
);
const automaticStatusRef = React.useRef(automaticStatus);
const skipNextSyncRef = React.useRef<PresenceStatus | null>(null);
const applyAutomaticStatus = React.useEffectEvent((next: PresenceStatus) => {
if (next !== automaticStatusRef.current) {
automaticStatusRef.current = next;
setAutomaticStatus(next);
}
});
const reevaluateAutomaticStatus = React.useEffectEvent(() => {
applyAutomaticStatus(
resolveAutomaticPresenceStatusSync(lastActivityAtRef.current, Date.now()),
);
});
// OS-wide idle is authoritative when available; async, so tick-driven only.
const reevaluateFromOsIdle = React.useEffectEvent(async () => {
const osIdleSeconds = await getOsIdleSeconds().catch(() => null);
if (typeof osIdleSeconds === "number") {
// Keep the fallback clock consistent so a later null reading can't flap.
lastActivityAtRef.current = Math.max(
lastActivityAtRef.current,
Date.now() - osIdleSeconds * 1000,
);
}
applyAutomaticStatus(
resolveAutomaticPresenceStatus(
typeof osIdleSeconds === "number" ? osIdleSeconds : null,
lastActivityAtRef.current,
Date.now(),
),
);
});
React.useEffect(() => {
setPresencePreference(readStoredPresencePreference(normalizedPubkey));
lastActivityAtRef.current = Date.now();
reevaluateAutomaticStatus();
}, [normalizedPubkey]);
React.useEffect(() => {
writeStoredPresencePreference(normalizedPubkey, presencePreference);
}, [normalizedPubkey, presencePreference]);
const recordActivity = React.useEffectEvent(() => {
lastActivityAtRef.current = Date.now();
reevaluateAutomaticStatus();
});
React.useEffect(() => {
if (normalizedPubkey.length === 0) {
return;
}
// Fallback activity signal: wheel/pointermove count (passive reading),
// throttled to 1/s; window visibility never affects presence.
let lastRecordedAt = 0;
function handleUserActivity() {
const now = Date.now();
if (now - lastRecordedAt < PRESENCE_ACTIVITY_THROTTLE_MS) {
return;
}
lastRecordedAt = now;
recordActivity();
}
window.addEventListener("pointerdown", handleUserActivity, true);
window.addEventListener("pointermove", handleUserActivity, true);
window.addEventListener("wheel", handleUserActivity, {
capture: true,
passive: true,
});
window.addEventListener("keydown", handleUserActivity, true);
window.addEventListener("focus", handleUserActivity);
return () => {
window.removeEventListener("pointerdown", handleUserActivity, true);
window.removeEventListener("pointermove", handleUserActivity, true);
window.removeEventListener("wheel", handleUserActivity, {
capture: true,
});
window.removeEventListener("keydown", handleUserActivity, true);
window.removeEventListener("focus", handleUserActivity);
};
}, [normalizedPubkey]);
React.useEffect(() => {
if (normalizedPubkey.length === 0) {
return;
}
void reevaluateFromOsIdle();
const intervalId = window.setInterval(() => {
void reevaluateFromOsIdle();
}, PRESENCE_STATUS_TICK_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [normalizedPubkey]);
const currentStatus =
normalizedPubkey.length === 0
? "offline"
: presencePreference === "offline"
? "offline"
: presencePreference === "away"
? "away"
: presencePreference === "auto"
? automaticStatus
: automaticStatus;
const updatePresence = React.useCallback(
async (status: PresenceStatus) => {
const previousPreference = presencePreference;
const nextPreference: PresencePreference =
status === "online" ? "auto" : status;
if (nextPreference === "auto") {
lastActivityAtRef.current = Date.now();
reevaluateAutomaticStatus();
}
setPresencePreference(nextPreference);
skipNextSyncRef.current = status;
try {
await setPresenceMutation.mutateAsync(status);
} catch (error) {
skipNextSyncRef.current = null;
setPresencePreference(previousPreference);
throw error;
}
},
[presencePreference, setPresenceMutation],
);
const syncPresence = React.useEffectEvent((status: PresenceStatus) => {
void setPresenceMutation.mutateAsync(status).catch(() => {
return;
});
});
React.useEffect(() => {
if (normalizedPubkey.length === 0) {
return;
}
if (skipNextSyncRef.current === currentStatus) {
skipNextSyncRef.current = null;
return;
}
syncPresence(currentStatus);
}, [currentStatus, normalizedPubkey]);
React.useEffect(() => {
if (normalizedPubkey.length === 0 || currentStatus === "offline") {
return;
}
const intervalId = window.setInterval(() => {
// Skip heartbeat ticks while the relay is unavailable or rate-limited —
// the publish would fail anyway and consumes quota the recovery needs.
if (relayClient.getConnectionState() !== "connected" || isRateLimited()) {
return;
}
syncPresence(currentStatus);
}, PRESENCE_HEARTBEAT_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [currentStatus, normalizedPubkey]);
return {
currentStatus,
isLoading: presenceQuery.isLoading,
isPending: setPresenceMutation.isPending,
error:
setPresenceMutation.error instanceof Error
? setPresenceMutation.error
: presenceQuery.error instanceof Error
? presenceQuery.error
: null,
setStatus: updatePresence,
};
}