fix(status): any Lotus device clears an auto-expiring status, not just the one that set it (#300)
CI / Build & Quality Checks (pull_request) Successful in 3m2s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 25m12s
CI / Build & Quality Checks (pull_request) Successful in 3m2s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 25m12s
"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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3878a85339
commit
c8576cbb1f
@@ -67,6 +67,7 @@ import {
|
|||||||
makePresetId,
|
makePresetId,
|
||||||
normalizeLabel,
|
normalizeLabel,
|
||||||
} from '../../../utils/statusPresets';
|
} from '../../../utils/statusPresets';
|
||||||
|
import { writeStatusExpiry } from '../../../utils/statusExpiry';
|
||||||
|
|
||||||
type ProfileProps = {
|
type ProfileProps = {
|
||||||
profile: UserProfile;
|
profile: UserProfile;
|
||||||
@@ -508,12 +509,16 @@ function ProfileStatus() {
|
|||||||
if (msg && delayMs > 0) {
|
if (msg && delayMs > 0) {
|
||||||
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
|
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
|
||||||
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
|
// (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 {
|
} else {
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||||
|
writeStatusExpiry(mx, msg).catch(() => undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[saveStatus, userId],
|
[saveStatus, userId, mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
@@ -553,6 +558,7 @@ function ProfileStatus() {
|
|||||||
setStatusMsg('');
|
setStatusMsg('');
|
||||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||||
|
writeStatusExpiry(mx, '').catch(() => undefined);
|
||||||
// Preserve the user's chosen presence when clearing the status message.
|
// Preserve the user's chosen presence when clearing the status message.
|
||||||
mx.setPresence({
|
mx.setPresence({
|
||||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ import {
|
|||||||
shouldNotifyThreadReply,
|
shouldNotifyThreadReply,
|
||||||
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
||||||
} from '../../utils/threadNotifications';
|
} from '../../utils/threadNotifications';
|
||||||
|
import { readStatusExpiry, statusExpiryAction, writeStatusExpiry } from '../../utils/statusExpiry';
|
||||||
|
|
||||||
// Grace period after the initial sync settles before invite notifications arm, so
|
// 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.
|
// 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
|
// permanently and the status stayed set forever. Retry the write (honouring
|
||||||
// Retry-After), and only forget the status once the server has taken it.
|
// Retry-After), and only forget the status once the server has taken it.
|
||||||
let clearing = false;
|
let clearing = false;
|
||||||
const check = () => {
|
const clearStatus = () => {
|
||||||
if (clearing) return;
|
|
||||||
const stored = localStorage.getItem(expiryKey);
|
|
||||||
if (!stored) return;
|
|
||||||
const ts = parseInt(stored, 10);
|
|
||||||
if (!ts || Date.now() < ts) return;
|
|
||||||
clearing = true;
|
clearing = true;
|
||||||
setPresenceWithRetry(mx, {
|
setPresenceWithRetry(mx, {
|
||||||
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
|
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
|
||||||
@@ -442,12 +438,31 @@ function StatusExpiryMonitor() {
|
|||||||
noteLocalStatusWrite();
|
noteLocalStatusWrite();
|
||||||
localStorage.removeItem(msgKey);
|
localStorage.removeItem(msgKey);
|
||||||
localStorage.removeItem(expiryKey);
|
localStorage.removeItem(expiryKey);
|
||||||
|
writeStatusExpiry(mx, '').catch(() => undefined);
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
clearing = false;
|
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();
|
check();
|
||||||
// Poll often enough that a rate-limited retry lands promptly, but not so
|
// Poll often enough that a rate-limited retry lands promptly, but not so
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
const content =
|
||||||
|
statusMsg && expiresAt !== undefined ? { status_msg: statusMsg, expires_at: expiresAt } : {};
|
||||||
|
await mx.setAccountData(AccountDataEvent.LotusStatusExpiry as never, content as never);
|
||||||
|
}
|
||||||
@@ -26,6 +26,10 @@ export enum AccountDataEvent {
|
|||||||
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
|
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
|
||||||
LotusSettings = 'io.lotus.settings',
|
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',
|
SecretStorageDefaultKey = 'm.secret_storage.default_key',
|
||||||
|
|
||||||
CrossSigningMaster = 'm.cross_signing.master',
|
CrossSigningMaster = 'm.cross_signing.master',
|
||||||
|
|||||||
Reference in New Issue
Block a user