Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec47d542dd | ||
|
|
674aef94fb |
@@ -12,6 +12,9 @@ regexes = [
|
|||||||
'''https?://matrix\.lotusguild\.org''',
|
'''https?://matrix\.lotusguild\.org''',
|
||||||
'''https?://code\.lotusguild\.org/api/packages/LotusGuild/npm/''',
|
'''https?://code\.lotusguild\.org/api/packages/LotusGuild/npm/''',
|
||||||
'''matrix\.lotusguild\.org''',
|
'''matrix\.lotusguild\.org''',
|
||||||
|
# Lotus Chat desktop updater's minisign PUBLIC key (published in the
|
||||||
|
# cinny-desktop README); utils/updateErrors.ts verifies Linux downloads with it.
|
||||||
|
'''RWRZZdyii05/NUcz337A5SAbiZK\+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l''',
|
||||||
]
|
]
|
||||||
paths = [
|
paths = [
|
||||||
'''config\.json''',
|
'''config\.json''',
|
||||||
|
|||||||
@@ -90,9 +90,6 @@ import { useDenoiseNotices } from '../hooks/useDenoiseNotices';
|
|||||||
|
|
||||||
const PIP_MIN_W = 200;
|
const PIP_MIN_W = 200;
|
||||||
const PIP_MIN_H = 112;
|
const PIP_MIN_H = 112;
|
||||||
/** [Gitea #323] Minimised PiP bar size. */
|
|
||||||
const PIP_MINI_W = 220;
|
|
||||||
const PIP_MINI_H = 40;
|
|
||||||
/** Default PiP top: just below the 64px-ish room header. */
|
/** Default PiP top: just below the 64px-ish room header. */
|
||||||
const PIP_DEFAULT_TOP = 72;
|
const PIP_DEFAULT_TOP = 72;
|
||||||
|
|
||||||
@@ -1006,28 +1003,6 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
|
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
|
||||||
// [Gitea #323] Minimised PiP: a small draggable bar (room name + expand)
|
|
||||||
// instead of the video. Remembered; phones start minimised.
|
|
||||||
const [pipMinimized, setPipMinimized] = useState<boolean>(() => {
|
|
||||||
try {
|
|
||||||
const v = localStorage.getItem('pip-minimized');
|
|
||||||
if (v !== null) return v === '1';
|
|
||||||
} catch {
|
|
||||||
/* storage unavailable */
|
|
||||||
}
|
|
||||||
return window.innerWidth < 600;
|
|
||||||
});
|
|
||||||
const pipFullSizeRef = React.useRef({ width: 280, height: 158 });
|
|
||||||
const togglePipMinimized = useCallback(() => {
|
|
||||||
setPipMinimized((m) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('pip-minimized', m ? '0' : '1');
|
|
||||||
} catch {
|
|
||||||
/* storage unavailable */
|
|
||||||
}
|
|
||||||
return !m;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
|
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
|
||||||
document.addEventListener('fullscreenchange', onFsChange);
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
@@ -1128,26 +1103,6 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
}
|
}
|
||||||
}, [pipMode, callVisible]);
|
}, [pipMode, callVisible]);
|
||||||
|
|
||||||
// [Gitea #323] Apply the minimised / full PiP size (after the entry effect
|
|
||||||
// below has positioned and sized it). Keeps the bar on screen.
|
|
||||||
React.useEffect(() => {
|
|
||||||
const el = callEmbedRef.current;
|
|
||||||
if (!el || !pipMode) return;
|
|
||||||
if (pipMinimized) {
|
|
||||||
if (el.offsetHeight > PIP_MINI_H) {
|
|
||||||
pipFullSizeRef.current = { width: el.offsetWidth, height: el.offsetHeight };
|
|
||||||
}
|
|
||||||
el.style.width = `${PIP_MINI_W}px`;
|
|
||||||
el.style.height = `${PIP_MINI_H}px`;
|
|
||||||
} else {
|
|
||||||
el.style.width = `${pipFullSizeRef.current.width}px`;
|
|
||||||
el.style.height = `${pipFullSizeRef.current.height}px`;
|
|
||||||
}
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
el.style.left = `${Math.max(0, Math.min(rect.left, window.innerWidth - el.offsetWidth))}px`;
|
|
||||||
el.style.top = `${Math.max(0, Math.min(rect.top, window.innerHeight - el.offsetHeight))}px`;
|
|
||||||
}, [pipMode, pipMinimized, callEmbedRef]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!pipMode) return;
|
if (!pipMode) return;
|
||||||
const onPipWindowResize = (): void => {
|
const onPipWindowResize = (): void => {
|
||||||
@@ -1466,50 +1421,19 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
// Minimised: cover the frame so only the bar shows.
|
background: 'transparent',
|
||||||
background: pipMinimized ? color.Surface.Container : 'transparent',
|
|
||||||
cursor: 'grab',
|
cursor: 'grab',
|
||||||
// [Gitea #43] The call's "Share your screen?" prompt is drawn
|
// [Gitea #43] The call's "Share your screen?" prompt is drawn
|
||||||
// inside the frame: let the click reach its Share button.
|
// inside the frame: let the click reach its Share button.
|
||||||
pointerEvents: pipPromptOpen ? 'none' : undefined,
|
pointerEvents: pipPromptOpen ? 'none' : undefined,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
alignItems: pipMinimized ? 'center' : 'flex-start',
|
|
||||||
padding: '6px',
|
padding: '6px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
||||||
style={{
|
{document.fullscreenEnabled && (
|
||||||
display: 'flex',
|
|
||||||
gap: config.space.S100,
|
|
||||||
alignItems: 'center',
|
|
||||||
...(pipMinimized ? { width: '100%' } : {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Leftmost: the top-right corner is a resize handle. */}
|
|
||||||
<IconButton
|
|
||||||
type="button"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
variant="Surface"
|
|
||||||
fill="None"
|
|
||||||
aria-label={pipMinimized ? 'Expand call' : 'Minimise call'}
|
|
||||||
aria-pressed={pipMinimized}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
togglePipMinimized();
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onTouchStart={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
background: 'rgba(0,0,0,0.65)',
|
|
||||||
backdropFilter: 'blur(4px)',
|
|
||||||
color: '#fff',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon size="100" src={pipMinimized ? Icons.ChevronTop : Icons.Minus} />
|
|
||||||
</IconButton>
|
|
||||||
{!pipMinimized && document.fullscreenEnabled && (
|
|
||||||
<IconButton
|
<IconButton
|
||||||
type="button"
|
type="button"
|
||||||
size="300"
|
size="300"
|
||||||
@@ -1541,23 +1465,14 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
...(pipMinimized
|
|
||||||
? {
|
|
||||||
flexGrow: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{pipMinimized ? `↗ ${callEmbed.room.name ?? 'Call'}` : '↗ Return to call'}
|
↗ Return to call
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!pipMinimized && <PipMuteOverlay callEmbed={callEmbed} />}
|
<PipMuteOverlay callEmbed={callEmbed} />
|
||||||
{(pipMinimized ? [] : (['se', 'sw', 'ne', 'nw'] as Corner[])).map((corner) => {
|
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
|
||||||
const s = corner.includes('s');
|
const s = corner.includes('s');
|
||||||
const e2 = corner.includes('e');
|
const e2 = corner.includes('e');
|
||||||
const dots = [
|
const dots = [
|
||||||
|
|||||||
@@ -33,9 +33,16 @@ import { About } from './about';
|
|||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||||
import { SettingsPages } from './pages';
|
|
||||||
|
|
||||||
export { SettingsPages };
|
export enum SettingsPages {
|
||||||
|
GeneralPage,
|
||||||
|
AccountPage,
|
||||||
|
NotificationPage,
|
||||||
|
DevicesPage,
|
||||||
|
EmojisStickersPage,
|
||||||
|
DeveloperToolsPage,
|
||||||
|
AboutPage,
|
||||||
|
}
|
||||||
|
|
||||||
type SettingsMenuItem = {
|
type SettingsMenuItem = {
|
||||||
page: SettingsPages;
|
page: SettingsPages;
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
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,8 +67,6 @@ import {
|
|||||||
makePresetId,
|
makePresetId,
|
||||||
normalizeLabel,
|
normalizeLabel,
|
||||||
} from '../../../utils/statusPresets';
|
} from '../../../utils/statusPresets';
|
||||||
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../../utils/statusKeys';
|
|
||||||
import { writeStatusExpiry } from '../../../utils/statusExpiry';
|
|
||||||
|
|
||||||
type ProfileProps = {
|
type ProfileProps = {
|
||||||
profile: UserProfile;
|
profile: UserProfile;
|
||||||
@@ -367,7 +365,8 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { STATUS_EXPIRY_KEY, STATUS_MSG_KEY };
|
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
||||||
|
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
|
||||||
|
|
||||||
const CLEAR_AFTER_OPTIONS = [
|
const CLEAR_AFTER_OPTIONS = [
|
||||||
{ label: 'Never', value: '0' },
|
{ label: 'Never', value: '0' },
|
||||||
@@ -509,16 +508,12 @@ 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.
|
||||||
const expiresAt = Date.now() + delayMs;
|
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(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, mx],
|
[saveStatus, userId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
@@ -558,7 +553,6 @@ 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),
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ import {
|
|||||||
TRANSLATE_TARGET_LANGUAGES,
|
TRANSLATE_TARGET_LANGUAGES,
|
||||||
normalizeLang,
|
normalizeLang,
|
||||||
isSupportedTargetLang,
|
isSupportedTargetLang,
|
||||||
translationUnavailableText,
|
|
||||||
} from '../../../utils/translation/langUtils';
|
} from '../../../utils/translation/langUtils';
|
||||||
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
@@ -2757,7 +2756,7 @@ function Messages() {
|
|||||||
description={
|
description={
|
||||||
translationSupported
|
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.'
|
? '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.'
|
||||||
: translationUnavailableText(isTauriEnv())
|
: 'On-device translation isn’t available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
|
||||||
}
|
}
|
||||||
after={
|
after={
|
||||||
<SettingsSelect
|
<SettingsSelect
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export { SettingsPages } from './pages';
|
export * from './Settings';
|
||||||
export { Settings } from './SettingsLazy';
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
/** 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,
|
migrateLocalMuteTimers,
|
||||||
unmuteRoom,
|
unmuteRoom,
|
||||||
} from '../../features/room-nav/muteTimers';
|
} from '../../features/room-nav/muteTimers';
|
||||||
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../utils/statusKeys';
|
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
|
||||||
import { noteLocalStatusWrite } from '../../utils/ownStatusSync';
|
import { noteLocalStatusWrite } from '../../utils/ownStatusSync';
|
||||||
import { setPresenceWithRetry } from '../../utils/presenceWrite';
|
import { setPresenceWithRetry } from '../../utils/presenceWrite';
|
||||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||||
@@ -92,7 +92,6 @@ 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
|
||||||
@@ -437,7 +436,12 @@ 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 clearStatus = () => {
|
const check = () => {
|
||||||
|
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),
|
||||||
@@ -447,31 +451,12 @@ 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
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
/** 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,7 +1,6 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
translationUnavailableText,
|
|
||||||
normalizeLang,
|
normalizeLang,
|
||||||
sameLanguage,
|
sameLanguage,
|
||||||
languageName,
|
languageName,
|
||||||
@@ -54,9 +53,3 @@ test('curated list: unique codes, English present', () => {
|
|||||||
assert.equal(new Set(codes).size, codes.length);
|
assert.equal(new Set(codes).size, codes.length);
|
||||||
assert.ok(codes.includes('en'));
|
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,16 +86,6 @@ export function isSupportedTargetLang(code: string): boolean {
|
|||||||
return CURATED_NAME.has(normalizeLang(code));
|
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). */
|
/** Pure: why a manual Translate did nothing (#287). */
|
||||||
export const translateSkipMessage = (from: string | undefined, targetLang: string): string =>
|
export const translateSkipMessage = (from: string | undefined, targetLang: string): string =>
|
||||||
from
|
from
|
||||||
|
|||||||
@@ -69,13 +69,23 @@ test('install kinds: anything unknown keeps the in-app updater', async () => {
|
|||||||
test('package installs get their package manager’s command, not an Install button', async () => {
|
test('package installs get their package manager’s command, not an Install button', async () => {
|
||||||
const { packageUpdateHelp, isPackageManagedError } = await import('./updateErrors');
|
const { packageUpdateHelp, isPackageManagedError } = await import('./updateErrors');
|
||||||
const pacman = packageUpdateHelp('pacman')!;
|
const pacman = packageUpdateHelp('pacman')!;
|
||||||
// Not `pacman -U <url>`: that also wants `<url>.sig`, which we don't publish.
|
// [cinny-desktop #36] signature, then checksum, then install, in that order;
|
||||||
assert.equal(
|
// a local `pacman -U ./file` (not `<url>`, which also wants `<url>.sig`).
|
||||||
pacman.command,
|
const steps = pacman.command!.split(' && ');
|
||||||
'curl -LO https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.pkg.tar.zst && sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst',
|
assert.equal(steps[0], 'cd "$(mktemp -d)"');
|
||||||
|
assert.match(
|
||||||
|
steps[1],
|
||||||
|
/-fLO \S+\/LotusChat-x86_64\.pkg\.tar\.zst -fLO \S+\/SHA256SUMS\.txt -fLO \S+\/SHA256SUMS\.txt\.minisig$/,
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
steps[2],
|
||||||
|
'minisign -Vm SHA256SUMS.txt -P RWRZZdyii05/NUcz337A5SAbiZK+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l',
|
||||||
|
);
|
||||||
|
assert.equal(steps[3], 'sha256sum -c --ignore-missing SHA256SUMS.txt');
|
||||||
|
assert.equal(steps[4], 'sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst');
|
||||||
assert.match(pacman.url, /LotusChat-x86_64\.pkg\.tar\.zst$/);
|
assert.match(pacman.url, /LotusChat-x86_64\.pkg\.tar\.zst$/);
|
||||||
const deb = packageUpdateHelp('deb')!;
|
const deb = packageUpdateHelp('deb')!;
|
||||||
|
assert.match(deb.command!, /minisign -Vm SHA256SUMS\.txt/);
|
||||||
assert.match(deb.command!, /sudo apt install \.\/LotusChat-x86_64\.deb$/);
|
assert.match(deb.command!, /sudo apt install \.\/LotusChat-x86_64\.deb$/);
|
||||||
assert.equal(packageUpdateHelp('manual')?.command, undefined);
|
assert.equal(packageUpdateHelp('manual')?.command, undefined);
|
||||||
assert.equal(packageUpdateHelp('in-app'), undefined);
|
assert.equal(packageUpdateHelp('in-app'), undefined);
|
||||||
|
|||||||
@@ -64,23 +64,38 @@ export const toInstallKind = (value: unknown): InstallKind =>
|
|||||||
|
|
||||||
export type PackageUpdateHelp = { command?: string; url: string; download: string };
|
export type PackageUpdateHelp = { command?: string; url: string; download: string };
|
||||||
|
|
||||||
|
/** The desktop updater's minisign public key (cinny-desktop tauri.conf.json). */
|
||||||
|
export const RELEASE_MINISIGN_KEY = 'RWRZZdyii05/NUcz337A5SAbiZK+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [cinny-desktop #36] Download a Linux package and install it only after
|
||||||
|
* checking the release's minisign-signed SHA256SUMS.txt. Runs in a fresh temp
|
||||||
|
* directory so a stale SHA256SUMS.txt can't be picked up.
|
||||||
|
*/
|
||||||
|
const verifiedInstall = (file: string, install: string): string =>
|
||||||
|
[
|
||||||
|
`cd "$(mktemp -d)"`,
|
||||||
|
`curl -fLO ${RELEASE_DOWNLOAD}/${file} -fLO ${RELEASE_DOWNLOAD}/SHA256SUMS.txt -fLO ${RELEASE_DOWNLOAD}/SHA256SUMS.txt.minisig`,
|
||||||
|
`minisign -Vm SHA256SUMS.txt -P ${RELEASE_MINISIGN_KEY}`,
|
||||||
|
`sha256sum -c --ignore-missing SHA256SUMS.txt`,
|
||||||
|
`${install} ./${file}`,
|
||||||
|
].join(' && ');
|
||||||
|
|
||||||
export const packageUpdateHelp = (kind: InstallKind): PackageUpdateHelp | undefined => {
|
export const packageUpdateHelp = (kind: InstallKind): PackageUpdateHelp | undefined => {
|
||||||
if (kind === 'pacman') {
|
if (kind === 'pacman') {
|
||||||
const url = `${RELEASE_DOWNLOAD}/LotusChat-x86_64.pkg.tar.zst`;
|
// Install the local file: `pacman -U <url>` also fetches `<url>.sig` and
|
||||||
// Download first, then install the local file: `pacman -U <url>` also
|
// fails without it (packages aren't signed for pacman), while a local file
|
||||||
// fetches `<url>.sig` and fails without it (we don't sign packages),
|
// falls under LocalFileSigLevel. Integrity comes from SHA256SUMS.txt.
|
||||||
// while a local file falls under LocalFileSigLevel (signature optional).
|
|
||||||
return {
|
return {
|
||||||
command: `curl -LO ${url} && sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst`,
|
command: verifiedInstall('LotusChat-x86_64.pkg.tar.zst', 'sudo pacman -U'),
|
||||||
url,
|
url: `${RELEASE_DOWNLOAD}/LotusChat-x86_64.pkg.tar.zst`,
|
||||||
download: 'Download package',
|
download: 'Download package',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (kind === 'deb') {
|
if (kind === 'deb') {
|
||||||
const url = `${RELEASE_DOWNLOAD}/LotusChat-x86_64.deb`;
|
|
||||||
return {
|
return {
|
||||||
command: `curl -LO ${url} && sudo apt install ./LotusChat-x86_64.deb`,
|
command: verifiedInstall('LotusChat-x86_64.deb', 'sudo apt install'),
|
||||||
url,
|
url: `${RELEASE_DOWNLOAD}/LotusChat-x86_64.deb`,
|
||||||
download: 'Download package',
|
download: 'Download package',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ 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',
|
||||||
|
|||||||
@@ -251,25 +251,6 @@ const vendorChunks = (id) => {
|
|||||||
if (id.includes('node_modules/immer')) return 'immer';
|
if (id.includes('node_modules/immer')) return 'immer';
|
||||||
if (id.includes('node_modules/folds')) return 'folds';
|
if (id.includes('node_modules/folds')) return 'folds';
|
||||||
if (id.includes('node_modules/emojibase')) return 'emojibase';
|
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({
|
export default defineConfig({
|
||||||
|
|||||||
Reference in New Issue
Block a user