Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 e50bd677d6 fix(presence): closing a tab no longer erases your custom status (#339)
CI / Build & Quality Checks (pull_request) Successful in 3m16s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 15s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m20s
The pagehide keepalive write sent { presence: 'offline' } with no
status_msg, which Synapse treats as clearing it, so closing or reloading
any Lotus tab wiped the user's status for everyone (intermittently, as
the 10 s presence rate limit sometimes 429'd the write). It now sends the
current status along with the offline state.

Verified against the local Synapse: with a status set from another
device, two reloads and a tab close keep it (server shows
offline/"Gone fishing" after the close; before, the first reload wiped it).

Fixes #339

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-11 22:04:57 -04:00
2 changed files with 17 additions and 49 deletions
+5 -1
View File
@@ -167,13 +167,17 @@ export function usePresenceUpdater() {
// it isn't usable here. fetch(..., { keepalive: true }) lets the request outlive the // 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 // page and is the correct mechanism for an authed endpoint. (keepalive bodies are
// capped at 64KB, which this tiny payload is well under.) // capped at 64KB, which this tiny payload is well under.)
// [Gitea #339] Synapse treats a missing status_msg as "clear it", so a bare
// { presence: 'offline' } wiped the user's custom status for everyone whenever
// any tab closed or reloaded. Send the current status along with it.
const status = readStatus();
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, { fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ presence: 'offline' }), body: JSON.stringify({ presence: 'offline', ...(status ? { status_msg: status } : {}) }),
keepalive: true, keepalive: true,
}).catch((err) => warnPresenceFailure('offline (pagehide)', err)); }).catch((err) => warnPresenceFailure('offline (pagehide)', err));
}; };
+12 -48
View File
@@ -783,20 +783,15 @@ function ReminderMonitor() {
const { reminders, removeReminder } = useReminders(); const { reminders, removeReminder } = useReminders();
const setToast = useSetAtom(toastQueueAtom); const setToast = useSetAtom(toastQueueAtom);
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const quietActive = useNotificationsQuiet();
const firedRef = useRef<Set<string>>(new Set()); const firedRef = useRef<Set<string>>(new Set());
const removingRef = useRef<Set<string>>(new Set()); const removingRef = useRef<Set<string>>(new Set());
// Read the latest reminders / DM map / notification prefs via refs so the poll // Read the latest reminders / DM map via refs so the poll interval below is
// interval below is created once — not torn down and restarted (which resets // created once — not torn down and restarted (which resets its 30s countdown
// its 30s countdown and can indefinitely defer a near-due reminder) on every // and can indefinitely defer a near-due reminder) on every reminder sync (N115).
// reminder sync (N115).
const remindersRef = useRef(reminders); const remindersRef = useRef(reminders);
remindersRef.current = reminders; remindersRef.current = reminders;
const mDirectsRef = useRef(mDirects); const mDirectsRef = useRef(mDirects);
mDirectsRef.current = mDirects; mDirectsRef.current = mDirects;
const osNotifyRef = useRef(false);
osNotifyRef.current = showNotifications && !quietActive;
useEffect(() => { useEffect(() => {
const check = () => { const check = () => {
@@ -820,23 +815,6 @@ function ReminderMonitor() {
const hashPath = mDirectsRef.current.has(r.roomId) const hashPath = mDirectsRef.current.has(r.roomId)
? getDirectRoomPath(r.roomId, r.eventId) ? getDirectRoomPath(r.roomId, r.eventId)
: getHomeRoomPath(r.roomId, r.eventId); : getHomeRoomPath(r.roomId, r.eventId);
// [Gitea #338] A reminder is only done once the user has SEEN it: the
// toast stays until clicked or dismissed (it used to vanish after 4 s
// and the reminder was deleted at once, so one due while the user was
// away was lost). The removal is persisted on that click/dismiss; an
// unseen reminder comes back on the next start.
const done = () => {
if (removingRef.current.has(key)) return;
removingRef.current.add(key);
removeReminder(r.eventId, r.timestamp).catch(() => {
removingRef.current.delete(key);
});
};
const open = () => {
done();
// window.location.hash setter auto-prepends '#' (as the toast does)
window.location.hash = hashPath;
};
setToast({ setToast({
id: `reminder-${key}`, id: `reminder-${key}`,
displayName: 'Reminder', displayName: 'Reminder',
@@ -844,30 +822,16 @@ function ReminderMonitor() {
roomName, roomName,
roomId: r.roomId, roomId: r.roomId,
hashPath, hashPath,
sticky: true,
onClick: open,
onDismiss: done,
}); });
// Away from the window: also raise an OS notification (the reminder is }
// an explicit request, but quiet hours / notifications-off win). Never // Persist the removal, retrying on a later tick if it fails — without
// put decrypted E2EE text in it (N106). // re-showing the toast (N114). The server echo drops it from
if (osNotifyRef.current && notificationPermission('granted') && !document.hasFocus()) { // `reminders` once the write lands.
const encrypted = !!room?.hasEncryptionStateEvent(); if (!removingRef.current.has(key)) {
showOsNotification( removingRef.current.add(key);
'Reminder', removeReminder(r.eventId, r.timestamp).catch(() => {
{ removingRef.current.delete(key);
icon: LogoSVG, });
badge: LogoSVG,
body: encrypted ? `A message in ${roomName}` : body,
tag: `reminder-${key}`,
data: { path: hashPath, roomId: r.roomId },
},
() => {
window.focus();
open();
},
).catch(() => undefined);
}
} }
}); });
}; };