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
// page and is the correct mechanism for an authed endpoint. (keepalive bodies are
// 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`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ presence: 'offline' }),
body: JSON.stringify({ presence: 'offline', ...(status ? { status_msg: status } : {}) }),
keepalive: true,
}).catch((err) => warnPresenceFailure('offline (pagehide)', err));
};
+12 -48
View File
@@ -783,20 +783,15 @@ function ReminderMonitor() {
const { reminders, removeReminder } = useReminders();
const setToast = useSetAtom(toastQueueAtom);
const mDirects = useAtomValue(mDirectAtom);
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const quietActive = useNotificationsQuiet();
const firedRef = 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
// interval below is created once — not torn down and restarted (which resets
// its 30s countdown and can indefinitely defer a near-due reminder) on every
// reminder sync (N115).
// Read the latest reminders / DM map via refs so the poll interval below is
// created once — not torn down and restarted (which resets its 30s countdown
// and can indefinitely defer a near-due reminder) on every reminder sync (N115).
const remindersRef = useRef(reminders);
remindersRef.current = reminders;
const mDirectsRef = useRef(mDirects);
mDirectsRef.current = mDirects;
const osNotifyRef = useRef(false);
osNotifyRef.current = showNotifications && !quietActive;
useEffect(() => {
const check = () => {
@@ -820,23 +815,6 @@ function ReminderMonitor() {
const hashPath = mDirectsRef.current.has(r.roomId)
? getDirectRoomPath(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({
id: `reminder-${key}`,
displayName: 'Reminder',
@@ -844,30 +822,16 @@ function ReminderMonitor() {
roomName,
roomId: r.roomId,
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
// put decrypted E2EE text in it (N106).
if (osNotifyRef.current && notificationPermission('granted') && !document.hasFocus()) {
const encrypted = !!room?.hasEncryptionStateEvent();
showOsNotification(
'Reminder',
{
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);
}
}
// Persist the removal, retrying on a later tick if it fails — without
// re-showing the toast (N114). The server echo drops it from
// `reminders` once the write lands.
if (!removingRef.current.has(key)) {
removingRef.current.add(key);
removeReminder(r.eventId, r.timestamp).catch(() => {
removingRef.current.delete(key);
});
}
});
};