Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b940a6df7 | ||
|
|
46da76c21b | ||
|
|
a20accd446 | ||
|
|
080831d4ea | ||
|
|
8b6464c8b9 | ||
|
|
ca284a75e3 | ||
|
|
179be38e2c | ||
|
|
b07eb35159 | ||
|
|
e4b2405a19 | ||
|
|
f41de57893 | ||
|
|
c8576cbb1f |
@@ -33,16 +33,9 @@ import { About } from './about';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||
import { SettingsPages } from './pages';
|
||||
|
||||
export enum SettingsPages {
|
||||
GeneralPage,
|
||||
AccountPage,
|
||||
NotificationPage,
|
||||
DevicesPage,
|
||||
EmojisStickersPage,
|
||||
DeveloperToolsPage,
|
||||
AboutPage,
|
||||
}
|
||||
export { SettingsPages };
|
||||
|
||||
type SettingsMenuItem = {
|
||||
page: SettingsPages;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { Box, Spinner, config } from 'folds';
|
||||
import type { SettingsPages } from './pages';
|
||||
|
||||
// Settings is a large, rarely-open dialog (all its pages, pickers, the
|
||||
// denoise tester…). Load it on first open instead of in the entry chunk, and
|
||||
// warm the chunk once the app is idle so opening it rarely waits.
|
||||
const loadSettings = () => import('./Settings');
|
||||
const SettingsDialog = lazy(() => loadSettings().then((m) => ({ default: m.Settings })));
|
||||
if (typeof window !== 'undefined') {
|
||||
const warm = () => {
|
||||
loadSettings().catch(() => undefined);
|
||||
};
|
||||
if ('requestIdleCallback' in window) window.requestIdleCallback(warm, { timeout: 10_000 });
|
||||
else setTimeout(warm, 5_000);
|
||||
}
|
||||
|
||||
type SettingsProps = {
|
||||
initialPage?: SettingsPages;
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
export function Settings(props: SettingsProps) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
// Settings opens inside a FocusTrap, which needs a tabbable node.
|
||||
<Box
|
||||
tabIndex={0}
|
||||
role="status"
|
||||
aria-label="Loading settings"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
style={{ padding: config.space.S700, outline: 'none' }}
|
||||
>
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<SettingsDialog {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,8 @@ import {
|
||||
makePresetId,
|
||||
normalizeLabel,
|
||||
} from '../../../utils/statusPresets';
|
||||
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../../utils/statusKeys';
|
||||
import { writeStatusExpiry } from '../../../utils/statusExpiry';
|
||||
|
||||
type ProfileProps = {
|
||||
profile: UserProfile;
|
||||
@@ -365,8 +367,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
||||
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
|
||||
export { STATUS_EXPIRY_KEY, STATUS_MSG_KEY };
|
||||
|
||||
const CLEAR_AFTER_OPTIONS = [
|
||||
{ label: 'Never', value: '0' },
|
||||
@@ -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<HTMLFormElement> = (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),
|
||||
|
||||
@@ -108,6 +108,7 @@ import {
|
||||
TRANSLATE_TARGET_LANGUAGES,
|
||||
normalizeLang,
|
||||
isSupportedTargetLang,
|
||||
translationUnavailableText,
|
||||
} from '../../../utils/translation/langUtils';
|
||||
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
@@ -2756,7 +2757,7 @@ function Messages() {
|
||||
description={
|
||||
translationSupported
|
||||
? 'Translate messages written in other languages into this one. Runs entirely on your device — message text never leaves your computer or touches a cloud service.'
|
||||
: 'On-device translation isn’t available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
|
||||
: translationUnavailableText(isTauriEnv())
|
||||
}
|
||||
after={
|
||||
<SettingsSelect
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './Settings';
|
||||
export { SettingsPages } from './pages';
|
||||
export { Settings } from './SettingsLazy';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Settings dialog pages. Kept apart from Settings.tsx so callers can name a
|
||||
* page without pulling the dialog into the entry chunk. */
|
||||
export enum SettingsPages {
|
||||
GeneralPage,
|
||||
AccountPage,
|
||||
NotificationPage,
|
||||
DevicesPage,
|
||||
EmojisStickersPage,
|
||||
DeveloperToolsPage,
|
||||
AboutPage,
|
||||
}
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
migrateLocalMuteTimers,
|
||||
unmuteRoom,
|
||||
} from '../../features/room-nav/muteTimers';
|
||||
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
|
||||
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../utils/statusKeys';
|
||||
import { noteLocalStatusWrite } from '../../utils/ownStatusSync';
|
||||
import { setPresenceWithRetry } from '../../utils/presenceWrite';
|
||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** localStorage keys for this device's copy of the status message and its expiry. */
|
||||
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
||||
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
translationUnavailableText,
|
||||
normalizeLang,
|
||||
sameLanguage,
|
||||
languageName,
|
||||
@@ -53,3 +54,9 @@ test('curated list: unique codes, English present', () => {
|
||||
assert.equal(new Set(codes).size, codes.length);
|
||||
assert.ok(codes.includes('en'));
|
||||
});
|
||||
|
||||
test('translationUnavailableText: no "use the desktop app" inside the desktop app (#349)', () => {
|
||||
assert.doesNotMatch(translationUnavailableText(true), /desktop app\.$|Lotus desktop app/);
|
||||
assert.match(translationUnavailableText(true), /this version of the desktop app/);
|
||||
assert.match(translationUnavailableText(false), /this browser/);
|
||||
});
|
||||
|
||||
@@ -86,6 +86,16 @@ export function isSupportedTargetLang(code: string): boolean {
|
||||
return CURATED_NAME.has(normalizeLang(code));
|
||||
}
|
||||
|
||||
/**
|
||||
* [Gitea #349] Why translation is unavailable, by platform. The desktop app
|
||||
* on Linux/macOS (WebKit) lacks the on-device APIs too, so it must not send
|
||||
* people to "the Lotus desktop app" they're already using.
|
||||
*/
|
||||
export const translationUnavailableText = (desktopApp: boolean): string =>
|
||||
desktopApp
|
||||
? 'On-device translation isn’t available in this version of the desktop app. It works in Chrome or Edge 138+ on a computer.'
|
||||
: 'On-device translation isn’t available in this browser. Use Chrome or Edge 138+ on a computer.';
|
||||
|
||||
/** Pure: why a manual Translate did nothing (#287). */
|
||||
export const translateSkipMessage = (from: string | undefined, targetLang: string): string =>
|
||||
from
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -251,6 +251,25 @@ const vendorChunks = (id) => {
|
||||
if (id.includes('node_modules/immer')) return 'immer';
|
||||
if (id.includes('node_modules/folds')) return 'folds';
|
||||
if (id.includes('node_modules/emojibase')) return 'emojibase';
|
||||
// HTML sanitising (sanitize-html pulls in postcss + htmlparser2) and colour
|
||||
// maths are needed at startup but are big, stable libraries: keep them in
|
||||
// their own cacheable chunks instead of wherever the chunk graph drops them
|
||||
// (once Settings became lazy they landed in the entry chunk).
|
||||
if (
|
||||
id.includes('node_modules/sanitize-html') ||
|
||||
id.includes('node_modules/postcss') ||
|
||||
id.includes('node_modules/htmlparser2') ||
|
||||
id.includes('node_modules/domhandler') ||
|
||||
id.includes('node_modules/domutils') ||
|
||||
id.includes('node_modules/dom-serializer') ||
|
||||
id.includes('node_modules/entities') ||
|
||||
id.includes('node_modules/parse-srcset') ||
|
||||
id.includes('node_modules/nanoid') ||
|
||||
id.includes('node_modules/picocolors') ||
|
||||
id.includes('node_modules/source-map-js')
|
||||
)
|
||||
return 'sanitize-html';
|
||||
if (id.includes('node_modules/chroma-js')) return 'chroma';
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
Reference in New Issue
Block a user