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 eab713f32..2c1c659c1 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -92,6 +92,7 @@ import { shouldNotifyThreadReply, THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR, } from '../../utils/threadNotifications'; +import { readStatusExpiry, statusExpiryAction, writeStatusExpiry } from '../../utils/statusExpiry'; import { AccountDataEvent } from '../../../types/matrix/accountData'; // Grace period after the initial sync settles before invite notifications arm, so @@ -436,12 +437,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), @@ -451,12 +447,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 74e8dee4a..72f1d0854 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -26,6 +26,9 @@ 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', // [Gitea #333] Timed mutes: { timers: { [roomId]: unmuteAt } }, so any Lotus // device can lift a mute set on another. LotusMuteTimers = 'io.lotus.mute_timers',