Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d15013a27 | ||
|
|
0d4f2e4a87 | ||
|
|
c907b0ba99 | ||
|
|
02c852e194 | ||
|
|
6e6213a43b | ||
|
|
5379eb046d | ||
|
|
17ebddb159 | ||
|
|
7bd1c14a3c | ||
|
|
a58684958c | ||
|
|
3c3bb2e41c | ||
|
|
88446e8953 | ||
|
|
f6bad9eb58 | ||
|
|
93c7dcd749 | ||
|
|
43e166b879 |
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"matrix_rtc_session": {
|
||||
"delayed_leave": { "delay_ms": 60000, "restart_ms": 15000 }
|
||||
}
|
||||
}
|
||||
@@ -109,9 +109,17 @@ test.describe('local homeserver regression', () => {
|
||||
);
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
// Wait for the timeline to settle at the newest message first: a wheel
|
||||
// during the initial jump-to-bottom gets snapped back (test race, seen
|
||||
// ~1 in 5 runs once matrix-js-sdk 44 changed the load timing).
|
||||
await expect(page.getByText('filler 39')).toBeInViewport();
|
||||
await page.mouse.move(600, 350);
|
||||
await page.mouse.wheel(0, -600);
|
||||
await expect(page.getByRole('button', { name: /Jump to Latest/ })).toBeVisible();
|
||||
await expect(async () => {
|
||||
await page.mouse.wheel(0, -600);
|
||||
await expect(page.getByRole('button', { name: /Jump to Latest/ })).toBeVisible({
|
||||
timeout: 2000,
|
||||
});
|
||||
}).toPass({ timeout: 20_000 });
|
||||
await page.locator('[data-slate-editor]').first().click();
|
||||
await page.keyboard.type('sent while scrolled up');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
@@ -11,6 +11,7 @@ import { HS, PASSWORD, api, enc, ensureUser, hsReachable, uniq } from './localHs
|
||||
const AUTH = 'https://auth.fake.test';
|
||||
|
||||
type Fake = {
|
||||
prompts: (string | null)[];
|
||||
registered: number;
|
||||
tokenCalls: { grant: string; refresh?: string }[];
|
||||
revoked: string[];
|
||||
@@ -33,6 +34,7 @@ async function synapseToken(localpart: string, deviceId: string): Promise<string
|
||||
|
||||
async function fakeOAuth(context: BrowserContext, localpart: string): Promise<Fake> {
|
||||
const fake: Fake = {
|
||||
prompts: [],
|
||||
registered: 0,
|
||||
tokenCalls: [],
|
||||
revoked: [],
|
||||
@@ -73,6 +75,7 @@ async function fakeOAuth(context: BrowserContext, localpart: string): Promise<Fa
|
||||
response_modes_supported: ['query', 'fragment'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
prompt_values_supported: ['create'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -107,6 +110,7 @@ async function fakeOAuth(context: BrowserContext, localpart: string): Promise<Fa
|
||||
const deviceId = /urn:matrix:client:device:(\S+)/.exec(
|
||||
url.searchParams.get('scope') ?? '',
|
||||
)?.[1];
|
||||
fake.prompts.push(url.searchParams.get('prompt'));
|
||||
expect(url.searchParams.get('response_mode')).toBe('query');
|
||||
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
const code = `code-${Date.now()}`;
|
||||
@@ -231,4 +235,26 @@ test.describe('OAuth sign-in on SDK 44', () => {
|
||||
expect(fake.revoked.sort()).toEqual([latest.accessToken, latest.refreshToken].sort());
|
||||
expect(await storedSession(page)).toBeNull();
|
||||
});
|
||||
|
||||
test('Register opens the auth server sign-up page (prompt=create)', async ({ context }) => {
|
||||
test.setTimeout(120_000);
|
||||
const user = await ensureUser(uniq('or'));
|
||||
const fake = await fakeOAuth(context, user.localpart);
|
||||
const page = await context.newPage();
|
||||
await page.goto(`/register/${enc(HS)}/`);
|
||||
// Native-OAuth server: no legacy registration form, just the auth server.
|
||||
await expect(page.getByLabel('Username', { exact: true })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: /Continue with/ }).click();
|
||||
await page.waitForURL(/\/home/, { timeout: 60_000 });
|
||||
expect(fake.prompts).toEqual(['create']);
|
||||
});
|
||||
|
||||
test('Login sends no prompt', async ({ context }) => {
|
||||
test.setTimeout(120_000);
|
||||
const user = await ensureUser(uniq('ol'));
|
||||
const fake = await fakeOAuth(context, user.localpart);
|
||||
const page = await context.newPage();
|
||||
await oauthLogin(page);
|
||||
expect(fake.prompts).toEqual([null]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ test.describe('security nudge (#110, #123)', () => {
|
||||
const nudge = strip(page);
|
||||
await expect(nudge).toContainText("Your encryption keys aren't backed up");
|
||||
await nudge.getByRole('button', { name: 'Set up' }).click();
|
||||
await expect(page.getByText('Device Verification').first()).toBeVisible();
|
||||
await expect(page.getByText('Device verification').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { HS, PASSWORD, api, ensureUser, hsReachable, loginUI, uniq } from './localHs';
|
||||
|
||||
// [Gitea #113] Sessions unused for 90+ days: a nudge (after the device-security
|
||||
// one), and Review opens Settings → Devices with them ticked for sign-out.
|
||||
// Synapse won't backdate last-seen, so the session list is served with one
|
||||
// real extra session made 120 days old.
|
||||
const DAY = 86_400_000;
|
||||
|
||||
const strip = (page: Page, text: RegExp) => page.getByRole('status').filter({ hasText: text });
|
||||
|
||||
async function pastGrace(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const { deviceId } = JSON.parse(localStorage.getItem('cinny_session_v1') ?? '{}');
|
||||
localStorage.setItem(
|
||||
`lotus-security-nudge-${deviceId}`,
|
||||
JSON.stringify({ firstSeen: Date.now() - 2 * 86_400_000, dismissals: {} }),
|
||||
);
|
||||
});
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
async function settled(page: Page) {
|
||||
await page
|
||||
.getByText('Connecting...')
|
||||
.first()
|
||||
.waitFor({ timeout: 30_000 })
|
||||
.catch(() => undefined);
|
||||
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
|
||||
}
|
||||
|
||||
test.describe('stale sessions (#113)', () => {
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||
});
|
||||
|
||||
test('nudge after the security one, Review ticks the old session, sign-out works', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const user = await ensureUser(uniq('ss'));
|
||||
// Two extra sessions: one we'll make look 4 months old, one recent.
|
||||
const login = (device: string) =>
|
||||
api<{ device_id: string }>('POST', '/_matrix/client/v3/login', undefined, {
|
||||
type: 'm.login.password',
|
||||
identifier: { type: 'm.id.user', user: user.localpart },
|
||||
password: PASSWORD,
|
||||
device_id: device,
|
||||
initial_device_display_name: device,
|
||||
});
|
||||
await login('OLDLAPTOP');
|
||||
await login('RECENTPHONE');
|
||||
await page.route(/\/_matrix\/client\/v3\/devices(\?.*)?$/, async (route) => {
|
||||
const res = await route.fetch();
|
||||
const body = await res.json();
|
||||
for (const d of body.devices ?? []) {
|
||||
if (d.device_id === 'OLDLAPTOP') d.last_seen_ts = Date.now() - 120 * DAY;
|
||||
else d.last_seen_ts = Date.now() - DAY;
|
||||
}
|
||||
await route.fulfill({ response: res, json: body });
|
||||
});
|
||||
|
||||
await loginUI(page, user);
|
||||
await pastGrace(page);
|
||||
await settled(page);
|
||||
|
||||
// A fresh account has no key backup: that nudge comes first.
|
||||
const security = strip(page, /encryption keys/);
|
||||
await expect(security).toBeVisible({ timeout: 30_000 });
|
||||
await security.getByRole('button', { name: 'Not now' }).click();
|
||||
|
||||
const stale = strip(page, /a session you haven't used in 3 months/);
|
||||
await expect(stale).toBeVisible({ timeout: 30_000 });
|
||||
await stale.getByRole('button', { name: 'Review' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog').first();
|
||||
await expect(dialog.getByText('Inactive for 3+ months')).toHaveCount(1);
|
||||
await expect(dialog.getByText(/Logout from selected devices\. \(1 selected\)/)).toBeVisible();
|
||||
|
||||
// The sticky "Logout from selected devices" bar comes last.
|
||||
await dialog.getByRole('button', { name: 'Logout', exact: true }).last().click();
|
||||
await page.locator('input[name="passwordInput"]').fill(PASSWORD);
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(dialog.getByText(/Logout from selected devices/)).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const { devices } = await api<{ devices: { device_id: string }[] }>(
|
||||
'GET',
|
||||
'/_matrix/client/v3/devices',
|
||||
user.token,
|
||||
);
|
||||
const ids = devices.map((d) => d.device_id);
|
||||
expect(ids).not.toContain('OLDLAPTOP');
|
||||
expect(ids).toContain('RECENTPHONE');
|
||||
expect(HS).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Generated
+4
-4
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.29",
|
||||
"@lotusguild/element-call-embedded": "0.28.0-lotus.2",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
@@ -2695,9 +2695,9 @@
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||
},
|
||||
"node_modules/@lotusguild/element-call-embedded": {
|
||||
"version": "0.25.0-lotus.29",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.29/element-call-embedded-0.25.0-lotus.29.tgz",
|
||||
"integrity": "sha512-IAIEyiOFfkfb1k2JfBNOkX4LOVaGF8LzeKVQYCqKoyKDVao6rYEYA71LON8ntTaxkredXOK4CeLFsnkCiJHuGg==",
|
||||
"version": "0.28.0-lotus.2",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.28.0-lotus.2/element-call-embedded-0.28.0-lotus.2.tgz",
|
||||
"integrity": "sha512-g3xaZ/i3RQ0mToWmjLi08HjSuKD3BpAllj9aEucPS8wh3Uwmz4HXa0tgF1hEN3dPrZR2BTWLQii5Mtfh4037vg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.29",
|
||||
"@lotusguild/element-call-embedded": "0.28.0-lotus.2",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
|
||||
@@ -23,7 +23,12 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
|
||||
|
||||
const [state, load] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const result = await Promise.allSettled([mx.loginFlows(), mx.registerRequest({})]);
|
||||
const result = await Promise.allSettled([
|
||||
mx.loginFlows(),
|
||||
mx.registerRequest({}),
|
||||
// Rejects when the server has no native OAuth: normal for most servers.
|
||||
mx.getAuthMetadata(),
|
||||
]);
|
||||
const loginFlows = promiseFulfilledResult(result[0]);
|
||||
const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined;
|
||||
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
|
||||
@@ -42,6 +47,7 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
|
||||
const authFlows: AuthFlows = {
|
||||
loginFlows,
|
||||
registerFlows,
|
||||
authMetadata: promiseFulfilledResult(result[2]),
|
||||
};
|
||||
|
||||
return authFlows;
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
useCallThemeSync,
|
||||
useCallStart,
|
||||
} from '../hooks/useCallEmbed';
|
||||
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||
import { callChatAtom, callEmbedAtom, immersiveCallAtom } from '../state/callEmbed';
|
||||
import { toastQueueAtom } from '../state/toast';
|
||||
import { CallEmbed, useCallControlState, useScreensharePromptOpen } from '../plugins/call';
|
||||
import { hangupCallAndWait } from '../plugins/call/hangup';
|
||||
@@ -52,6 +52,7 @@ import { useCallEndedToast } from '../hooks/useCallEndedToast';
|
||||
import { useCallRejoin } from '../hooks/useCallRejoin';
|
||||
import { usePttHaptics } from '../hooks/usePttHaptics';
|
||||
import { useScreenshareNotices } from '../hooks/useScreenshareNotices';
|
||||
import { ExpectedDevicesPrompt } from '../features/call/ExpectedDevicesPrompt';
|
||||
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
|
||||
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
|
||||
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
||||
@@ -858,7 +859,14 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
};
|
||||
}, [joined]);
|
||||
|
||||
return null;
|
||||
return (
|
||||
<ExpectedDevicesPrompt
|
||||
embed={embed}
|
||||
onCancel={() => {
|
||||
setCallEmbed(undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -946,6 +954,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
const inCallRoom = callEmbed && selectedRoom === callEmbed.roomId;
|
||||
const callActive = callEmbed && joined;
|
||||
const callVisible = inCallRoom && callActive && !chatOnlyView;
|
||||
const immersiveCall = useAtomValue(immersiveCallAtom);
|
||||
const pipMode = callActive && !inCallRoom;
|
||||
const pipPromptOpen = useScreensharePromptOpen(callEmbed?.control);
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
@@ -1450,7 +1459,13 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
...(pipMode
|
||||
? { background: color.Surface.Container }
|
||||
: callVisible
|
||||
? wallpaperStyle
|
||||
? {
|
||||
// [Gitea #126] Full screen: the frame is transparent, so give
|
||||
// it an opaque base under the wallpaper or the room's
|
||||
// sidebar / call bar show through.
|
||||
...(immersiveCall ? { backgroundColor: color.Background.Container } : {}),
|
||||
...wallpaperStyle,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
ref={callEmbedRef}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
|
||||
import { CallMemberRenderer } from './CallMemberCard';
|
||||
import * as css from './styles.css';
|
||||
import { CallControls } from './CallControls';
|
||||
import { ImmersiveCallOverlay } from './ImmersiveCallOverlay';
|
||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { CallsUnavailableMessage } from './CallsUnavailable';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
@@ -198,10 +199,24 @@ function CallLoadErrorMessage() {
|
||||
type CallJoinedProps = {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
joined: boolean;
|
||||
/** [Gitea #126] Full screen: controls float over the call instead. */
|
||||
onExitImmersive?: () => void;
|
||||
};
|
||||
function CallJoined({ joined, containerRef }: CallJoinedProps) {
|
||||
function CallJoined({ joined, containerRef, onExitImmersive }: CallJoinedProps) {
|
||||
const callEmbed = useCallEmbed();
|
||||
|
||||
if (onExitImmersive) {
|
||||
return (
|
||||
<Box grow="Yes" direction="Column" style={{ position: 'relative' }}>
|
||||
<Box grow="Yes" ref={containerRef} />
|
||||
{callEmbed && joined && (
|
||||
<ImmersiveCallOverlay callEmbed={callEmbed} onExit={onExitImmersive} />
|
||||
)}
|
||||
{callEmbed && joined && <LotusDecorationPusher callEmbed={callEmbed} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Box grow="Yes" ref={containerRef} />
|
||||
@@ -212,7 +227,7 @@ function CallJoined({ joined, containerRef }: CallJoinedProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function CallView() {
|
||||
export function CallView({ onExitImmersive }: { onExitImmersive?: () => void } = {}) {
|
||||
const room = useRoom();
|
||||
const callContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||
useCallEmbedPlacementSync(callContainerRef);
|
||||
@@ -227,6 +242,26 @@ export function CallView() {
|
||||
// made it into the call (a hung iframe / blank spinner otherwise).
|
||||
const showLoadError = isCurrentRoom && !currentJoined && Boolean(loadError);
|
||||
|
||||
if (onExitImmersive && currentJoined) {
|
||||
// [Gitea #126] Cover the screen with a transparent layer above the call
|
||||
// frame: the frame follows the placeholder (useCallEmbedPlacementSync), so
|
||||
// it goes full screen too; the layer only takes touches on its own
|
||||
// tap-catcher / controls, so the call underneath stays usable.
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
paddingTop: 'env(safe-area-inset-top)',
|
||||
}}
|
||||
>
|
||||
<CallJoined joined containerRef={callContainerRef} onExitImmersive={onExitImmersive} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'Surface' })}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
Header,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { CallEmbed } from '../../plugins/call';
|
||||
import {
|
||||
MissingDevice,
|
||||
acceptForSession,
|
||||
describeLost,
|
||||
describeMissing,
|
||||
isAcceptedThisSession,
|
||||
parseDeviceLost,
|
||||
parseDeviceMissing,
|
||||
} from '../../plugins/call/expectedDevices';
|
||||
import { toastQueueAtom } from '../../state/toast';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
|
||||
/**
|
||||
* [Gitea #121] "Your usual headset isn't connected": the fork holds the join
|
||||
* until we answer. Join anyway (not asked again this session for the same
|
||||
* device) or Cancel (closes the call before it starts). A device lost mid-call
|
||||
* is only a toast.
|
||||
*/
|
||||
export function ExpectedDevicesPrompt({
|
||||
embed,
|
||||
onCancel,
|
||||
}: {
|
||||
embed: CallEmbed;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [missing, setMissing] = useState<MissingDevice[]>([]);
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
const modalStyle = useModalStyle(440);
|
||||
|
||||
useEffect(() => {
|
||||
const offMissing = embed.listenAction<{ data?: unknown }>('io.lotus.device_missing', (evt) => {
|
||||
const list = parseDeviceMissing(evt.detail?.data);
|
||||
if (list.length === 0) return;
|
||||
if (isAcceptedThisSession(list)) {
|
||||
embed.control.sendDeviceDecision(true);
|
||||
return;
|
||||
}
|
||||
setMissing(list);
|
||||
});
|
||||
const offLost = embed.listenAction<{ data?: unknown }>('io.lotus.device_lost', (evt) => {
|
||||
const lost = parseDeviceLost(evt.detail?.data);
|
||||
if (!lost) return;
|
||||
setToast({
|
||||
id: `device-lost-${lost.kind}-${Date.now()}`,
|
||||
iconSrc: lost.kind === 'audioinput' ? Icons.Mic : Icons.VolumeHigh,
|
||||
displayName: 'Audio device',
|
||||
body: describeLost(lost),
|
||||
roomName: embed.room.name ?? 'Voice call',
|
||||
roomId: embed.roomId,
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
offMissing();
|
||||
offLost();
|
||||
};
|
||||
}, [embed, setToast]);
|
||||
|
||||
if (missing.length === 0) return null;
|
||||
|
||||
const joinAnyway = () => {
|
||||
acceptForSession(missing);
|
||||
embed.control.sendDeviceDecision(true);
|
||||
setMissing([]);
|
||||
};
|
||||
const cancel = () => {
|
||||
embed.control.sendDeviceDecision(false);
|
||||
setMissing([]);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap focusTrapOptions={{ initialFocus: false, escapeDeactivates: false }}>
|
||||
<Dialog
|
||||
variant="Surface"
|
||||
role="alertdialog"
|
||||
aria-labelledby="expected-devices-title"
|
||||
style={modalStyle}
|
||||
>
|
||||
<Header
|
||||
variant="Surface"
|
||||
size="500"
|
||||
style={{
|
||||
padding: `0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
>
|
||||
<Text id="expected-devices-title" as="h2" size="H4">
|
||||
{missing.length === 1
|
||||
? describeMissing(missing[0]).title
|
||||
: "Your usual audio devices aren't connected"}
|
||||
</Text>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
{missing.map((d) => {
|
||||
const t = describeMissing(d);
|
||||
return (
|
||||
<Text key={d.kind} size="T300">
|
||||
<b>{t.expected}</b> isn't connected. Join with <b>{t.fallback}</b> instead?
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
<Text size="T200" priority="300">
|
||||
Plug it in and choose Cancel to start over with it.
|
||||
</Text>
|
||||
<Box direction="Column" gap="200">
|
||||
<Button variant="Primary" onClick={joinAnyway}>
|
||||
<Text size="B400">Join anyway</Text>
|
||||
</Button>
|
||||
<Button variant="Secondary" fill="Soft" onClick={cancel}>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
|
||||
import { CallEmbed } from '../../plugins/call';
|
||||
import { CallControls } from './CallControls';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { CONTROLS_AUTOHIDE_MS, wantsBrowserFullscreen } from './landscapeCall';
|
||||
|
||||
const isStandalone = (): boolean =>
|
||||
window.matchMedia?.('(display-mode: standalone)').matches ||
|
||||
(navigator as { standalone?: boolean }).standalone === true;
|
||||
|
||||
/**
|
||||
* [Gitea #126] Full-screen call controls. Taps inside the call frame never
|
||||
* reach us, so while the controls are hidden a transparent layer covers the
|
||||
* call: the first tap shows them (and is otherwise swallowed, as in native
|
||||
* video apps); with them up the layer is gone, so the call stays usable. They
|
||||
* hide again after a few seconds without a touch. The first tap in an Android
|
||||
* browser tab also enters the browser's own full screen (hides the address
|
||||
* bar); it is left again when this unmounts (rotate back, hang up, exit).
|
||||
*/
|
||||
export function ImmersiveCallOverlay({
|
||||
callEmbed,
|
||||
onExit,
|
||||
}: {
|
||||
callEmbed: CallEmbed;
|
||||
onExit: () => void;
|
||||
}) {
|
||||
const [shown, setShown] = useState(false);
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
const enteredFullscreen = useRef(false);
|
||||
const setChat = useSetAtom(callChatAtom);
|
||||
|
||||
const armHide = useCallback(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setShown(false), CONTROLS_AUTOHIDE_MS);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timer.current);
|
||||
if (enteredFullscreen.current && document.fullscreenElement === document.documentElement) {
|
||||
document.exitFullscreen().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reveal = () => {
|
||||
if (
|
||||
wantsBrowserFullscreen({
|
||||
userAgent: navigator.userAgent,
|
||||
standalone: isStandalone(),
|
||||
fullscreenEnabled: !!document.fullscreenEnabled,
|
||||
alreadyFullscreen: !!document.fullscreenElement,
|
||||
})
|
||||
) {
|
||||
document.documentElement
|
||||
.requestFullscreen({ navigationUI: 'hide' })
|
||||
.then(() => {
|
||||
enteredFullscreen.current = true;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
setShown(true);
|
||||
armHide();
|
||||
};
|
||||
|
||||
if (!shown) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Show call controls"
|
||||
onClick={reveal}
|
||||
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && reveal()}
|
||||
style={{ position: 'absolute', inset: 0, pointerEvents: 'auto' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
// Any touch on the controls keeps them up a little longer.
|
||||
onPointerDown={armHide}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: 'auto',
|
||||
paddingLeft: 'env(safe-area-inset-left)',
|
||||
paddingRight: 'env(safe-area-inset-right)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.7), rgba(0,0,0,0))',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
justifyContent="End"
|
||||
gap="200"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
||||
>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
before={<Icon size="100" src={Icons.Message} />}
|
||||
onClick={() => setChat(true)}
|
||||
>
|
||||
<Text size="B300">Chat</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
before={<Icon size="100" src={Icons.Cross} />}
|
||||
onClick={onExit}
|
||||
style={{ minWidth: toRem(40) }}
|
||||
>
|
||||
<Text size="B300">Exit full screen</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
<CallControls callEmbed={callEmbed} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { shouldGoImmersive, wantsBrowserFullscreen } from './landscapeCall';
|
||||
|
||||
const base = { landscapePhone: true, joinedHere: true, chatOpen: false, dismissed: false };
|
||||
|
||||
test('[#126] full screen only for a joined call on a sideways phone', () => {
|
||||
assert.equal(shouldGoImmersive(base), true);
|
||||
assert.equal(shouldGoImmersive({ ...base, landscapePhone: false }), false, 'upright / tablet');
|
||||
assert.equal(shouldGoImmersive({ ...base, joinedHere: false }), false, 'prescreen / other room');
|
||||
assert.equal(shouldGoImmersive({ ...base, chatOpen: true }), false, 'chat open');
|
||||
assert.equal(shouldGoImmersive({ ...base, dismissed: true }), false, 'user exited');
|
||||
});
|
||||
|
||||
test('[#126] browser full screen only in an Android browser tab', () => {
|
||||
const env = {
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) Chrome/140 Mobile',
|
||||
standalone: false,
|
||||
fullscreenEnabled: true,
|
||||
alreadyFullscreen: false,
|
||||
};
|
||||
assert.equal(wantsBrowserFullscreen(env), true);
|
||||
assert.equal(wantsBrowserFullscreen({ ...env, standalone: true }), false, 'installed app');
|
||||
assert.equal(
|
||||
wantsBrowserFullscreen({
|
||||
...env,
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari',
|
||||
}),
|
||||
false,
|
||||
'iPhone',
|
||||
);
|
||||
assert.equal(wantsBrowserFullscreen({ ...env, alreadyFullscreen: true }), false);
|
||||
assert.equal(wantsBrowserFullscreen({ ...env, fullscreenEnabled: false }), false);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* [Gitea #126] Phone turned sideways in a call: the call takes the whole
|
||||
* screen. A phone in landscape is ~840px wide (the "Tablet" size class), so
|
||||
* the trigger is the shape of the screen, not the size class: landscape, at
|
||||
* most 500px tall, touch input. Tablets and desktops never match.
|
||||
*/
|
||||
export const LANDSCAPE_PHONE_QUERY =
|
||||
'(orientation: landscape) and (max-height: 500px) and (pointer: coarse)';
|
||||
|
||||
/** Controls hide again after this long without a touch on them. */
|
||||
export const CONTROLS_AUTOHIDE_MS = 4000;
|
||||
|
||||
export type ImmersiveInputs = {
|
||||
landscapePhone: boolean;
|
||||
/** This room's call is the one we are in, and we have joined it. */
|
||||
joinedHere: boolean;
|
||||
/** The in-call chat is open (it replaces the call on small screens). */
|
||||
chatOpen: boolean;
|
||||
/** "Exit full screen" was pressed since the phone was last upright. */
|
||||
dismissed: boolean;
|
||||
};
|
||||
|
||||
/** Pure: whether the call should be full screen. */
|
||||
export const shouldGoImmersive = (i: ImmersiveInputs): boolean =>
|
||||
i.landscapePhone && i.joinedHere && !i.chatOpen && !i.dismissed;
|
||||
|
||||
/**
|
||||
* Pure: use the browser's own full-screen mode too? Only where an address bar
|
||||
* takes room: an Android browser tab (not the installed app / iOS, which have
|
||||
* none or don't support it), when the page isn't already full screen.
|
||||
*/
|
||||
export const wantsBrowserFullscreen = (env: {
|
||||
userAgent: string;
|
||||
standalone: boolean;
|
||||
fullscreenEnabled: boolean;
|
||||
alreadyFullscreen: boolean;
|
||||
}): boolean =>
|
||||
/Android/i.test(env.userAgent) &&
|
||||
!env.standalone &&
|
||||
env.fullscreenEnabled &&
|
||||
!env.alreadyFullscreen;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Line } from 'folds';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
@@ -11,7 +11,6 @@ import { WidgetsPanel } from './widgets/WidgetsPanel';
|
||||
import { widgetsPanelAtom } from '../../state/widgetsPanel';
|
||||
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { useMediaQuery } from '../../hooks/useMediaQuery';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
@@ -21,10 +20,12 @@ import { markAsRead } from '../../utils/notifications';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
import { CallView } from '../call/CallView';
|
||||
import { LANDSCAPE_PHONE_QUERY, shouldGoImmersive } from '../call/landscapeCall';
|
||||
import { useMediaQuery } from '../../hooks/useMediaQuery';
|
||||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { callChatAtom, immersiveCallAtom } from '../../state/callEmbed';
|
||||
import { CallChatView } from './CallChatView';
|
||||
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
||||
import { useCallEmbed, useCallJoined } from '../../hooks/useCallEmbed';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||
import { threadsListAtom } from '../../state/threadsList';
|
||||
@@ -57,6 +58,25 @@ export function Room() {
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const members = useRoomMembers(mx, room.roomId);
|
||||
const chat = useAtomValue(callChatAtom);
|
||||
// [Gitea #126] Phone turned sideways in this room's call: full screen.
|
||||
const landscapePhone = useMediaQuery(LANDSCAPE_PHONE_QUERY);
|
||||
const joinedAnywhere = useCallJoined(callEmbed);
|
||||
const [immersiveDismissed, setImmersiveDismissed] = useState(false);
|
||||
useEffect(() => {
|
||||
// "Exit full screen" lasts until the phone is upright again.
|
||||
if (!landscapePhone) setImmersiveDismissed(false);
|
||||
}, [landscapePhone]);
|
||||
const setImmersiveCall = useSetAtom(immersiveCallAtom);
|
||||
const immersive = shouldGoImmersive({
|
||||
landscapePhone,
|
||||
joinedHere: joinedAnywhere && callEmbed?.roomId === room.roomId,
|
||||
chatOpen: chat,
|
||||
dismissed: immersiveDismissed,
|
||||
});
|
||||
useEffect(() => {
|
||||
setImmersiveCall(immersive);
|
||||
return () => setImmersiveCall(false);
|
||||
}, [immersive, setImmersiveCall]);
|
||||
// Hoisted here (instead of inside RoomTimeline) so ThreadPanel/ThreadTimeline —
|
||||
// rendered as siblings of RoomView, not descendants — share the same computed
|
||||
// positions instead of falling back to the context default (Gitea #38).
|
||||
@@ -170,7 +190,8 @@ export function Room() {
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
<ReadPositionsContext.Provider value={readPositions}>
|
||||
<Box grow="Yes">
|
||||
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
|
||||
{immersive && <CallView onExitImmersive={() => setImmersiveDismissed(true)} />}
|
||||
{!immersive && callView && (screenSize === ScreenSize.Desktop || !chat) && (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomViewHeader callView />
|
||||
<Box grow="Yes">
|
||||
|
||||
@@ -128,7 +128,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
||||
const editor = useEditor();
|
||||
const thread = useThreadInstance(room, threadId);
|
||||
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
|
||||
// "Hide Typing & Read Receipts" must also make thread receipts private (matches markAsRead).
|
||||
// "Hide typing and read receipts" must also make thread receipts private (matches markAsRead).
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
|
||||
@@ -23,8 +23,11 @@ import {
|
||||
nudgeFor,
|
||||
NudgeRecord,
|
||||
parseNudgeRecord,
|
||||
shouldShowNudge,
|
||||
pickNudge,
|
||||
staleSessions,
|
||||
staleSessionsText,
|
||||
} from '../../utils/securityNudge';
|
||||
import { preselectSignOutAtom } from '../../state/staleSessions';
|
||||
import { StatusStrip } from '../server-status/ServerStatusBanner';
|
||||
|
||||
/** This device's backup connection: undefined until known, then true/false. */
|
||||
@@ -40,6 +43,35 @@ const useBackupActive = (crypto: CryptoApi): boolean | undefined => {
|
||||
return active;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #113] Ids of this account's sessions unused for 90+ days. Fetched
|
||||
* once sync has settled, then hourly; [] until known or on error.
|
||||
*/
|
||||
const useStaleSessionIds = (enabled: boolean): string[] => {
|
||||
const mx = useMatrixClient();
|
||||
const alive = useAlive();
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const load = () =>
|
||||
mx
|
||||
.getDevices()
|
||||
.then(({ devices }) => {
|
||||
if (alive())
|
||||
setIds(
|
||||
staleSessions(devices, mx.getDeviceId() ?? undefined, Date.now()).map(
|
||||
(d) => d.device_id,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
load();
|
||||
const t = window.setInterval(load, 60 * 60 * 1000);
|
||||
return () => window.clearInterval(t);
|
||||
}, [mx, enabled, alive]);
|
||||
return ids;
|
||||
};
|
||||
|
||||
const readRecord = (key: string): NudgeRecord => {
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
@@ -84,12 +116,15 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
let deviceVerified: boolean | undefined;
|
||||
if (status === VerificationStatus.Verified) deviceVerified = true;
|
||||
else if (status === VerificationStatus.Unverified) deviceVerified = false;
|
||||
const kind = nudgeFor({
|
||||
const securityKind = nudgeFor({
|
||||
crossSigning,
|
||||
deviceVerified,
|
||||
backupOnServer: backupInfo === undefined ? undefined : backupInfo !== null,
|
||||
backupActive,
|
||||
});
|
||||
const staleIds = useStaleSessionIds(settled);
|
||||
const setPreselect = useSetAtom(preselectSignOutAtom);
|
||||
const kind = pickNudge(securityKind, staleIds.length, record, Date.now());
|
||||
|
||||
// One strip at a time: outages, maintenance and a lost connection win.
|
||||
// [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't
|
||||
@@ -97,10 +132,12 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
const otherStrip =
|
||||
syncLost ||
|
||||
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
|
||||
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
|
||||
return null;
|
||||
if (!settled || !deviceId || !kind || otherStrip) return null;
|
||||
|
||||
const copy = NUDGE_COPY[kind];
|
||||
const copy =
|
||||
kind === 'stale-sessions'
|
||||
? { ...NUDGE_COPY[kind], text: staleSessionsText(staleIds.length) }
|
||||
: NUDGE_COPY[kind];
|
||||
const dismiss = () => {
|
||||
const next = dismissNudge(kind, record, Date.now());
|
||||
try {
|
||||
@@ -113,8 +150,15 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
return (
|
||||
<StatusStrip
|
||||
banner={{ key: kind, tone: 'Primary', text: copy.text, dismissable: true }}
|
||||
icon={Icons.ShieldUser}
|
||||
action={{ label: copy.action, onClick: () => requestSettings('devices') }}
|
||||
action={{
|
||||
label: copy.action,
|
||||
onClick: () => {
|
||||
// Review: the Devices page opens with these ticked for sign-out.
|
||||
if (kind === 'stale-sessions') setPreselect(staleIds);
|
||||
requestSettings('devices');
|
||||
},
|
||||
}}
|
||||
icon={kind === 'stale-sessions' ? Icons.Category : Icons.ShieldUser}
|
||||
dismissLabel="Not now"
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
|
||||
@@ -205,7 +205,7 @@ export function About({ requestClose }: AboutProps) {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Clear Cache & Reload"
|
||||
title="Clear cache and reload"
|
||||
description="Clear all your locally stored data and reload from server. This also deletes this device's encryption keys — older encrypted messages stay unreadable here unless key backup is set up."
|
||||
after={
|
||||
<Button
|
||||
|
||||
@@ -22,14 +22,14 @@ export function ContactInformation() {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Contact Information</Text>
|
||||
<Text size="L400">Contact information</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile title="Email Address" description="Email address attached to your account.">
|
||||
<SettingTile title="Email address" description="Email address attached to your account.">
|
||||
<Box>
|
||||
{emailIds?.map((email) => (
|
||||
<Chip key={email.address} as="span" variant="Secondary" radii="Pill">
|
||||
|
||||
@@ -136,7 +136,7 @@ export function IgnoredUserList() {
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="L400">Blocked Users</Text>
|
||||
<Text size="L400">Blocked users</Text>
|
||||
</Box>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
@@ -145,8 +145,8 @@ export function IgnoredUserList() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Select User"
|
||||
description="Prevent receiving messages or invites from user by adding their userId."
|
||||
title="Block someone"
|
||||
description="Stop someone's messages and invites reaching you. Enter their Matrix ID, e.g. @name:server."
|
||||
>
|
||||
<Box direction="Column" gap="300">
|
||||
<IgnoreUserInput userList={ignoredUsers} />
|
||||
|
||||
@@ -31,7 +31,7 @@ export function OidcManageAccount() {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Account Management</Text>
|
||||
<Text size="L400">Account management</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
|
||||
@@ -72,7 +72,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Enable Developer Tools"
|
||||
title="Enable developer tools"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
@@ -90,7 +90,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Access Token"
|
||||
title="Access token"
|
||||
description="Copy access token to clipboard."
|
||||
after={
|
||||
<Button
|
||||
|
||||
@@ -35,7 +35,7 @@ export function CryptoDiagnostics() {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Crypto Diagnostics</Text>
|
||||
<Text size="L400">Crypto diagnostics</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
@@ -43,8 +43,8 @@ export function CryptoDiagnostics() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Crypto Diagnostics — captures E2EE error signatures this session"
|
||||
description="Ring-buffers up to 200 matched console warnings/errors for the KE-1..KE-4 bug cluster. Local only — no network calls. The downloaded report includes the matched log lines as evidence."
|
||||
title="Crypto diagnostics"
|
||||
description="Captures encryption error signatures from this session. Ring-buffers up to 200 matched console warnings/errors for the KE-1..KE-4 bug cluster. Local only — no network calls. The downloaded report includes the matched log lines as evidence."
|
||||
after={
|
||||
<Box alignItems="Center" gap="200" shrink="No">
|
||||
<Badge variant={count > 0 ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
|
||||
|
||||
@@ -93,8 +93,8 @@ export function Devices({ requestClose }: DevicesProps) {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Device Verification"
|
||||
description="To verify device identity and grant access to encrypted messages."
|
||||
title="Device verification"
|
||||
description="Verify this device so it can read your encrypted messages and others can trust it."
|
||||
after={
|
||||
<>
|
||||
<EnableVerification visible={!crossSigningActive} />
|
||||
|
||||
@@ -130,8 +130,8 @@ function ExportKeysTile() {
|
||||
return (
|
||||
<>
|
||||
<SettingTile
|
||||
title="Export Messages Data"
|
||||
description="Save password protected copy of encryption data on your device to decrypt messages later."
|
||||
title="Export encryption keys"
|
||||
description="Save a password-protected file of your encryption keys. Useful when moving to a device without key backup, or as an offline copy."
|
||||
after={
|
||||
<Box>
|
||||
<Button
|
||||
@@ -260,8 +260,8 @@ function ImportKeysTile() {
|
||||
return (
|
||||
<>
|
||||
<SettingTile
|
||||
title="Import Messages Data"
|
||||
description="Load password protected copy of encryption data from device to decrypt your messages."
|
||||
title="Import encryption keys"
|
||||
description="Load a password-protected encryption key file to read older encrypted messages on this device."
|
||||
after={
|
||||
<Box>
|
||||
{file ? (
|
||||
@@ -307,7 +307,7 @@ function ImportKeysTile() {
|
||||
export function LocalBackup() {
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Local Backup</Text>
|
||||
<Text size="L400">Encryption key backup file</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Box, Button, config, Menu, Spinner, Text } from 'folds';
|
||||
import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
@@ -15,6 +16,8 @@ import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
|
||||
import { withSearchParam } from '../../../pages/pathUtils';
|
||||
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { preselectSignOutAtom } from '../../../state/staleSessions';
|
||||
import { staleSessions } from '../../../utils/securityNudge';
|
||||
|
||||
type OtherDevicesProps = {
|
||||
devices: IMyDevice[];
|
||||
@@ -29,6 +32,23 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
||||
|
||||
const [deleted, setDeleted] = useState<Set<string>>(new Set());
|
||||
|
||||
// [Gitea #113] The stale-sessions nudge's Review: tick those sessions for
|
||||
// sign-out (one confirm below). OIDC accounts sign out on the provider's
|
||||
// dashboard instead, so nothing is ticked there.
|
||||
const [preselect, setPreselect] = useAtom(preselectSignOutAtom);
|
||||
useEffect(() => {
|
||||
if (preselect.length === 0) return;
|
||||
if (!authMetadata) {
|
||||
const present = new Set(devices.map((d) => d.device_id));
|
||||
setDeleted(new Set(preselect.filter((id) => present.has(id))));
|
||||
}
|
||||
setPreselect([]);
|
||||
}, [preselect, setPreselect, devices, authMetadata]);
|
||||
|
||||
const staleIds = new Set(
|
||||
staleSessions(devices, mx.getDeviceId() ?? undefined, Date.now()).map((d) => d.device_id),
|
||||
);
|
||||
|
||||
const handleDashboardOIDC = useCallback(() => {
|
||||
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
|
||||
if (!authUrl) return;
|
||||
@@ -115,7 +135,7 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Device Dashboard"
|
||||
title="Device dashboard"
|
||||
description="Manage your devices on OIDC dashboard."
|
||||
after={
|
||||
<Button
|
||||
@@ -132,8 +152,20 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
||||
/>
|
||||
</SequenceCard>
|
||||
)}
|
||||
{devices
|
||||
{staleIds.size > 0 && (
|
||||
<Text size="T200" priority="300">
|
||||
{staleIds.size === 1
|
||||
? "1 session hasn't been used in 3 months or more (marked Inactive)."
|
||||
: `${staleIds.size} sessions haven't been used in 3 months or more (marked Inactive).`}{' '}
|
||||
Sign out the ones you no longer use.
|
||||
</Text>
|
||||
)}
|
||||
{[...devices]
|
||||
// [Gitea #113] Inactive (90+ days) sessions first, then most recent.
|
||||
.sort((d1, d2) => {
|
||||
const s1 = staleIds.has(d1.device_id) ? 0 : 1;
|
||||
const s2 = staleIds.has(d2.device_id) ? 0 : 1;
|
||||
if (s1 !== s2) return s1 - s2;
|
||||
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
|
||||
return d1.last_seen_ts < d2.last_seen_ts ? 1 : -1;
|
||||
})
|
||||
@@ -145,6 +177,11 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
{staleIds.has(device.device_id) && (
|
||||
<Text size="L400" style={{ color: 'inherit' }}>
|
||||
Inactive for 3+ months
|
||||
</Text>
|
||||
)}
|
||||
<DeviceTile
|
||||
device={device}
|
||||
deleted={deleted.has(device.device_id)}
|
||||
|
||||
@@ -423,7 +423,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
|
||||
return (
|
||||
<>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Favorite Packs</Text>
|
||||
<Text size="L400">Favourite packs</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
@@ -431,7 +431,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Select Pack"
|
||||
title="Add a pack"
|
||||
description="Pick emojis and stickers pack from rooms to use in all rooms."
|
||||
after={
|
||||
<>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function UserPack({ onViewPack }: UserPackProps) {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Default Pack</Text>
|
||||
<Text size="L400">Default pack</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
|
||||
@@ -155,7 +155,7 @@ function DesktopChromeSetting() {
|
||||
return (
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Custom Window Chrome (Beta)"
|
||||
title="Custom window chrome (beta)"
|
||||
description="Replace the system title bar with a Lotus-styled one. Desktop only — reloads to apply."
|
||||
after={<Switch variant="Primary" value={customChrome} onChange={handleToggle} />}
|
||||
/>
|
||||
@@ -263,7 +263,7 @@ function KeychainMirrorSetting() {
|
||||
}
|
||||
return (
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Login in the system keychain" description={description} />
|
||||
<SettingTile title="Keep your sign-in in the system keychain" description={description} />
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
@@ -294,12 +294,18 @@ const ThemeSelector = as<'div', ThemeSelectorProps>(
|
||||
),
|
||||
);
|
||||
|
||||
function SelectTheme({ disabled }: { disabled?: boolean }) {
|
||||
const themes = useThemes();
|
||||
function SelectTheme({ disabled, kind }: { disabled?: boolean; kind?: ThemeKind }) {
|
||||
const allThemes = useThemes();
|
||||
const themes = kind ? allThemes.filter((theme) => theme.kind === kind) : allThemes;
|
||||
const themeNames = useThemeNames();
|
||||
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
|
||||
const [, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
|
||||
const [, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
|
||||
const selectedTheme =
|
||||
themes.find((theme) => theme.id === themeId) ??
|
||||
themes[0] ??
|
||||
(kind === ThemeKind.Dark ? DarkTheme : LightTheme);
|
||||
|
||||
const handleThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
@@ -307,6 +313,10 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
|
||||
|
||||
const handleThemeSelect = (theme: Theme) => {
|
||||
setThemeId(theme.id);
|
||||
// [Gitea #455] Also remember it as this kind's variant, so switching to
|
||||
// System later keeps the user's pick.
|
||||
if (theme.kind === ThemeKind.Light) setLightThemeId(theme.id);
|
||||
else setDarkThemeId(theme.id);
|
||||
setMenuCords(undefined);
|
||||
};
|
||||
|
||||
@@ -391,7 +401,7 @@ function SystemThemePreferences() {
|
||||
return (
|
||||
<Box wrap="Wrap" gap="400">
|
||||
<SettingTile
|
||||
title="Light Theme:"
|
||||
title="Light theme:"
|
||||
after={
|
||||
<Chip
|
||||
variant={themeKind === ThemeKind.Light ? 'Primary' : 'Secondary'}
|
||||
@@ -432,7 +442,7 @@ function SystemThemePreferences() {
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Dark Theme:"
|
||||
title="Dark theme:"
|
||||
after={
|
||||
<Chip
|
||||
variant={themeKind === ThemeKind.Dark ? 'Primary' : 'Secondary'}
|
||||
@@ -534,6 +544,26 @@ function PageZoomInput() {
|
||||
|
||||
function Appearance() {
|
||||
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
|
||||
const allThemes = useThemes();
|
||||
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
|
||||
const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId');
|
||||
const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId');
|
||||
const currentKind = allThemes.find((t) => t.id === themeId)?.kind ?? ThemeKind.Light;
|
||||
let themeMode: 'system' | 'light' | 'dark' = 'system';
|
||||
if (!systemTheme) themeMode = currentKind === ThemeKind.Dark ? 'dark' : 'light';
|
||||
const chooseThemeMode = (mode: 'system' | 'light' | 'dark') => {
|
||||
if (mode === 'system') {
|
||||
setSystemTheme(true);
|
||||
return;
|
||||
}
|
||||
setSystemTheme(false);
|
||||
// Use the variant already chosen for that kind (or the default one).
|
||||
const kind = mode === 'dark' ? ThemeKind.Dark : ThemeKind.Light;
|
||||
if (allThemes.find((t) => t.id === themeId)?.kind === kind) return;
|
||||
const preferred = mode === 'dark' ? darkThemeId : lightThemeId;
|
||||
const fallback = mode === 'dark' ? DarkTheme.id : LightTheme.id;
|
||||
setThemeId(allThemes.find((t) => t.id === preferred && t.kind === kind)?.id ?? fallback);
|
||||
};
|
||||
const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode');
|
||||
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||
const [perMessageProfiles, setPerMessageProfiles] = useSetting(
|
||||
@@ -575,38 +605,64 @@ function Appearance() {
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="System Theme"
|
||||
description="Choose between light and dark theme based on system preference."
|
||||
after={<Switch variant="Primary" value={systemTheme} onChange={setSystemTheme} />}
|
||||
/>
|
||||
{systemTheme && <SystemThemePreferences />}
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
{/* [Gitea #455] One control instead of a System switch plus a Theme
|
||||
dropdown that was greyed out whenever the switch was on. */}
|
||||
<SettingTile
|
||||
title="Theme"
|
||||
description="Theme to use when system theme is not enabled."
|
||||
after={<SelectTheme disabled={systemTheme} />}
|
||||
description={
|
||||
themeMode === 'system'
|
||||
? "Follows your device's light or dark setting."
|
||||
: 'Always use this theme, whatever your device is set to.'
|
||||
}
|
||||
after={
|
||||
<Box gap="100" role="radiogroup" aria-label="Theme" shrink="No" wrap="Wrap">
|
||||
{(
|
||||
[
|
||||
['system', 'System'],
|
||||
['light', 'Light'],
|
||||
['dark', 'Dark'],
|
||||
] as const
|
||||
).map(([mode, label]) => (
|
||||
<Chip
|
||||
key={mode}
|
||||
role="radio"
|
||||
aria-checked={themeMode === mode}
|
||||
variant={themeMode === mode ? 'Primary' : 'Secondary'}
|
||||
outlined={themeMode === mode}
|
||||
radii="Pill"
|
||||
onClick={() => chooseThemeMode(mode)}
|
||||
>
|
||||
<Text size="B300">{label}</Text>
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
{themeMode === 'system' && <SystemThemePreferences />}
|
||||
{themeMode !== 'system' && (
|
||||
<SettingTile
|
||||
title={themeMode === 'light' ? 'Light theme:' : 'Dark theme:'}
|
||||
after={<SelectTheme kind={themeMode === 'light' ? ThemeKind.Light : ThemeKind.Dark} />}
|
||||
/>
|
||||
)}
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Monochrome Mode"
|
||||
title="Monochrome mode"
|
||||
after={<Switch variant="Primary" value={monochromeMode} onChange={setMonochromeMode} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Twitter Emoji"
|
||||
title="Emoji style: Twemoji"
|
||||
after={<Switch variant="Primary" value={twitterEmoji} onChange={setTwitterEmoji} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
|
||||
<SettingTile title="Page zoom" after={<PageZoomInput />} />
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard
|
||||
@@ -616,7 +672,7 @@ function Appearance() {
|
||||
gap="200"
|
||||
>
|
||||
<SettingTile
|
||||
title="Chat Background"
|
||||
title="Chat background"
|
||||
description="Pattern applied behind the message timeline."
|
||||
/>
|
||||
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
|
||||
@@ -626,7 +682,7 @@ function Appearance() {
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Pause Background Animations"
|
||||
title="Pause background animations"
|
||||
description="Stop background animation to reduce motion or improve performance."
|
||||
after={<Switch variant="Primary" value={pauseAnimations} onChange={setPauseAnimations} />}
|
||||
/>
|
||||
@@ -639,7 +695,7 @@ function Appearance() {
|
||||
gap="200"
|
||||
>
|
||||
<SettingTile
|
||||
title="Seasonal Theme"
|
||||
title="Seasonal theme"
|
||||
description="Decorative overlays for holidays and events. “Auto” follows the calendar — each theme below shows the dates it turns on. Click to select."
|
||||
/>
|
||||
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
|
||||
@@ -659,7 +715,7 @@ function Appearance() {
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Show Profile on Every Message"
|
||||
title="Show name and avatar on every message"
|
||||
description="Display avatar and name on each message instead of grouping consecutive messages."
|
||||
after={
|
||||
<Switch variant="Primary" value={perMessageProfiles} onChange={setPerMessageProfiles} />
|
||||
@@ -673,7 +729,7 @@ function Appearance() {
|
||||
gap="200"
|
||||
>
|
||||
<SettingTile
|
||||
title="Night Light"
|
||||
title="Night light"
|
||||
description="Reduce blue light with a warm orange tint overlay."
|
||||
after={
|
||||
<Switch variant="Primary" value={nightLightEnabled} onChange={setNightLightEnabled} />
|
||||
@@ -753,7 +809,7 @@ function Appearance() {
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Glassmorphism Sidebar"
|
||||
title="Glass sidebar"
|
||||
description="Semi-transparent sidebar with frosted glass effect. Works best with custom chat backgrounds."
|
||||
after={
|
||||
<Switch
|
||||
@@ -767,8 +823,8 @@ function Appearance() {
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Lotus Terminal Mode"
|
||||
description="LotusGuild Terminal Design System: Anduril Orange + Ice Cyan + Matrix Green, dot-grid background, CRT scanlines, and boot sequence animation (press Escape to skip)."
|
||||
title="Lotus Terminal mode"
|
||||
description="A retro terminal look: orange and cyan on a dot grid, CRT scanlines and a boot animation (press Esc to skip)."
|
||||
after={
|
||||
<Box direction="Row" gap="200" alignItems="Center">
|
||||
{lotusTerminal && (
|
||||
@@ -802,7 +858,7 @@ function Appearance() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="UI Font"
|
||||
title="Interface font"
|
||||
description="Font used throughout the interface."
|
||||
after={
|
||||
<SettingsSelect
|
||||
@@ -822,7 +878,7 @@ function Appearance() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="@Mention Highlight Color"
|
||||
title="@mention highlight colour"
|
||||
description="Color used to highlight messages that mention you. Leave empty to use the theme default."
|
||||
after={
|
||||
<HexColorPickerPopOut
|
||||
@@ -866,7 +922,7 @@ function Appearance() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Custom Accent Color"
|
||||
title="Custom accent colour"
|
||||
description={
|
||||
lotusTerminal
|
||||
? 'Only applies to non-TDS themes. Disable Lotus Terminal Mode to use a custom accent.'
|
||||
@@ -1240,7 +1296,7 @@ function SelectDateFormat() {
|
||||
return (
|
||||
<>
|
||||
<SettingTile
|
||||
title="Date Format"
|
||||
title="Date format"
|
||||
description={customDateFormat ? dayjs().format(dateFormatString) : ''}
|
||||
after={<PresetDateFormat value={selectedDateFormat} onChange={handlePresetChange} />}
|
||||
/>
|
||||
@@ -1256,10 +1312,10 @@ function DateAndTime() {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Date & Time</Text>
|
||||
<Text size="L400">Date and time</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="24-Hour Time Format"
|
||||
title="24-hour time"
|
||||
after={<Switch variant="Primary" value={hour24Clock} onChange={setHour24Clock} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
@@ -1471,7 +1527,7 @@ function Editor() {
|
||||
<Text size="L400">Editor</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="ENTER for Newline"
|
||||
title="Enter for a new line"
|
||||
description={`Use ${
|
||||
isMacOS() ? KeySymbol.Command : 'Ctrl'
|
||||
} + ENTER to send message and ENTER for newline.`}
|
||||
@@ -1480,20 +1536,20 @@ function Editor() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Markdown Formatting"
|
||||
title="Markdown formatting"
|
||||
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Offer to Format Pasted Code"
|
||||
title="Offer to format pasted code"
|
||||
description="When something you paste looks like code, offer to turn it into a code block. Nothing changes unless you accept."
|
||||
after={<Switch variant="Primary" value={offerCodePaste} onChange={setOfferCodePaste} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Formatting Toolbar"
|
||||
title="Formatting toolbar"
|
||||
description="Show bold, italic, code and other formatting buttons above the message input."
|
||||
after={<Switch variant="Primary" value={editorToolbar} onChange={setEditorToolbar} />}
|
||||
/>
|
||||
@@ -1505,7 +1561,7 @@ function Editor() {
|
||||
gap="300"
|
||||
>
|
||||
<SettingTile
|
||||
title="Composer Toolbar"
|
||||
title="Composer toolbar"
|
||||
description="Drag to reorder buttons, and tap a button to show or hide it in the message composer."
|
||||
/>
|
||||
<Box direction="Column" style={{ paddingBottom: config.space.S200 }}>
|
||||
@@ -1540,28 +1596,28 @@ function Privacy() {
|
||||
<Text size="L400">Privacy</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Strip Tracking Parameters from Links"
|
||||
title="Strip tracking parameters from links"
|
||||
description="Remove utm_, fbclid, YouTube si= and other ad/analytics identifiers from links you paste or send, and from links shown in chat. Runs entirely on this device."
|
||||
after={<Switch variant="Primary" value={stripTracking} onChange={setStripTracking} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Remove Photo Metadata Before Sending"
|
||||
title="Remove photo metadata before sending"
|
||||
description="Strip location, camera model and timestamp (EXIF/XMP) from JPEG, PNG and WebP images you send or set as an avatar. Pixels are untouched; the rotation is kept. Videos and GIFs are not covered."
|
||||
after={<Switch variant="Primary" value={stripImageMeta} onChange={setStripImageMeta} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Hide Typing & Read Receipts"
|
||||
title="Hide typing and read receipts"
|
||||
description="Turn off both typing status and read receipts to keep your activity private."
|
||||
after={<Switch variant="Primary" value={hideActivity} onChange={setHideActivity} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Private Read Receipts"
|
||||
title="Private read receipts"
|
||||
description="Send read receipts only to you and the server — other users won't see when you've read messages."
|
||||
after={
|
||||
<Switch
|
||||
@@ -1574,7 +1630,7 @@ function Privacy() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Hide Online Status"
|
||||
title="Hide online status"
|
||||
description="Appear offline to everyone while still using the app. Disables online, away, and idle indicators."
|
||||
after={<Switch variant="Primary" value={hidePresence} onChange={setHidePresence} />}
|
||||
/>
|
||||
@@ -1656,7 +1712,7 @@ function SettingsSyncSection() {
|
||||
<Text size="L400">Sync</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Sync Settings Across Devices"
|
||||
title="Sync settings across devices"
|
||||
description="Keep your preferences (theme, composer toolbar, notifications, quiet hours, call keys…) the same on every device, stored as account data on your own homeserver. Device-specific settings such as zoom, media auto-load, noise suppression and volumes stay local."
|
||||
after={<Switch variant="Primary" value={settingsSync} onChange={setSettingsSync} />}
|
||||
/>
|
||||
@@ -1664,7 +1720,7 @@ function SettingsSyncSection() {
|
||||
{settingsSync && (
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Use This Device's Settings Everywhere"
|
||||
title="Use this device's settings everywhere"
|
||||
description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently."
|
||||
// [Gitea #273] On phones the two buttons squeezed the text into a
|
||||
// narrow column; put them under the description there.
|
||||
@@ -1799,14 +1855,14 @@ function Calls() {
|
||||
<Text size="L400">Calls</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Join with Camera On"
|
||||
title="Join with camera on"
|
||||
description="Enable camera automatically when joining a voice or video call. Camera is off by default."
|
||||
after={<Switch variant="Primary" value={cameraOnJoin} onChange={setCameraOnJoin} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Noise Suppression"
|
||||
title="Noise suppression"
|
||||
description={
|
||||
<Box direction="Column" gap="200">
|
||||
{/* [Gitea #271] T200 like every other setting description. */}
|
||||
@@ -1863,7 +1919,7 @@ function Calls() {
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="L400">Model</Text>
|
||||
<SettingTile
|
||||
title="ML Model"
|
||||
title="ML model"
|
||||
description="Choose the machine learning model used for noise removal. Heavier models clean more aggressively at a higher CPU cost."
|
||||
after={
|
||||
<SettingsSelect<DenoiseModelId>
|
||||
@@ -1979,7 +2035,7 @@ function Calls() {
|
||||
>
|
||||
<Text size="L400">Enhancements</Text>
|
||||
<SettingTile
|
||||
title="Series Suppression"
|
||||
title="Also use the browser's noise suppression"
|
||||
description="Run the browser's native stationary noise filter before the ML model. Recommended for eliminating fan hum."
|
||||
after={
|
||||
<Switch
|
||||
@@ -1991,7 +2047,7 @@ function Calls() {
|
||||
/>
|
||||
|
||||
<SettingTile
|
||||
title="Noise Gate"
|
||||
title="Noise gate"
|
||||
description="Hard-cut audio when you aren't speaking to ensure absolute silence between sentences."
|
||||
after={
|
||||
<Switch variant="Primary" value={callDenoiseGate} onChange={setCallDenoiseGate} />
|
||||
@@ -2027,7 +2083,7 @@ function Calls() {
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
<Text size="L400">Test & calibrate</Text>
|
||||
<Text size="L400">Test and calibrate</Text>
|
||||
<Text size="T200" priority="300">
|
||||
Audition the selected model and tune the gate without joining a call. Changes to the
|
||||
settings above apply the next time you start a monitor or play a clip.
|
||||
@@ -2049,7 +2105,7 @@ function Calls() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="After a Restart"
|
||||
title="Rejoin calls after a restart"
|
||||
description="If Lotus crashes, reloads or restarts for an update while you are in a voice room, put you back in."
|
||||
after={
|
||||
<SettingsSelect<'ask' | 'auto' | 'off'>
|
||||
@@ -2064,7 +2120,7 @@ function Calls() {
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Summary After Calls"
|
||||
title="Summary after calls"
|
||||
description="When a call ends, show how long it was and how the connection held up, with a Details button for who was there, network numbers and your own talk/mute time."
|
||||
after={
|
||||
<Switch variant="Primary" value={callSummaryToast} onChange={setCallSummaryToast} />
|
||||
@@ -2072,19 +2128,19 @@ function Calls() {
|
||||
/>
|
||||
{hapticsSupported() && (
|
||||
<SettingTile
|
||||
title="Haptic Feedback"
|
||||
title="Haptic feedback"
|
||||
description="A short vibration when push-to-talk engages or releases and when you send a reaction. Off automatically when your system prefers reduced motion."
|
||||
after={<Switch variant="Primary" value={haptics} onChange={setHaptics} />}
|
||||
/>
|
||||
)}
|
||||
<SettingTile
|
||||
title="Push to Talk"
|
||||
title="Push to talk"
|
||||
description="Mute your microphone by default. Hold the PTT key to speak."
|
||||
after={<Switch variant="Primary" value={pttMode} onChange={setPttMode} />}
|
||||
/>
|
||||
{pttMode && (
|
||||
<SettingTile
|
||||
title="PTT Key"
|
||||
title="Push-to-talk key"
|
||||
description={pttBind.error ?? 'Press a key to bind it as your push-to-talk key.'}
|
||||
after={
|
||||
<Button
|
||||
@@ -2102,7 +2158,7 @@ function Calls() {
|
||||
/>
|
||||
)}
|
||||
<SettingTile
|
||||
title="Push to Deafen"
|
||||
title="Push to deafen"
|
||||
description={
|
||||
deafenBind.error ??
|
||||
(deafenHotkey
|
||||
@@ -2143,7 +2199,7 @@ function Calls() {
|
||||
/>
|
||||
{isTauriEnv() && (
|
||||
<SettingTile
|
||||
title="Hotkeys Work Outside the Window"
|
||||
title="Hotkeys work outside the window"
|
||||
description="Keep Push to Talk and Push to Deafen working while a game or another app has focus. The keys are only watched during a call and are never blocked from other apps. Windows only; some anti-cheat systems may prevent it."
|
||||
after={
|
||||
<Switch variant="Primary" value={globalCallHotkeys} onChange={setGlobalCallHotkeys} />
|
||||
@@ -2165,13 +2221,13 @@ function Calls() {
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="AFK Auto-Mute"
|
||||
title="AFK auto-mute"
|
||||
description="Automatically mute your microphone when you have been silent in a call."
|
||||
after={<Switch variant="Primary" value={afkAutoMute} onChange={setAfkAutoMute} />}
|
||||
/>
|
||||
{afkAutoMute && (
|
||||
<SettingTile
|
||||
title="Idle Timeout"
|
||||
title="Idle timeout"
|
||||
description="How long to wait before auto-muting."
|
||||
after={
|
||||
<SettingsSelect
|
||||
@@ -2203,7 +2259,7 @@ function Calls() {
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Ringtone Volume" description="Volume of the incoming call ringtone." />
|
||||
<SettingTile title="Ringtone volume" description="Volume of the incoming call ringtone." />
|
||||
<Box
|
||||
direction="Row"
|
||||
alignItems="Center"
|
||||
@@ -2227,7 +2283,7 @@ function Calls() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Join & Leave Sounds"
|
||||
title="Join and leave sounds"
|
||||
description="Play a sound when someone joins or leaves a call you are in. Selecting an option plays a preview."
|
||||
after={
|
||||
<SettingsSelect
|
||||
@@ -2246,7 +2302,7 @@ function Calls() {
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Microphone Bitrate"
|
||||
title="Microphone bitrate"
|
||||
description="Cap the audio bitrate your mic sends in calls. Lower saves bandwidth; higher is clearer. Auto lets Element Call decide."
|
||||
after={
|
||||
<SettingsSelect<CallAudioBitrate>
|
||||
@@ -2260,7 +2316,7 @@ function Calls() {
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Screenshare Bitrate"
|
||||
title="Screen share bitrate"
|
||||
description="Cap the bitrate used when you share your screen. Lower is smoother on poor connections; higher is sharper."
|
||||
after={
|
||||
<SettingsSelect<ScreenshareBitrate>
|
||||
@@ -2274,7 +2330,7 @@ function Calls() {
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Screenshare Framerate"
|
||||
title="Screen share frame rate"
|
||||
description="Cap the frames-per-second of your screenshare. 60 fps suits motion/gaming; 15 fps suits slides and saves bandwidth."
|
||||
after={
|
||||
<SettingsSelect<ScreenshareFramerate>
|
||||
@@ -2300,7 +2356,7 @@ function Calls() {
|
||||
/>
|
||||
{soundboardEnabled && (
|
||||
<>
|
||||
<SettingTile title="Soundboard Volume" />
|
||||
<SettingTile title="Soundboard volume" />
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
@@ -2645,14 +2701,14 @@ function Messages() {
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Messages</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Message Layout" after={<SelectMessageLayout />} />
|
||||
<SettingTile title="Message layout" after={<SelectMessageLayout />} />
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
|
||||
<SettingTile title="Message spacing" after={<SelectMessageSpacing />} />
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Legacy Username Color"
|
||||
title="Legacy username colour"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
@@ -2664,7 +2720,7 @@ function Messages() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Hide Membership Change"
|
||||
title="Hide joins and leaves"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
@@ -2676,7 +2732,7 @@ function Messages() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Hide Profile Change"
|
||||
title="Hide name and avatar changes"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
@@ -2688,7 +2744,7 @@ function Messages() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Disable Media Auto Load"
|
||||
title="Don't load media automatically"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
@@ -2700,14 +2756,14 @@ function Messages() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="URL Preview"
|
||||
title="Link previews"
|
||||
description="Your homeserver fetches and caches link previews. It will see the URLs of all links you preview."
|
||||
after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="URL Preview in Encrypted Rooms"
|
||||
title="Link previews in encrypted rooms"
|
||||
description={
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T200" priority="300">
|
||||
@@ -2731,34 +2787,29 @@ function Messages() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Inline Media Players"
|
||||
title="Inline media players"
|
||||
description="Play YouTube, Vimeo, TikTok, Twitch, Spotify and more right in the chat. Nothing loads from the third-party site until you press play."
|
||||
after={
|
||||
<Switch variant="Primary" value={inlineMediaEmbeds} onChange={setInlineMediaEmbeds} />
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
{/* [Gitea #457] Only where this build has a GIF key: a disabled switch
|
||||
with a paragraph about missing configuration helps nobody. */}
|
||||
{gifAvailable && (
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="GIF picker"
|
||||
description="Every search term (and your IP address) is sent directly to Giphy, not through your homeserver. Off by default."
|
||||
after={
|
||||
<Switch variant="Primary" value={gifPickerEnabled} onChange={setGifPickerEnabled} />
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
)}
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="GIF Picker"
|
||||
description={
|
||||
gifAvailable
|
||||
? 'Every search term (and your IP address) is sent directly to Giphy, not through your homeserver. Off by default.'
|
||||
: "GIF search isn't set up for this version of the app (no Giphy key in its configuration), so there is no GIF button even when this is on."
|
||||
}
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={gifPickerEnabled && gifAvailable}
|
||||
onChange={setGifPickerEnabled}
|
||||
disabled={!gifAvailable}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Show Hidden Events"
|
||||
title="Show hidden events"
|
||||
after={
|
||||
<Switch variant="Primary" value={showHiddenEvents} onChange={setShowHiddenEvents} />
|
||||
}
|
||||
@@ -2766,7 +2817,7 @@ function Messages() {
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Enforce Message Retention"
|
||||
title="Enforce message retention"
|
||||
description="Permanently delete your own messages once a room's retention window (Room Settings → Message Retention) has passed. Off by default; only affects your own messages."
|
||||
after={
|
||||
<Switch
|
||||
@@ -2783,7 +2834,7 @@ function Messages() {
|
||||
direction="Column"
|
||||
>
|
||||
<SettingTile
|
||||
title="Translate Messages Into"
|
||||
title="Translate messages into"
|
||||
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.'
|
||||
@@ -2801,7 +2852,7 @@ function Messages() {
|
||||
/>
|
||||
{translationSupported && (
|
||||
<SettingTile
|
||||
title="Auto-translate Incoming Messages"
|
||||
title="Auto-translate incoming messages"
|
||||
description="Automatically translate messages that aren’t already in your language. When off, translate individual messages from their menu. The first time a language is used, its model downloads once (a few MB)."
|
||||
after={<Switch variant="Primary" value={autoTranslate} onChange={setAutoTranslate} />}
|
||||
/>
|
||||
@@ -2962,9 +3013,9 @@ function AppUpdates() {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">App Updates</Text>
|
||||
<Text size="L400">App updates</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Check for Updates" description={description} after={after} />
|
||||
<SettingTile title="Check for updates" description={description} after={after} />
|
||||
{packageUpdate && packageHelp && <PackageUpdate help={packageHelp} />}
|
||||
{status.state === 'error' && !packageUpdate && (
|
||||
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
|
||||
|
||||
@@ -82,7 +82,7 @@ export function AllMessagesNotifications() {
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="L400">All Messages</Text>
|
||||
<Text size="L400">All messages</Text>
|
||||
<Box gap="100">
|
||||
<Text size="T200">Badge: </Text>
|
||||
<Badge radii="300" variant="Secondary" fill="Solid">
|
||||
@@ -97,7 +97,7 @@ export function AllMessagesNotifications() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="1-to-1 Chats"
|
||||
title="1-to-1 chats"
|
||||
after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.DM} oneToOne />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
@@ -108,7 +108,7 @@ export function AllMessagesNotifications() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="1-to-1 Chats (Encrypted)"
|
||||
title="1-to-1 chats (encrypted)"
|
||||
after={
|
||||
<AllMessagesModeSwitcher
|
||||
pushRules={pushRules}
|
||||
@@ -137,7 +137,7 @@ export function AllMessagesNotifications() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Rooms (Encrypted)"
|
||||
title="Rooms (encrypted)"
|
||||
after={
|
||||
<AllMessagesModeSwitcher
|
||||
pushRules={pushRules}
|
||||
|
||||
@@ -171,7 +171,7 @@ export function KeywordMessagesNotifications() {
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="L400">Keyword Messages</Text>
|
||||
<Text size="L400">Keyword messages</Text>
|
||||
<Box gap="100">
|
||||
<Text size="T200">Badge: </Text>
|
||||
<Badge radii="300" variant="Success" fill="Solid">
|
||||
@@ -186,7 +186,7 @@ export function KeywordMessagesNotifications() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Select Keyword"
|
||||
title="Add a keyword"
|
||||
description="Set a notification preference for message containing given keyword."
|
||||
>
|
||||
<KeywordInput />
|
||||
|
||||
@@ -9,7 +9,6 @@ import { KeywordMessagesNotifications } from './KeywordMessages';
|
||||
import { PushRuleEditor } from './PushRuleEditor';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { settingsAtom, Settings } from '../../../state/settings';
|
||||
import { notificationSnoozeUntilAtom } from '../../../state/notificationSnooze';
|
||||
|
||||
@@ -81,7 +80,7 @@ function NotificationPresets() {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Quick Presets</Text>
|
||||
<Text size="L400">Quick presets</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<Box gap="300" style={{ padding: config.space.S300, flexWrap: 'wrap' }}>
|
||||
{PRESETS.map((preset) => (
|
||||
@@ -150,17 +149,6 @@ export function Notifications({ requestClose }: NotificationsProps) {
|
||||
<SpecialMessagesNotifications />
|
||||
<KeywordMessagesNotifications />
|
||||
<PushRuleEditor />
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Block Messages</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile description='This option has been moved to "Account > Block Users" section.' />
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
|
||||
@@ -333,10 +333,10 @@ export function PushRuleEditor() {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Advanced Push Rules</Text>
|
||||
<RuleSection title="Override Rules" kind={PushRuleKind.Override} rules={overrideRules} />
|
||||
<Text size="L400">Advanced push rules</Text>
|
||||
<RuleSection title="Override rules" kind={PushRuleKind.Override} rules={overrideRules} />
|
||||
<RuleSection
|
||||
title="Room Rules"
|
||||
title="Room rules"
|
||||
kind={PushRuleKind.RoomSpecific}
|
||||
rules={roomRules}
|
||||
addForm={
|
||||
@@ -348,7 +348,7 @@ export function PushRuleEditor() {
|
||||
}
|
||||
/>
|
||||
<RuleSection
|
||||
title="Sender Rules"
|
||||
title="Sender rules"
|
||||
kind={PushRuleKind.SenderSpecific}
|
||||
rules={senderRules}
|
||||
addForm={
|
||||
@@ -359,7 +359,7 @@ export function PushRuleEditor() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<RuleSection title="Underride Rules" kind={PushRuleKind.Underride} rules={underrideRules} />
|
||||
<RuleSection title="Underride rules" kind={PushRuleKind.Underride} rules={underrideRules} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export function SpecialMessagesNotifications() {
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="L400">Special Messages</Text>
|
||||
<Text size="L400">Special messages</Text>
|
||||
<Box gap="100">
|
||||
<Text size="T200">Badge: </Text>
|
||||
<Badge radii="300" variant="Success" fill="Solid">
|
||||
|
||||
@@ -63,7 +63,7 @@ function EmailNotification() {
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title="Email Notification"
|
||||
title="Email notifications"
|
||||
description={
|
||||
<>
|
||||
{result && !result.email && (
|
||||
@@ -161,7 +161,7 @@ function PauseNotifications() {
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title="Pause Notifications"
|
||||
title="Pause notifications"
|
||||
description={<span style={active ? { color: color.Warning.Main } : undefined}>{status}</span>}
|
||||
after={
|
||||
active ? (
|
||||
@@ -221,7 +221,7 @@ export function SystemNotification() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Desktop Notifications"
|
||||
title="Desktop notifications"
|
||||
description={
|
||||
notifPermission === 'denied' ? (
|
||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||
@@ -230,7 +230,7 @@ export function SystemNotification() {
|
||||
: 'Notifications are not supported by the system.'}
|
||||
</Text>
|
||||
) : (
|
||||
<span>Show desktop notifications when message arrive.</span>
|
||||
<span>Show desktop notifications when messages arrive.</span>
|
||||
)
|
||||
}
|
||||
after={
|
||||
@@ -263,8 +263,8 @@ export function SystemNotification() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Notification Sound"
|
||||
description="Play sound when new message arrive."
|
||||
title="Notification sound"
|
||||
description="Play a sound when a new message arrives."
|
||||
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
||||
/>
|
||||
{isNotificationSounds && (
|
||||
@@ -353,7 +353,7 @@ export function SystemNotification() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Quiet Hours"
|
||||
title="Quiet hours"
|
||||
description="Notifications are silenced between these times. Handles overnight (e.g. 11:00 PM – 8:00 AM)."
|
||||
after={<Switch value={quietHoursEnabled} onChange={setQuietHoursEnabled} />}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { IAuthData, MatrixError } from 'matrix-js-sdk';
|
||||
import { IAuthData, MatrixError, ValidatedAuthMetadata } from 'matrix-js-sdk';
|
||||
import { ILoginFlowsResponse } from 'matrix-js-sdk/lib/@types/auth';
|
||||
|
||||
export enum RegisterFlowStatus {
|
||||
@@ -44,6 +44,13 @@ export const parseRegisterErrResp = (matrixError: MatrixError): RegisterFlowsRes
|
||||
export type AuthFlows = {
|
||||
loginFlows: ILoginFlowsResponse;
|
||||
registerFlows: RegisterFlowsResponse;
|
||||
/**
|
||||
* The homeserver's OAuth 2.0 metadata (`/_matrix/client/v1/auth_metadata`),
|
||||
* when it uses native OAuth (MSC3861, e.g. matrix.org via MAS). The same
|
||||
* source the SDK uses to refresh tokens, so sign-in is only offered when
|
||||
* refresh will work too.
|
||||
*/
|
||||
authMetadata?: ValidatedAuthMetadata;
|
||||
};
|
||||
|
||||
const AuthFlowsContext = createContext<AuthFlows | null>(null);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { immersiveCallAtom } from '../state/callEmbed';
|
||||
import { useCallEmbed } from '../hooks/useCallEmbed';
|
||||
import { CallStatus } from '../features/call-status';
|
||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||
@@ -9,8 +11,10 @@ export function CallStatusRenderer() {
|
||||
const selectedRoom = useSelectedRoom();
|
||||
|
||||
const screenSize = useScreenSizeContext();
|
||||
// [Gitea #126] The full-screen call has its own controls.
|
||||
const immersive = useAtomValue(immersiveCallAtom);
|
||||
|
||||
if (!callEmbed) return null;
|
||||
if (!callEmbed || immersive) return null;
|
||||
|
||||
if (screenSize === ScreenSize.Mobile && callEmbed.roomId === selectedRoom) return null;
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { SSOLogin } from '../SSOLogin';
|
||||
import { TokenLogin } from './TokenLogin';
|
||||
import { OidcLogin } from './OidcLogin';
|
||||
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||
import { getOidcIssuer } from '../../../cs-api';
|
||||
import { OrDivider } from '../OrDivider';
|
||||
import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils';
|
||||
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
||||
@@ -41,7 +40,7 @@ export function Login() {
|
||||
const server = useAuthServer();
|
||||
const discovery = useAutoDiscoveryInfo();
|
||||
const { hashRouter } = useClientConfig();
|
||||
const { loginFlows } = useAuthFlows();
|
||||
const { loginFlows, authMetadata } = useAuthFlows();
|
||||
const [searchParams] = useSearchParams();
|
||||
const loginSearchParams = useLoginSearchParams(searchParams);
|
||||
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
|
||||
@@ -58,11 +57,13 @@ export function Login() {
|
||||
|
||||
const parsedFlows = useParsedLoginFlows(loginFlows.flows);
|
||||
|
||||
// Next-gen auth (MSC3861/MSC2965): when the server advertises an OIDC issuer in
|
||||
// its `.well-known` discovery, use the native OIDC flow and suppress the
|
||||
// password / legacy-SSO forms (those servers, e.g. MAS, reject `m.login.password`).
|
||||
const oidc = getOidcIssuer(discovery);
|
||||
if (oidc.issuer) {
|
||||
// Next-gen auth (MSC3861): when the homeserver serves OAuth metadata
|
||||
// (`/auth_metadata`, e.g. matrix.org via MAS), use the native OAuth flow and
|
||||
// suppress the password / legacy-SSO forms, as Element does. Detected the way
|
||||
// the SDK finds the auth server for refresh; the old `.well-known`
|
||||
// `m.authentication` key is gone from matrix.org. A legacy SSO `loginToken`
|
||||
// coming back from a redirect still completes below.
|
||||
if (authMetadata && !loginSearchParams.loginToken) {
|
||||
return (
|
||||
<Box direction="Column" gap="500">
|
||||
<Text as="h1" size="H2" priority="400">
|
||||
|
||||
@@ -4,6 +4,8 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { startOidcLogin } from '../oidc/oidcLoginUtil';
|
||||
|
||||
type OidcLoginProps = {
|
||||
/** Register: open the auth server's sign-up page. */
|
||||
register?: boolean;
|
||||
homeserverBaseUrl: string;
|
||||
serverName: string;
|
||||
};
|
||||
@@ -12,7 +14,7 @@ type OidcLoginProps = {
|
||||
* with the homeserver's OIDC provider and redirects to it. Mirrors `SSOLogin`'s
|
||||
* single-button styling.
|
||||
*/
|
||||
export function OidcLogin({ homeserverBaseUrl, serverName }: OidcLoginProps) {
|
||||
export function OidcLogin({ homeserverBaseUrl, serverName, register }: OidcLoginProps) {
|
||||
const [state, startLogin] = useAsyncCallback<void, unknown, Parameters<typeof startOidcLogin>>(
|
||||
useCallback(startOidcLogin, []),
|
||||
);
|
||||
@@ -25,7 +27,9 @@ export function OidcLogin({ homeserverBaseUrl, serverName }: OidcLoginProps) {
|
||||
<Box direction="Column" gap="300">
|
||||
{state.status === AsyncStatus.Error && (
|
||||
<Text size="T200" align="Center" style={{ color: color.Critical.Main }}>
|
||||
Failed to start single sign-on. Please try again.
|
||||
{register
|
||||
? 'Failed to open sign-up. Please try again.'
|
||||
: 'Failed to start single sign-on. Please try again.'}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
@@ -34,7 +38,7 @@ export function OidcLogin({ homeserverBaseUrl, serverName }: OidcLoginProps) {
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
onClick={() => startLogin(homeserverBaseUrl)}
|
||||
onClick={() => startLogin(homeserverBaseUrl, register ? 'create' : undefined)}
|
||||
disabled={ongoing}
|
||||
before={ongoing ? <Spinner size="200" variant="Secondary" /> : undefined}
|
||||
>
|
||||
|
||||
@@ -31,10 +31,14 @@ export const getOrRegisterClientId = async (
|
||||
/**
|
||||
* Begin the OAuth authorization-code flow: discover the auth server through
|
||||
* the homeserver, (re)register a client, remember the PKCE verifier + device
|
||||
* under a fresh `state` (oidcState), then redirect. The callback reads the
|
||||
* under a fresh `state` (oidcState), then redirect. `prompt: 'create'` opens
|
||||
* the auth server's sign-up page instead, where supported. The callback reads the
|
||||
* result from the query string (`response_mode=query`).
|
||||
*/
|
||||
export const startOidcLogin = async (homeserverBaseUrl: string): Promise<void> => {
|
||||
export const startOidcLogin = async (
|
||||
homeserverBaseUrl: string,
|
||||
prompt?: 'create',
|
||||
): Promise<void> => {
|
||||
const redirectUri = getOidcCallbackUrl();
|
||||
let issuer: string | undefined;
|
||||
try {
|
||||
@@ -52,8 +56,12 @@ export const startOidcLogin = async (homeserverBaseUrl: string): Promise<void> =
|
||||
redirectUri,
|
||||
};
|
||||
savePendingOidcLogin(state, pending);
|
||||
// `prompt=create` asks the auth server for its sign-up page (Register),
|
||||
// only where it says it supports that.
|
||||
const usePrompt =
|
||||
prompt && metadata.prompt_values_supported?.includes(prompt) ? prompt : undefined;
|
||||
window.location.assign(
|
||||
await oauth.generateAuthorizationCodeGrantUrl(state, redirectUri, 'query'),
|
||||
await oauth.generateAuthorizationCodeGrantUrl(state, redirectUri, 'query', usePrompt),
|
||||
);
|
||||
} catch (e) {
|
||||
// #67 — drop the cached client so the next attempt re-registers, but ONLY
|
||||
|
||||
@@ -12,6 +12,8 @@ import { SupportedUIAFlowsLoader } from '../../../components/SupportedUIAFlowsLo
|
||||
import { getLoginPath } from '../../pathUtils';
|
||||
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
||||
import { RegisterPathSearchParams } from '../../paths';
|
||||
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||
import { OidcLogin } from '../login/OidcLogin';
|
||||
|
||||
const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams =>
|
||||
useMemo(
|
||||
@@ -25,7 +27,8 @@ const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSea
|
||||
|
||||
export function Register() {
|
||||
const server = useAuthServer();
|
||||
const { loginFlows, registerFlows } = useAuthFlows();
|
||||
const { loginFlows, registerFlows, authMetadata } = useAuthFlows();
|
||||
const discovery = useAutoDiscoveryInfo();
|
||||
const [searchParams] = useSearchParams();
|
||||
const registerSearchParams = useRegisterSearchParams(searchParams);
|
||||
const { sso } = useParsedLoginFlows(loginFlows.flows);
|
||||
@@ -33,6 +36,27 @@ export function Register() {
|
||||
// redirect to /login because only that path handle m.login.token
|
||||
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
|
||||
|
||||
// Native OAuth servers (e.g. matrix.org via MAS) create accounts at the auth
|
||||
// server; /register here would only offer the legacy forms. Mirrors Login.
|
||||
if (authMetadata) {
|
||||
return (
|
||||
<Box direction="Column" gap="500">
|
||||
<Text as="h2" size="H2" priority="400">
|
||||
Register
|
||||
</Text>
|
||||
<OidcLogin
|
||||
register
|
||||
homeserverBaseUrl={discovery['m.homeserver'].base_url}
|
||||
serverName={server}
|
||||
/>
|
||||
<span data-spacing-node />
|
||||
<Text align="Center">
|
||||
Already have an account? <Link to={getLoginPath(server)}>Login</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="500">
|
||||
<Text as="h2" size="H2" priority="400">
|
||||
|
||||
@@ -1015,13 +1015,13 @@ function TauriUpdateFeature() {
|
||||
dismissToast(UPDATE_PROGRESS_TOAST);
|
||||
if (!failure) return;
|
||||
// Short on purpose; the full explanation and a Download installer button
|
||||
// are in Settings → General → App Updates.
|
||||
// are in Settings → General → App updates.
|
||||
const busy = failure.phase !== 'install' && isNetworkUpdateError(failure.message);
|
||||
setToast({
|
||||
id: UPDATE_FAILED_TOAST,
|
||||
iconSrc: Icons.Warning,
|
||||
displayName: 'Update didn’t install',
|
||||
body: `${busy ? 'The update server seems busy. ' : ''}Click to try again, or get the installer in Settings → General → App Updates.`,
|
||||
body: `${busy ? 'The update server seems busy. ' : ''}Click to try again, or get the installer in Settings → General → App updates.`,
|
||||
roomName: 'System',
|
||||
roomId: '',
|
||||
sticky: true,
|
||||
|
||||
@@ -261,6 +261,11 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
this.emit(CALL_ERROR_CHANGED);
|
||||
}
|
||||
|
||||
/** [Gitea #121] Answer the fork's expected-device check: join or not. */
|
||||
public sendDeviceDecision(join: boolean): void {
|
||||
this.call.transport.send('io.lotus.device_decision', { join }).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Press the fork's Reconnect / Try again. */
|
||||
public reconnect(): void {
|
||||
this.call.transport.send('io.lotus.reconnect', {}).catch(() => undefined);
|
||||
|
||||
@@ -257,6 +257,9 @@ export class CallEmbed {
|
||||
// sends io.lotus.inject_audio (only on an explicit user click), so
|
||||
// arming it for every call is safe.
|
||||
lotusAudioInject: 'true',
|
||||
// [Gitea #121] Before joining, the fork checks that the mic / output the
|
||||
// user last picked are plugged in, and asks us (ExpectedDevicesPrompt).
|
||||
lotusDeviceCheck: 'true',
|
||||
});
|
||||
|
||||
if (denoiseMode === 'ml') {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const store = new Map<string, string>();
|
||||
(globalThis as { sessionStorage?: unknown }).sessionStorage = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
store.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
store.delete(k);
|
||||
},
|
||||
};
|
||||
const m = await import('./expectedDevices');
|
||||
|
||||
test('[#121] parses the fork messages defensively', () => {
|
||||
assert.deepEqual(
|
||||
m.parseDeviceMissing({
|
||||
missing: [
|
||||
{ kind: 'audioinput', expected: 'Arctis 7', fallback: 'Laptop Mic' },
|
||||
{ kind: 'audiooutput', expected: 'Arctis 7', fallback: null },
|
||||
{ kind: 'videoinput', expected: 'Cam' },
|
||||
{ kind: 'audioinput' },
|
||||
],
|
||||
}),
|
||||
[
|
||||
{ kind: 'audioinput', expected: 'Arctis 7', fallback: 'Laptop Mic' },
|
||||
{ kind: 'audiooutput', expected: 'Arctis 7', fallback: null },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(m.parseDeviceMissing(undefined), []);
|
||||
assert.deepEqual(m.parseDeviceMissing({ missing: 'x' }), []);
|
||||
assert.equal(m.parseDeviceLost({ kind: 'nope' }), undefined);
|
||||
});
|
||||
|
||||
test('[#121] wording', () => {
|
||||
assert.deepEqual(
|
||||
m.describeMissing({ kind: 'audioinput', expected: 'Arctis 7', fallback: 'Laptop Mic' }),
|
||||
{
|
||||
title: "Your usual microphone isn't connected",
|
||||
expected: 'Arctis 7',
|
||||
fallback: 'Laptop Mic',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
m.describeLost({ kind: 'audiooutput', expected: 'Arctis 7', fallback: null }),
|
||||
'Arctis 7 disconnected — now using the default output.',
|
||||
);
|
||||
});
|
||||
|
||||
test('[#121] "Join anyway" is remembered for the session, per device set', () => {
|
||||
store.clear();
|
||||
const a = [{ kind: 'audioinput' as const, expected: 'Arctis 7', fallback: 'Laptop Mic' }];
|
||||
const b = [{ kind: 'audiooutput' as const, expected: 'Arctis 7', fallback: null }];
|
||||
assert.equal(m.isAcceptedThisSession(a), false);
|
||||
m.acceptForSession(a);
|
||||
assert.equal(m.isAcceptedThisSession(a), true);
|
||||
assert.equal(m.isAcceptedThisSession(b), false, 'a different device still asks');
|
||||
assert.equal(m.isAcceptedThisSession([...a, ...b]), false, 'more missing still asks');
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* [Gitea #121] The fork's expected-device check. Before joining, EC reports
|
||||
* the user's usual mic / output (the one they last picked) as missing; we ask
|
||||
* "Join anyway" or "Cancel". In a call, losing it is a toast. Pure helpers.
|
||||
*/
|
||||
|
||||
export type DeviceKind = 'audioinput' | 'audiooutput';
|
||||
export type MissingDevice = { kind: DeviceKind; expected: string; fallback: string | null };
|
||||
|
||||
const isKind = (k: unknown): k is DeviceKind => k === 'audioinput' || k === 'audiooutput';
|
||||
|
||||
const parseOne = (v: unknown): MissingDevice | undefined => {
|
||||
if (!v || typeof v !== 'object') return undefined;
|
||||
const { kind, expected, fallback } = v as Record<string, unknown>;
|
||||
if (!isKind(kind) || typeof expected !== 'string' || !expected) return undefined;
|
||||
return { kind, expected, fallback: typeof fallback === 'string' && fallback ? fallback : null };
|
||||
};
|
||||
|
||||
/** `io.lotus.device_missing` → the missing devices (empty if malformed). */
|
||||
export const parseDeviceMissing = (data: unknown): MissingDevice[] => {
|
||||
const list = (data as { missing?: unknown } | undefined)?.missing;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.map(parseOne).filter((d): d is MissingDevice => !!d);
|
||||
};
|
||||
|
||||
/** `io.lotus.device_lost` → the device that went away. */
|
||||
export const parseDeviceLost = (data: unknown): MissingDevice | undefined => parseOne(data);
|
||||
|
||||
const what = (kind: DeviceKind): string => (kind === 'audioinput' ? 'microphone' : 'audio output');
|
||||
|
||||
/** "Your usual microphone **Arctis 7** isn't connected." lines + the fallback. */
|
||||
export const describeMissing = (
|
||||
d: MissingDevice,
|
||||
): { title: string; expected: string; fallback: string } => ({
|
||||
title: `Your usual ${what(d.kind)} isn't connected`,
|
||||
expected: d.expected,
|
||||
fallback:
|
||||
d.fallback ?? (d.kind === 'audioinput' ? 'the default microphone' : 'the default output'),
|
||||
});
|
||||
|
||||
export const describeLost = (d: MissingDevice): string =>
|
||||
`${d.expected} disconnected — now using ${
|
||||
d.fallback ?? (d.kind === 'audioinput' ? 'the default microphone' : 'the default output')
|
||||
}.`;
|
||||
|
||||
// "Join anyway" holds for the rest of the session for the same device(s).
|
||||
const ACCEPTED_KEY = 'lotus-device-check-accepted';
|
||||
const keyOf = (missing: MissingDevice[]): string =>
|
||||
missing
|
||||
.map((d) => `${d.kind}:${d.expected}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
|
||||
const readAccepted = (): string[] => {
|
||||
try {
|
||||
const v: unknown = JSON.parse(sessionStorage.getItem(ACCEPTED_KEY) ?? '[]');
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const isAcceptedThisSession = (missing: MissingDevice[]): boolean =>
|
||||
readAccepted().includes(keyOf(missing));
|
||||
|
||||
export const acceptForSession = (missing: MissingDevice[]): void => {
|
||||
try {
|
||||
const list = readAccepted();
|
||||
const k = keyOf(missing);
|
||||
if (!list.includes(k)) sessionStorage.setItem(ACCEPTED_KEY, JSON.stringify([...list, k]));
|
||||
} catch {
|
||||
// storage unavailable: ask again next time
|
||||
}
|
||||
};
|
||||
@@ -18,3 +18,6 @@ export const callEmbedAtom = atom<CallEmbed | undefined, [CallEmbed | undefined]
|
||||
);
|
||||
|
||||
export const callChatAtom = atom<boolean>(false);
|
||||
|
||||
/** [Gitea #126] The call is full screen (phone turned sideways). */
|
||||
export const immersiveCallAtom = atom<boolean>(false);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
/**
|
||||
* [Gitea #113] Session ids the stale-sessions nudge's Review asked Settings →
|
||||
* Devices to tick for sign-out. Consumed (and cleared) by OtherDevices.
|
||||
*/
|
||||
export const preselectSignOutAtom = atom<string[]>([]);
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
parseNudgeRecord,
|
||||
SecurityState,
|
||||
shouldShowNudge,
|
||||
pickNudge,
|
||||
staleSessions,
|
||||
staleSessionsText,
|
||||
} from './securityNudge';
|
||||
|
||||
const healthy: SecurityState = {
|
||||
@@ -79,3 +82,45 @@ test('stored record: round trip, and garbage starts a fresh grace period', () =>
|
||||
assert.deepEqual(parseNudgeRecord(bad, t0), { firstSeen: t0, dismissals: {} }, bad);
|
||||
}
|
||||
});
|
||||
|
||||
test('[#113] stale sessions: 90+ days unused, never the current one', () => {
|
||||
const now = Date.UTC(2026, 9, 25);
|
||||
const day = 86_400_000;
|
||||
const devices = [
|
||||
{ device_id: 'ME', last_seen_ts: now - 400 * day },
|
||||
{ device_id: 'OLD', last_seen_ts: now - 91 * day },
|
||||
{ device_id: 'EDGE', last_seen_ts: now - 90 * day },
|
||||
{ device_id: 'RECENT', last_seen_ts: now - 89 * day },
|
||||
{ device_id: 'UNKNOWN' },
|
||||
{ device_id: 'NULL', last_seen_ts: null },
|
||||
];
|
||||
assert.deepEqual(
|
||||
staleSessions(devices, 'ME', now).map((d) => d.device_id),
|
||||
['OLD', 'EDGE'],
|
||||
);
|
||||
});
|
||||
|
||||
test('[#113] wording follows the count', () => {
|
||||
assert.match(
|
||||
staleSessionsText(1),
|
||||
/^You have a session you haven't used in 3 months\. Signing it out/,
|
||||
);
|
||||
assert.match(
|
||||
staleSessionsText(3),
|
||||
/^You have 3 sessions you haven't used in 3 months\. Signing them out/,
|
||||
);
|
||||
});
|
||||
|
||||
test('[#113] pickNudge: security first, stale sessions when that one is snoozed or absent', () => {
|
||||
const now = 10 * 86_400_000;
|
||||
const fresh = { firstSeen: 0, dismissals: {} };
|
||||
assert.equal(pickNudge('verify-device', 2, fresh, now), 'verify-device');
|
||||
assert.equal(pickNudge(undefined, 2, fresh, now), 'stale-sessions');
|
||||
assert.equal(pickNudge(undefined, 0, fresh, now), undefined);
|
||||
const snoozed = dismissNudge('verify-device', fresh, now);
|
||||
assert.equal(pickNudge('verify-device', 2, snoozed, now), 'stale-sessions');
|
||||
const bothSnoozed = dismissNudge('stale-sessions', snoozed, now);
|
||||
assert.equal(pickNudge('verify-device', 2, bothSnoozed, now), undefined);
|
||||
// inside the 24 h grace period nothing shows
|
||||
assert.equal(pickNudge(undefined, 2, { firstSeen: now, dismissals: {} }, now), undefined);
|
||||
});
|
||||
|
||||
@@ -9,12 +9,15 @@
|
||||
* - setup-backup: no key backup at all (includes accounts that never set up
|
||||
* cross-signing: the setup flow does both).
|
||||
*
|
||||
* - stale-sessions (#113, lowest priority): sessions of this account not used
|
||||
* for 90+ days; Review opens Settings → Devices with them ticked to sign out.
|
||||
*
|
||||
* Anything not yet known (crypto still loading) → no nudge. Timing: not in
|
||||
* the first 24 h on a device (onboarding), "Not now" snoozes 7 days, three
|
||||
* dismissals of a nudge stop it for good; a healthy device never sees one.
|
||||
*/
|
||||
|
||||
export type NudgeKind = 'verify-device' | 'connect-backup' | 'setup-backup';
|
||||
export type NudgeKind = 'verify-device' | 'connect-backup' | 'setup-backup' | 'stale-sessions';
|
||||
|
||||
export type SecurityState = {
|
||||
/** m.cross_signing.master account data present. */
|
||||
@@ -38,6 +41,51 @@ export const nudgeFor = (s: SecurityState): NudgeKind | undefined => {
|
||||
return s.backupActive ? undefined : 'connect-backup';
|
||||
};
|
||||
|
||||
/** [Gitea #113] A session counts as stale after this long unused. */
|
||||
export const STALE_SESSION_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type SessionLike = { device_id: string; last_seen_ts?: number | null };
|
||||
|
||||
/**
|
||||
* Pure: other sessions of this account not seen for STALE_SESSION_MS. Never
|
||||
* the current session; a session with no last-seen time is left alone (we
|
||||
* can't tell it's unused).
|
||||
*/
|
||||
export const staleSessions = <T extends SessionLike>(
|
||||
devices: T[],
|
||||
currentDeviceId: string | undefined,
|
||||
now: number,
|
||||
): T[] =>
|
||||
devices.filter(
|
||||
(d) =>
|
||||
d.device_id !== currentDeviceId &&
|
||||
typeof d.last_seen_ts === 'number' &&
|
||||
now - d.last_seen_ts >= STALE_SESSION_MS,
|
||||
);
|
||||
|
||||
/** "1 session" / "3 sessions you haven't used in 3 months". */
|
||||
export const staleSessionsText = (count: number): string =>
|
||||
`You have ${count === 1 ? 'a session' : `${count} sessions`} you haven't used in 3 months. Signing ${
|
||||
count === 1 ? 'it' : 'them'
|
||||
} out keeps your account safe if an old device is lost.`;
|
||||
|
||||
/**
|
||||
* The nudge to show: the device-security one first, then stale sessions, each
|
||||
* only if its own snooze allows (so a snoozed security nudge doesn't hide the
|
||||
* stale-sessions one, and vice versa).
|
||||
*/
|
||||
export const pickNudge = (
|
||||
security: NudgeKind | undefined,
|
||||
staleCount: number,
|
||||
record: NudgeRecord,
|
||||
now: number,
|
||||
): NudgeKind | undefined => {
|
||||
const candidates: NudgeKind[] = [];
|
||||
if (security) candidates.push(security);
|
||||
if (staleCount > 0) candidates.push('stale-sessions');
|
||||
return candidates.find((k) => shouldShowNudge(k, record, now));
|
||||
};
|
||||
|
||||
export const NUDGE_GRACE_MS = 24 * 60 * 60 * 1000;
|
||||
export const NUDGE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export const NUDGE_MAX_DISMISSALS = 3;
|
||||
@@ -87,6 +135,11 @@ export const NUDGE_COPY: Record<NudgeKind, { text: string; action: string }> = {
|
||||
text: "This device isn't using your key backup yet. Connect it so your encrypted messages stay readable if you lose it.",
|
||||
action: 'Connect',
|
||||
},
|
||||
'stale-sessions': {
|
||||
// The text depends on the count: see staleSessionsText.
|
||||
text: '',
|
||||
action: 'Review',
|
||||
},
|
||||
'setup-backup': {
|
||||
text: "Your encryption keys aren't backed up. Set up key backup so you don't lose your encrypted messages if you log out or lose this device.",
|
||||
action: 'Set up',
|
||||
|
||||
@@ -29,6 +29,17 @@ const copyFiles = {
|
||||
dest: 'public/element-call',
|
||||
rename: { stripBase: 4 },
|
||||
},
|
||||
{
|
||||
// [Gitea #201] Element Call's config. Since v0.28 the embedded package
|
||||
// ships none (its defaults are an 18 s delayed leave), so ship the Lotus
|
||||
// values: keep the call membership 60 s through a homeserver stall
|
||||
// (element-call lotus #46). On the web the deploy never overwrites the
|
||||
// server's own public/element-call/config.json (rsync excludes it); this
|
||||
// copy is what the desktop app and fresh installs get.
|
||||
src: 'build/element-call/config.json',
|
||||
dest: 'public/element-call',
|
||||
rename: { stripBase: 2 },
|
||||
},
|
||||
{
|
||||
src: 'config.json',
|
||||
dest: '',
|
||||
|
||||
Reference in New Issue
Block a user