Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 5d15013a27 feat(settings): quick wins from the settings audit (#453)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m18s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 24m56s
- #456 Copy pass: sentence case for every setting title and section,
  plainer names ("Hide joins and leaves", "Link previews", "Emoji
  style: Twemoji", "Rejoin calls after a restart", "Also use the
  browser's noise suppression", "Push-to-talk key"...) and grammar fixes
  ("when messages arrive", "Play a sound when a new message arrives",
  blocked users "Enter their Matrix ID"). Labels only: no setting is
  renamed in storage.
- #455 One theme control: Theme: System / Light / Dark. System shows the
  light and dark variant pickers, Light or Dark one picker of that kind.
  Replaces the System switch plus a Theme dropdown that was greyed out
  whenever the switch was on. Uses the existing settings (no migration);
  picking a variant also remembers it for System.
- #457 Remove the "Block Messages" stub from Notifications; the GIF
  picker row only appears in builds with a GIF key.
- #458 (part 1) "Export/Import Messages Data" -> "Export/Import
  encryption keys", with a sentence on when you need it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 01:37:37 -04:00
jared 0d4f2e4a87 Merge pull request 'feat(security): nudge to sign out sessions unused for 3 months (#113)' (#452) from feat/stale-sessions into lotus
CI / Build & Quality Checks (push) Successful in 4m7s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 21s
CI / Trigger Desktop Build (push) Successful in 12s
CI / Playwright smoke (e2e) (push) Successful in 23m25s
2026-10-25 00:43:14 -04:00
Lotus CIandClaude Opus 5.5 c907b0ba99 feat(security): nudge to sign out sessions unused for 3 months (#113)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 4m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Playwright smoke (e2e) (pull_request) Successful in 24m20s
Sessions of this account not seen for 90+ days (never the current one; a
session with no last-seen time is left alone) get a nudge in the existing
security strip, after the device-security ones and with the same rules
("Not now" snoozes 7 days, gone after 3 dismissals; a snoozed security
nudge no longer hides it): "You have 3 sessions you haven't used in 3
months. Signing them out keeps your account safe if an old device is
lost." [Review]

Review opens Settings -> Devices with those sessions ticked for sign-out
(one confirm, the usual re-auth incl. SSO); they are listed first and
marked "Inactive for 3+ months". OIDC accounts sign out on the
provider's dashboard, so nothing is ticked there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 23:52:16 -04:00
jared 02c852e194 Merge pull request 'feat(call): full-screen call when a phone is turned sideways (#126)' (#451) from feat/landscape-call into lotus
CI / Build & Quality Checks (push) Successful in 3m27s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 16s
CI / Trigger Desktop Build (push) Successful in 15s
CI / Playwright smoke (e2e) (push) Successful in 20m32s
2026-10-24 23:13:16 -04:00
Lotus CIandClaude Opus 5.5 6e6213a43b feat(call): full-screen call when a phone is turned sideways (#126)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m31s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 18m48s
Landscape, at most 500px tall, touch input (a sideways phone is ~840px
wide, the Tablet size class, so the trigger is the screen shape), in this
room's joined call, chat closed: the call takes the whole screen. No room
header, sidebar or call-status bar; safe-area insets respected.

- Taps inside the call frame never reach the app, so while the controls
  are hidden a transparent layer covers the call: the first tap shows the
  call bar plus Chat / Exit full screen, which hide again after 4 s
  without a touch; with them up the call underneath stays usable.
- In an Android browser tab the first tap also enters the browser's own
  full screen (hides the address bar); left again on rotate back, hang
  up or exit. No effect on iPhone or the installed app.
- Exit full screen lasts until the phone is upright again.
- The frame is transparent (room wallpaper), so in full screen it gets an
  opaque base colour under the wallpaper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 22:50:01 -04:00
jared 5379eb046d Merge pull request 'feat(call): ask before joining when your usual mic / headphones are missing (#121)' (#450) from feat/expected-devices into lotus
CI / Build & Quality Checks (push) Successful in 3m18s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Successful in 19m44s
2026-10-24 21:30:20 -04:00
Lotus CIandClaude Opus 5.5 17ebddb159 chore(deps): Element Call 0.28.0-lotus.2 (expected-device check, #121)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m25s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 21s
CI / Playwright smoke (e2e) (pull_request) Successful in 17m34s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 19:58:56 -04:00
Lotus CIandClaude Opus 5.5 7bd1c14a3c feat(call): ask before joining when your usual mic / headphones are missing (#121)
The fork now checks, before joining (lotusDeviceCheck=1), that the mic /
output the user last picked are plugged in. When one is missing it asks
us (io.lotus.device_missing): "Your usual microphone isn't connected.
Arctis 7 isn't connected. Join with Laptop Mic instead?" [Join anyway]
[Cancel]. Join anyway isn't asked again this session for the same
device(s); Cancel closes the call before it starts. A device lost during
a call is a toast ("Arctis 7 disconnected — now using Laptop Mic").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 19:49:50 -04:00
jared a58684958c Merge pull request 'feat(auth): offer native OAuth sign-in on servers like matrix.org' (#449) from feat/oauth-detect into lotus
CI / Build & Quality Checks (push) Successful in 3m9s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 16m31s
2026-10-24 16:36:37 -04:00
Lotus CIandClaude Opus 5.5 3c3bb2e41c feat(auth): offer native OAuth sign-in on servers like matrix.org
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m41s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m45s
The login page looked for an OAuth issuer in .well-known
(m.authentication), which matrix.org no longer publishes, so its users
got the password / SSO compatibility forms. Detect native OAuth the way
the SDK finds the auth server for refresh: load /auth_metadata next to
the login flows. When present:
- Login shows "Continue with <server>" only (as Element does); a legacy
  SSO loginToken coming back from a redirect still completes.
- Register opens the auth server's sign-up page (prompt=create, where
  prompt_values_supported says so) instead of the legacy forms.
Servers without it (Lotus) are unchanged.

Checked against the live servers: matrix.org login and register show
"Continue with matrix.org" and no password form; matrix.lotusguild.org
keeps its password form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 16:15:24 -04:00
jared 88446e8953 Merge pull request 'chore(deps): Element Call 0.28.0-lotus.1 (#201)' (#448) from chore/ec-0.28 into lotus
CI / Build & Quality Checks (push) Successful in 3m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 12s
CI / Playwright smoke (e2e) (push) Successful in 21m51s
2026-10-24 03:02:12 -04:00
Lotus CIandClaude Opus 5.5 f6bad9eb58 test(e2e): #212 waits for the timeline to settle before scrolling up
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 4m8s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 18m44s
The wheel ran as soon as the composer appeared; when it landed during the
room's initial jump-to-bottom it was snapped back and "Jump to Latest"
never showed. Seen ~1 in 5 runs since matrix-js-sdk 44 shifted the load
timing (failed the post-merge e2e run of #447 on lotus). Wait for the
newest message, then retry the scroll until the button appears.
10/10 locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 02:35:58 -04:00
Lotus CIandClaude Opus 5.5 93c7dcd749 chore(deps): Element Call 0.25.0-lotus.29 -> 0.28.0-lotus.1 (#201)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
CI / Secret scan (gitleaks) (pull_request) Canceled after 0s
CI / Docker image build & smoke test (pull_request) Canceled after 0s
CI / Playwright smoke (e2e) (pull_request) Canceled after 0s
The fork synced to upstream v0.28.0 (element-call #61): matrix-js-sdk
develop with the SDK 44 MatrixRTC changes, delayed-leave delegation.

v0.28 renamed the delayed-leave config keys and its embedded build ships
no config.json (defaults: 18 s). Ship the Lotus values with the build
(build/element-call/config.json -> public/element-call/config.json) so
the desktop app keeps the 60 s grace from element-call lotus #46. The web
deploy keeps the server's own config.json (rsync excludes it); that file
already carries both the old and new keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 02:06:43 -04:00
jared 43e166b879 Merge pull request 'chore(deps): matrix-js-sdk 41.7 → 44.0 (#201)' (#447) from chore/sdk-44 into lotus
CI / Build & Quality Checks (push) Successful in 3m16s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 13s
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Failing after 18m55s
2026-10-24 01:31:40 -04:00
51 changed files with 1188 additions and 204 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"matrix_rtc_session": {
"delayed_leave": { "delay_ms": 60000, "restart_ms": 15000 }
}
}
+10 -2
View File
@@ -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');
+26
View File
@@ -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]);
});
});
+1 -1
View File
@@ -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 }) => {
+98
View File
@@ -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();
});
});
+4 -4
View File
@@ -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
View File
@@ -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",
+7 -1
View File
@@ -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;
+18 -3
View File
@@ -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}
+37 -2
View File
@@ -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&apos;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);
});
+41
View File
@@ -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;
+26 -5
View File
@@ -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">
+1 -1
View File
@@ -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}
/>
+1 -1
View File
@@ -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"
+152 -101
View File
@@ -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 &amp; 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} />}
/>
+8 -1
View File
@@ -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);
+5 -1
View File
@@ -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;
+8 -7
View File
@@ -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">
+7 -3
View File
@@ -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}
>
+11 -3
View File
@@ -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
+25 -1
View File
@@ -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">
+2 -2
View File
@@ -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,
+5
View File
@@ -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);
+3
View File
@@ -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');
});
+74
View File
@@ -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
}
};
+3
View File
@@ -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);
+7
View File
@@ -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[]>([]);
+45
View File
@@ -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);
});
+54 -1
View File
@@ -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',
+11
View File
@@ -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: '',