Merge pull request 'fix(status): any Lotus device clears an auto-expiring status, not just the one that set it (#300)' (#394) from fix/status-expiry-account-data into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
This commit was merged in pull request #394.
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
@@ -92,6 +92,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';
|
||||||
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||||
|
|
||||||
// Grace period after the initial sync settles before invite notifications arm, so
|
// 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
|
// 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),
|
||||||
@@ -451,12 +447,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,9 @@ 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',
|
||||||
// [Gitea #333] Timed mutes: { timers: { [roomId]: unmuteAt } }, so any Lotus
|
// [Gitea #333] Timed mutes: { timers: { [roomId]: unmuteAt } }, so any Lotus
|
||||||
// device can lift a mute set on another.
|
// device can lift a mute set on another.
|
||||||
LotusMuteTimers = 'io.lotus.mute_timers',
|
LotusMuteTimers = 'io.lotus.mute_timers',
|
||||||
|
|||||||
Reference in New Issue
Block a user