From c8576cbb1feb15914a4658f8c9b3e7eb7fdc15ba Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:37:21 -0400 Subject: [PATCH] fix(status): any Lotus device clears an auto-expiring status, not just the one that set it (#300) "Clear after 1 hour" lived in that device's localStorage, so a status set on a laptop that was then closed stayed on the server until the laptop opened Lotus again. The expiry is now also kept in account data (io.lotus.status_expiry: { status_msg, expires_at }). Every device's StatusExpiryMonitor clears the status when due, but only if it is still the one that was set to expire (otherwise it just forgets the expiry). Fixes #300 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/features/settings/account/Profile.tsx | 10 +++- src/app/pages/client/ClientNonUIFeatures.tsx | 27 ++++++++--- src/app/utils/statusExpiry.test.ts | 22 +++++++++ src/app/utils/statusExpiry.ts | 46 +++++++++++++++++++ src/types/matrix/accountData.ts | 4 ++ 5 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 src/app/utils/statusExpiry.test.ts create mode 100644 src/app/utils/statusExpiry.ts diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 0fa983ce9..ce8728e5d 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -67,6 +67,7 @@ import { makePresetId, normalizeLabel, } from '../../../utils/statusPresets'; +import { writeStatusExpiry } from '../../../utils/statusExpiry'; type ProfileProps = { profile: UserProfile; @@ -508,12 +509,16 @@ function ProfileStatus() { if (msg && delayMs > 0) { // Persist the expiry timestamp; the always-mounted StatusExpiryMonitor // (ClientNonUIFeatures) fires the auto-clear even when Settings is closed. - localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(Date.now() + delayMs)); + const expiresAt = Date.now() + delayMs; + localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(expiresAt)); + // [Gitea #300] โ€ฆand on every other Lotus device, via account data. + writeStatusExpiry(mx, msg, expiresAt).catch(() => undefined); } else { localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); + writeStatusExpiry(mx, msg).catch(() => undefined); } }, - [saveStatus, userId], + [saveStatus, userId, mx], ); const handleSubmit: FormEventHandler = (evt) => { @@ -553,6 +558,7 @@ function ProfileStatus() { setStatusMsg(''); localStorage.removeItem(STATUS_MSG_KEY(userId)); localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); + writeStatusExpiry(mx, '').catch(() => undefined); // Preserve the user's chosen presence when clearing the status message. mx.setPresence({ presence: presenceStateFromSetting(presenceStatus, hidePresence), diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 6bf40cbe2..80fdb2a27 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -91,6 +91,7 @@ import { shouldNotifyThreadReply, THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR, } from '../../utils/threadNotifications'; +import { readStatusExpiry, statusExpiryAction, writeStatusExpiry } from '../../utils/statusExpiry'; // Grace period after the initial sync settles before invite notifications arm, so // the async invite-atom population lands first and isn't mistaken for new invites. @@ -427,12 +428,7 @@ function StatusExpiryMonitor() { // permanently and the status stayed set forever. Retry the write (honouring // Retry-After), and only forget the status once the server has taken it. let clearing = false; - const check = () => { - if (clearing) return; - const stored = localStorage.getItem(expiryKey); - if (!stored) return; - const ts = parseInt(stored, 10); - if (!ts || Date.now() < ts) return; + const clearStatus = () => { clearing = true; setPresenceWithRetry(mx, { presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current), @@ -442,12 +438,31 @@ function StatusExpiryMonitor() { noteLocalStatusWrite(); localStorage.removeItem(msgKey); localStorage.removeItem(expiryKey); + writeStatusExpiry(mx, '').catch(() => undefined); }) .catch(() => undefined) .finally(() => { clearing = false; }); }; + const check = () => { + if (clearing) return; + const stored = localStorage.getItem(expiryKey); + const ts = stored ? parseInt(stored, 10) : 0; + if (ts && Date.now() >= ts) { + clearStatus(); + return; + } + // [Gitea #300] An expiry set on another device (account data): clear the + // status if it is still that one, otherwise just forget the expiry. + const action = statusExpiryAction( + readStatusExpiry(mx), + mx.getUser(userId)?.presenceStatusMsg, + Date.now(), + ); + if (action === 'clear') clearStatus(); + else if (action === 'forget') writeStatusExpiry(mx, '').catch(() => undefined); + }; check(); // Poll often enough that a rate-limited retry lands promptly, but not so diff --git a/src/app/utils/statusExpiry.test.ts b/src/app/utils/statusExpiry.test.ts new file mode 100644 index 000000000..0f5af329b --- /dev/null +++ b/src/app/utils/statusExpiry.test.ts @@ -0,0 +1,22 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseStatusExpiry, statusExpiryAction } from './statusExpiry'; + +test('parseStatusExpiry (#300)', () => { + assert.deepEqual(parseStatusExpiry({ status_msg: '๐Ÿ” Lunch', expires_at: 5 }), { + statusMsg: '๐Ÿ” Lunch', + expiresAt: 5, + }); + assert.equal(parseStatusExpiry({}), undefined); + assert.equal(parseStatusExpiry({ status_msg: '', expires_at: 5 }), undefined); + assert.equal(parseStatusExpiry({ status_msg: 'x', expires_at: '5' }), undefined); +}); + +test('statusExpiryAction: clears only the status that was set to expire (#300)', () => { + const e = { statusMsg: '๐Ÿ” Lunch', expiresAt: 1000 }; + assert.equal(statusExpiryAction(e, '๐Ÿ” Lunch', 999), 'none'); + assert.equal(statusExpiryAction(e, '๐Ÿ” Lunch', 1000), 'clear'); + assert.equal(statusExpiryAction(e, '๐ŸŽฎ Gaming', 2000), 'forget'); + assert.equal(statusExpiryAction(e, '', 2000), 'forget'); + assert.equal(statusExpiryAction(undefined, 'x', 2000), 'none'); +}); diff --git a/src/app/utils/statusExpiry.ts b/src/app/utils/statusExpiry.ts new file mode 100644 index 000000000..73ad15ed0 --- /dev/null +++ b/src/app/utils/statusExpiry.ts @@ -0,0 +1,46 @@ +import { MatrixClient } from 'matrix-js-sdk'; +import { AccountDataEvent } from '../../types/matrix/accountData'; +import { getAccountData } from './room'; + +/** + * [Gitea #300] The status message's auto-clear time, in account data so every + * Lotus device knows it. Before, only the device that set "clear after 1 hour" + * (localStorage) ever cleared it, and only while it was open. + */ +export type StatusExpiry = { statusMsg: string; expiresAt: number }; + +/** Pure: parse `io.lotus.status_expiry` content. */ +export function parseStatusExpiry(content: unknown): StatusExpiry | undefined { + const c = content as { status_msg?: unknown; expires_at?: unknown } | undefined; + if (!c || typeof c.status_msg !== 'string' || typeof c.expires_at !== 'number') return undefined; + if (!c.status_msg || !Number.isFinite(c.expires_at)) return undefined; + return { statusMsg: c.status_msg, expiresAt: c.expires_at }; +} + +/** + * Pure: what to do with a due expiry. Clear the server status only if it is + * still the one that was set to expire; if it has since changed (e.g. on + * another device), just forget the expiry. + */ +export function statusExpiryAction( + entry: StatusExpiry | undefined, + currentStatusMsg: string | undefined, + now: number, +): 'none' | 'clear' | 'forget' { + if (!entry || now < entry.expiresAt) return 'none'; + return (currentStatusMsg ?? '') === entry.statusMsg ? 'clear' : 'forget'; +} + +export const readStatusExpiry = (mx: MatrixClient): StatusExpiry | undefined => + parseStatusExpiry(getAccountData(mx, AccountDataEvent.LotusStatusExpiry)?.getContent()); + +/** Record (or with no `expiresAt`, remove) the shared auto-clear time. */ +export async function writeStatusExpiry( + mx: MatrixClient, + statusMsg: string, + expiresAt?: number, +): Promise { + const content = + statusMsg && expiresAt !== undefined ? { status_msg: statusMsg, expires_at: expiresAt } : {}; + await mx.setAccountData(AccountDataEvent.LotusStatusExpiry as never, content as never); +} diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 472d0bd40..eb9c6a286 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -26,6 +26,10 @@ export enum AccountDataEvent { // [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync). LotusSettings = 'io.lotus.settings', + // [Gitea #300] When the status message auto-clears: { status_msg, expires_at }, + // so any Lotus device can clear it (not only the one that set it). + LotusStatusExpiry = 'io.lotus.status_expiry', + SecretStorageDefaultKey = 'm.secret_storage.default_key', CrossSigningMaster = 'm.cross_signing.master',