Files
cinny/src/app/hooks/usePresenceUpdater.ts
T
Lotus CIandClaude Opus 5.5 f62c5d5778
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
fix(status): a status cleared on another device stays cleared (#187 DP3)
Status messages are saved per device and re-sent with every presence
heartbeat (a presence write without status_msg clears it on Synapse). A
device never receives its own user's presence changes made on other
devices, so the DP3 fix in db864326 — mirroring remote changes from the
Profile page — could never fire: device B kept a status that device A had
cleared and re-published it on its next state change.

Heartbeats now reconcile with the server first: GET our own presence,
send the server's current status_msg and bring the local copy in line.
Falls back to the local copy when the read fails, when the server shows
us offline (invisible mode clears the status by design), and for 15 s
after this device saved/cleared its own status (a server read that
hasn't caught up yet can't override a fresh save).

Verified with two sessions of the same user against local Synapse:
B sets "dp3 old status" → A clears it → B goes hidden→visible → server
stays "" and B's local copy is removed (before: back to "dp3 old status").
A sets "dp3 new from A" → B heartbeat keeps it and adopts it locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 11:50:13 -04:00

197 lines
7.5 KiB
TypeScript

import { useEffect, useRef } from 'react';
import { PresenceDeduper, PresenceWrite } from '../utils/presenceWrite';
import { useMatrixClient } from './useMatrixClient';
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
const ACTIVITY_THROTTLE_MS = 1000;
export type PresenceSetting = 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
export type PresenceState = 'online' | 'unavailable' | 'offline';
/**
* Single source of truth for mapping the user's presence preference to the
* Matrix presence value: auto/online → 'online', idle/dnd → 'unavailable',
* invisible (or the hidePresence override) → 'offline'. Shared with the Profile
* status writer so setting/clearing a status message never overrides the user's
* chosen presence (e.g. outing an Invisible user as online).
*/
export function presenceStateFromSetting(
presenceStatus: PresenceSetting,
hidePresence: boolean,
): PresenceState {
if (hidePresence || presenceStatus === 'invisible') return 'offline';
if (presenceStatus === 'idle' || presenceStatus === 'dnd') return 'unavailable';
return 'online';
}
export function usePresenceUpdater() {
const mx = useMatrixClient();
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const isIdleRef = useRef(false);
const lastActivityRef = useRef(0);
const deduperRef = useRef(new PresenceDeduper());
useEffect(() => {
const userId = mx.getUserId();
const deduper = deduperRef.current;
// A manual status save changes what the next heartbeat should carry.
deduper.reset();
// Read status from localStorage at call time so manual updates from the
// Profile settings are never overwritten by a stale closure value.
const readStatus = () =>
userId ? (localStorage.getItem(`lotus-status-msg-${userId}`) ?? '') : '';
// Log presence failures without leaking PII (user id, token, status message).
const warnPresenceFailure = (presence: string, err: unknown) => {
const reason =
err instanceof Error ? err.message : typeof err === 'string' ? err : 'unknown error';
console.warn(`Failed to set presence to "${presence}":`, reason);
};
// Synapse allows ONE presence write per 10 s per user (rc_presence, shared
// by all devices) and /sync already keeps us online — only send when the
// state or status actually changes, so a manual status save isn't 429'd
// by a heartbeat that changed nothing.
const send = (write: PresenceWrite, label: string) => {
if (!deduper.shouldSend(write)) return Promise.resolve();
return mx
.setPresence(write)
.then(() => deduper.sent(write))
.catch((err) => warnPresenceFailure(label, err));
};
// #187 DP3: a device never sees its own presence changes made on other
// devices, so its saved status could be one another device already
// cleared. Ask the server what the status is now before re-sending it, and
// bring the local copy in line (falls back to the local copy on failure).
const resolveStatus = async (): Promise<string> => {
const local = readStatus();
if (!userId) return local;
const server = await mx.getPresence(userId).catch(() => undefined);
const status = pickHeartbeatStatus({
local,
server,
lastLocalWriteAt: getLastLocalStatusWrite(),
now: Date.now(),
});
if (status !== local) {
if (status) localStorage.setItem(`lotus-status-msg-${userId}`, status);
else {
localStorage.removeItem(`lotus-status-msg-${userId}`);
localStorage.removeItem(`lotus-status-expiry-${userId}`);
}
}
return status;
};
const setOnline = async () => {
const status = await resolveStatus();
return send({ presence: 'online', ...(status ? { status_msg: status } : {}) }, 'online');
};
const setUnavailable = async (statusMsg?: string) => {
const status = await resolveStatus();
return send(
{
presence: 'unavailable',
...(statusMsg ? { status_msg: statusMsg } : status ? { status_msg: status } : {}),
},
'unavailable',
);
};
const setOffline = () => send({ presence: 'offline', status_msg: '' }, 'offline');
// Manual presence overrides — no activity tracking needed.
if (hidePresence || presenceStatus === 'invisible') {
setOffline();
return undefined;
}
if (presenceStatus === 'online') {
setOnline();
return undefined;
}
if (presenceStatus === 'idle') {
setUnavailable();
return undefined;
}
if (presenceStatus === 'dnd') {
setUnavailable('dnd');
return undefined;
}
// presenceStatus === 'auto' — original activity-tracking behavior.
const startIdleTimer = () => {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = setTimeout(() => {
isIdleRef.current = true;
setUnavailable();
}, IDLE_TIMEOUT_MS);
};
const handleActivity = () => {
const now = Date.now();
if (now - lastActivityRef.current < ACTIVITY_THROTTLE_MS) return;
lastActivityRef.current = now;
if (isIdleRef.current && !document.hidden) {
isIdleRef.current = false;
setOnline();
}
startIdleTimer();
};
const handleVisibilityChange = () => {
if (document.hidden) {
clearTimeout(idleTimerRef.current);
setUnavailable();
} else {
isIdleRef.current = false;
lastActivityRef.current = Date.now();
setOnline();
startIdleTimer();
}
};
const handlePageHide = () => {
const token = mx.getAccessToken();
const baseUrl = mx.getHomeserverUrl();
if (!userId || !token || !baseUrl) return;
// Reliable delivery during page teardown: navigator.sendBeacon cannot set the
// Authorization header required by the authenticated Matrix presence endpoint, so
// it isn't usable here. fetch(..., { keepalive: true }) lets the request outlive the
// page and is the correct mechanism for an authed endpoint. (keepalive bodies are
// capped at 64KB, which this tiny payload is well under.)
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ presence: 'offline' }),
keepalive: true,
}).catch((err) => warnPresenceFailure('offline (pagehide)', err));
};
setOnline();
startIdleTimer();
const activityEvents = ['mousemove', 'keydown', 'touchstart', 'click', 'scroll'] as const;
activityEvents.forEach((e) => window.addEventListener(e, handleActivity, { passive: true }));
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('pagehide', handlePageHide);
return () => {
clearTimeout(idleTimerRef.current);
activityEvents.forEach((e) => window.removeEventListener(e, handleActivity));
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('pagehide', handlePageHide);
};
}, [mx, hidePresence, presenceStatus]);
}