Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a24b168cb | ||
|
|
71b7619082 | ||
|
|
e4591f669b | ||
|
|
185970bc5a | ||
|
|
d5b0b40b17 | ||
|
|
8cdf192b56 | ||
|
|
222927b558 | ||
|
|
b944456ab5 | ||
|
|
02bd356a89 | ||
|
|
3b3bf8abac | ||
|
|
22d5aea5e6 | ||
|
|
826fc46229 | ||
|
|
b93e4bcef9 | ||
|
|
1af491cabb | ||
|
|
449d0c740e | ||
|
|
a7495fc509 | ||
|
|
299da879fb | ||
|
|
f8fa1ca5ed | ||
|
|
5d15013a27 | ||
|
|
378b40fabe | ||
|
|
0d4f2e4a87 | ||
|
|
c907b0ba99 | ||
|
|
02c852e194 | ||
|
|
6e6213a43b | ||
|
|
5379eb046d | ||
|
|
17ebddb159 | ||
|
|
7bd1c14a3c | ||
|
|
a58684958c | ||
|
|
3c3bb2e41c | ||
|
|
88446e8953 | ||
|
|
f6bad9eb58 | ||
|
|
93c7dcd749 | ||
|
|
43e166b879 | ||
|
|
70206d6033 | ||
|
|
bfed37bf50 | ||
|
|
c2c43eda99 | ||
|
|
45c3b16694 | ||
|
|
5f3c5d370a | ||
|
|
4120ccdbcc | ||
|
|
ed5bf997b4 | ||
|
|
878a34712a | ||
|
|
62672787a0 | ||
|
|
ac1a1c9a48 | ||
|
|
8645a55769 |
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"matrix_rtc_session": {
|
||||
"delayed_leave": { "delay_ms": 60000, "restart_ms": 15000 }
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,31 @@
|
||||
- navigation "Settings sections":
|
||||
- button "General" [pressed]:
|
||||
- img
|
||||
- paragraph: General
|
||||
- button "Account":
|
||||
- button "Account" [pressed]:
|
||||
- img
|
||||
- paragraph: Account
|
||||
- button "Appearance":
|
||||
- img
|
||||
- paragraph: Appearance
|
||||
- button "Messages":
|
||||
- img
|
||||
- paragraph: Messages
|
||||
- button "Notifications":
|
||||
- img
|
||||
- paragraph: Notifications
|
||||
- button "Devices":
|
||||
- button "Calls & audio":
|
||||
- img
|
||||
- paragraph: Devices
|
||||
- button "Emojis & Stickers":
|
||||
- paragraph: Calls & audio
|
||||
- button "Privacy & security":
|
||||
- img
|
||||
- paragraph: Emojis & Stickers
|
||||
- button "Developer Tools":
|
||||
- paragraph: Privacy & security
|
||||
- button "Data & storage":
|
||||
- img
|
||||
- paragraph: Developer Tools
|
||||
- paragraph: Data & storage
|
||||
- button "Emojis & stickers":
|
||||
- img
|
||||
- paragraph: Emojis & stickers
|
||||
- button "Advanced":
|
||||
- img
|
||||
- paragraph: Advanced
|
||||
- button "About":
|
||||
- img
|
||||
- paragraph: About
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ensureUser, hsReachable, loginUI, uniq } from './localHs';
|
||||
|
||||
// [Gitea #201 KE-1] When the server keeps rejecting this device's key uploads
|
||||
// with "One time key … already exists", the app says so and offers the fix.
|
||||
const CONFLICT = {
|
||||
errcode: 'M_UNKNOWN',
|
||||
error: 'One time key signed_curve25519:AAAAAAAAAA0 already exists. Old key: {}; new key: {}',
|
||||
};
|
||||
|
||||
test.describe('key upload recovery (#201)', () => {
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||
});
|
||||
|
||||
test('a diverged device sees the banner, and Fix opens the logout dialog', async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
const user = await ensureUser(uniq('kup'));
|
||||
let rejected = 0;
|
||||
await page.route(/\/_matrix\/client\/v3\/keys\/upload/, async (route) => {
|
||||
rejected += 1;
|
||||
await route.fulfill({ status: 400, json: CONFLICT });
|
||||
});
|
||||
await loginUI(page, user);
|
||||
|
||||
const banner = page
|
||||
.getByRole('alert')
|
||||
.filter({ hasText: /Encryption on this device is out of sync/ });
|
||||
await expect(banner).toBeVisible({ timeout: 200_000 });
|
||||
console.log(`key uploads rejected: ${rejected}`);
|
||||
expect(rejected).toBeGreaterThanOrEqual(3);
|
||||
|
||||
await banner.getByRole('button', { name: 'Details' }).click();
|
||||
await expect(banner).toContainText('Settings → Privacy & security');
|
||||
await expect(banner).toContainText('already exists');
|
||||
|
||||
await banner.getByRole('button', { name: /Fix: sign out/ }).click();
|
||||
await expect(page.getByRole('dialog').getByText('Logout').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('a healthy device never sees it', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const user = await ensureUser(uniq('kok'));
|
||||
await loginUI(page, user);
|
||||
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
|
||||
await page.waitForTimeout(5000);
|
||||
await expect(page.getByText(/Encryption on this device is out of sync/)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
@@ -407,6 +415,64 @@ test.describe('local homeserver regression', () => {
|
||||
.toBe(status);
|
||||
});
|
||||
|
||||
test('status: applies at once, quick changes fold into one write (slow-save report)', async ({
|
||||
page,
|
||||
}) => {
|
||||
let lastOk = 0;
|
||||
const sent: string[] = [];
|
||||
await page.route(/\/presence\/[^/]+\/status/, (route) => {
|
||||
if (route.request().method() !== 'PUT') return route.continue();
|
||||
const now = Date.now();
|
||||
if (now - lastOk < 10_000) {
|
||||
return route.fulfill({
|
||||
status: 429,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
errcode: 'M_LIMIT_EXCEEDED',
|
||||
retry_after_ms: 10_000 - (now - lastOk),
|
||||
}),
|
||||
});
|
||||
}
|
||||
lastOk = now;
|
||||
sent.push(JSON.parse(route.request().postData() ?? '{}').status_msg ?? '');
|
||||
return route.continue();
|
||||
});
|
||||
await loginUI(page, alice);
|
||||
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||
await page.getByText('Account', { exact: true }).first().click();
|
||||
const input = page.getByLabel('Status message');
|
||||
const save = input.locator('xpath=ancestor::form[1]').getByRole('button', { name: 'Save' });
|
||||
|
||||
// Use up the 10 s budget, then pick three quick statuses in a row.
|
||||
await input.fill(uniq('first '));
|
||||
await save.click();
|
||||
await expect(save).toBeDisabled(); // applied: nothing left to save, no spinner wait
|
||||
const quick = page.getByRole('group', { name: 'Quick statuses' });
|
||||
await quick.getByRole('button', { name: /Lunch/ }).click();
|
||||
await quick.getByRole('button', { name: /Watching/ }).click();
|
||||
await quick.getByRole('button', { name: /AFK/ }).click();
|
||||
await expect(input).toHaveValue('😴 AFK');
|
||||
await expect(save).toBeDisabled();
|
||||
await expect(page.getByText(/Status set\. Sharing it with others/)).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await api<{ status_msg?: string }>(
|
||||
'GET',
|
||||
`/_matrix/client/v3/presence/${enc(alice.userId)}/status`,
|
||||
alice.token,
|
||||
)
|
||||
).status_msg,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe('😴 AFK');
|
||||
// The middle choices were superseded while waiting, never sent.
|
||||
expect(sent.filter((m) => /Lunch|Watching/.test(m))).toEqual([]);
|
||||
await expect(page.getByText(/Status set\. Sharing it with others/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('touch: long-press opens the message action sheet (#166)', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ ...devices['Pixel 7'] });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { test, expect, Page, Route, BrowserContext } from '@playwright/test';
|
||||
import { HS, PASSWORD, api, enc, ensureUser, hsReachable, uniq } from './localHs';
|
||||
|
||||
// Native OAuth sign-in on matrix-js-sdk 44 (Gitea #201 SDK upgrade): login,
|
||||
// refresh on an expired token, the #16 two-tab refresh race, and logout
|
||||
// revocation. The homeserver is the real local Synapse; the auth server is
|
||||
// faked here and hands out REAL Synapse tokens for the device the client asked
|
||||
// for, so sync works after every step. "Expiry" is simulated by answering
|
||||
// M_UNKNOWN_TOKEN for a token we mark expired.
|
||||
const AUTH = 'https://auth.fake.test';
|
||||
|
||||
type Fake = {
|
||||
prompts: (string | null)[];
|
||||
registered: number;
|
||||
tokenCalls: { grant: string; refresh?: string }[];
|
||||
revoked: string[];
|
||||
expired: Set<string>;
|
||||
issuedAccess: string[];
|
||||
};
|
||||
|
||||
const b64url = (buf: Buffer) =>
|
||||
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
async function synapseToken(localpart: string, deviceId: string): Promise<string> {
|
||||
const res = await api<{ access_token: string }>('POST', '/_matrix/client/v3/login', undefined, {
|
||||
type: 'm.login.password',
|
||||
identifier: { type: 'm.id.user', user: localpart },
|
||||
password: PASSWORD,
|
||||
device_id: deviceId,
|
||||
});
|
||||
return res.access_token;
|
||||
}
|
||||
|
||||
async function fakeOAuth(context: BrowserContext, localpart: string): Promise<Fake> {
|
||||
const fake: Fake = {
|
||||
prompts: [],
|
||||
registered: 0,
|
||||
tokenCalls: [],
|
||||
revoked: [],
|
||||
expired: new Set(),
|
||||
issuedAccess: [],
|
||||
};
|
||||
const codes = new Map<string, { challenge: string; deviceId: string }>();
|
||||
const refreshes = new Map<string, string>(); // live refresh token -> deviceId (rotating)
|
||||
let n = 0;
|
||||
const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' };
|
||||
|
||||
const issue = async (deviceId: string) => {
|
||||
n += 1;
|
||||
const access = await synapseToken(localpart, deviceId);
|
||||
const refresh = `r${n}`;
|
||||
refreshes.set(refresh, deviceId);
|
||||
fake.issuedAccess.push(access);
|
||||
return { token_type: 'Bearer', access_token: access, refresh_token: refresh, expires_in: 300 };
|
||||
};
|
||||
|
||||
// Homeserver: advertise the issuer, serve auth metadata, and "expire" tokens.
|
||||
await context.route(`${HS}/.well-known/matrix/client`, (route) =>
|
||||
route.fulfill({
|
||||
headers: cors,
|
||||
json: { 'm.homeserver': { base_url: HS }, 'm.authentication': { issuer: `${AUTH}/` } },
|
||||
}),
|
||||
);
|
||||
await context.route(`${HS}/_matrix/client/v1/auth_metadata`, (route) =>
|
||||
route.fulfill({
|
||||
headers: cors,
|
||||
json: {
|
||||
issuer: `${AUTH}/`,
|
||||
authorization_endpoint: `${AUTH}/authorize`,
|
||||
token_endpoint: `${AUTH}/token`,
|
||||
registration_endpoint: `${AUTH}/register`,
|
||||
revocation_endpoint: `${AUTH}/revoke`,
|
||||
response_types_supported: ['code'],
|
||||
response_modes_supported: ['query', 'fragment'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
prompt_values_supported: ['create'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await context.route(`${HS}/_matrix/**`, async (route: Route) => {
|
||||
const auth = route.request().headers().authorization ?? '';
|
||||
const token = auth.replace(/^Bearer /, '');
|
||||
if (token && fake.expired.has(token)) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
headers: cors,
|
||||
json: { errcode: 'M_UNKNOWN_TOKEN', error: 'Token expired', soft_logout: true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
// Auth server.
|
||||
await context.route(`${AUTH}/**`, async (route: Route) => {
|
||||
const req = route.request();
|
||||
const url = new URL(req.url());
|
||||
if (req.method() === 'OPTIONS') {
|
||||
await route.fulfill({ status: 204, headers: cors });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/register') {
|
||||
fake.registered += 1;
|
||||
await route.fulfill({ headers: cors, json: { client_id: 'fake-client' } });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/authorize') {
|
||||
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()}`;
|
||||
codes.set(code, { challenge: url.searchParams.get('code_challenge')!, deviceId: deviceId! });
|
||||
const back = new URL(url.searchParams.get('redirect_uri')!);
|
||||
back.searchParams.set('code', code);
|
||||
back.searchParams.set('state', url.searchParams.get('state')!);
|
||||
await route.fulfill({ status: 302, headers: { location: back.href } });
|
||||
return;
|
||||
}
|
||||
const form = new URLSearchParams(req.postData() ?? '');
|
||||
if (url.pathname === '/token') {
|
||||
const grant = form.get('grant_type') ?? '';
|
||||
fake.tokenCalls.push({ grant, refresh: form.get('refresh_token') ?? undefined });
|
||||
if (grant === 'authorization_code') {
|
||||
const pending = codes.get(form.get('code') ?? '');
|
||||
const verifier = form.get('code_verifier') ?? '';
|
||||
if (
|
||||
!pending ||
|
||||
b64url(createHash('sha256').update(verifier).digest()) !== pending.challenge
|
||||
) {
|
||||
await route.fulfill({ status: 400, headers: cors, json: { error: 'invalid_grant' } });
|
||||
return;
|
||||
}
|
||||
codes.delete(form.get('code')!);
|
||||
await route.fulfill({ headers: cors, json: await issue(pending.deviceId) });
|
||||
return;
|
||||
}
|
||||
if (grant === 'refresh_token') {
|
||||
const deviceId = refreshes.get(form.get('refresh_token') ?? '');
|
||||
if (!deviceId) {
|
||||
// rotating refresh tokens: a used one is dead
|
||||
await route.fulfill({ status: 400, headers: cors, json: { error: 'invalid_grant' } });
|
||||
return;
|
||||
}
|
||||
refreshes.delete(form.get('refresh_token')!);
|
||||
await route.fulfill({ headers: cors, json: await issue(deviceId) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (url.pathname === '/revoke') {
|
||||
fake.revoked.push(form.get('token') ?? '');
|
||||
await route.fulfill({ status: 200, headers: cors, body: '' });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 404, headers: cors, body: '' });
|
||||
});
|
||||
return fake;
|
||||
}
|
||||
|
||||
const storedSession = (page: Page) =>
|
||||
page.evaluate(() => JSON.parse(localStorage.getItem('cinny_session_v1') ?? 'null'));
|
||||
|
||||
async function oauthLogin(page: Page) {
|
||||
await page.goto(`/login/${enc(HS)}/`);
|
||||
await page.getByRole('button', { name: /Continue with/ }).click();
|
||||
await page.waitForURL(/\/home/, { timeout: 60_000 });
|
||||
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
|
||||
}
|
||||
|
||||
test.describe('OAuth sign-in on SDK 44', () => {
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||
});
|
||||
|
||||
test('login, refresh on expiry, two-tab race, logout revokes', async ({ context }) => {
|
||||
test.setTimeout(240_000);
|
||||
const user = await ensureUser(uniq('oa'));
|
||||
const fake = await fakeOAuth(context, user.localpart);
|
||||
const page = await context.newPage();
|
||||
|
||||
// 1. Login: registration, PKCE-checked code exchange, session persisted.
|
||||
await oauthLogin(page);
|
||||
expect(fake.registered).toBe(1);
|
||||
expect(fake.tokenCalls.map((c) => c.grant)).toEqual(['authorization_code']);
|
||||
let s = await storedSession(page);
|
||||
expect(s.userId).toBe(user.userId);
|
||||
expect(s.refreshToken).toBe('r1');
|
||||
expect(s.oidc.clientId).toBe('fake-client');
|
||||
const t1 = s.accessToken;
|
||||
|
||||
// 2. Second tab on the same session.
|
||||
const page2 = await context.newPage();
|
||||
await page2.goto('/home/');
|
||||
await page2.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
|
||||
|
||||
// 3. Token "expires": both tabs get M_UNKNOWN_TOKEN on their next request
|
||||
// and refresh at once. Exactly one refresh may reach the auth server
|
||||
// (r1 is single-use); the other tab must adopt its tokens.
|
||||
// Sync long-polls continuously, so both tabs hit it within ~30 s.
|
||||
fake.expired.add(t1);
|
||||
await expect
|
||||
.poll(async () => (await storedSession(page)).accessToken, { timeout: 90_000 })
|
||||
.not.toBe(t1);
|
||||
await page.waitForTimeout(5000);
|
||||
const refreshCalls = fake.tokenCalls.filter((c) => c.grant === 'refresh_token');
|
||||
expect(refreshCalls.map((c) => c.refresh)).toEqual(['r1']);
|
||||
s = await storedSession(page);
|
||||
expect(s.refreshToken).toBe('r2');
|
||||
await Promise.all(
|
||||
[page, page2].map((p) =>
|
||||
expect(p.getByText(/Username or email|Sign-in failed|logged out/i)).toHaveCount(0),
|
||||
),
|
||||
);
|
||||
|
||||
// 4. Logout through the UI revokes both tokens at the auth server
|
||||
// (the SDK does it for OAuth sessions instead of calling /logout).
|
||||
await page2.close();
|
||||
const latest = await storedSession(page);
|
||||
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Logout' }).first().click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Logout', exact: true })
|
||||
.last()
|
||||
.click();
|
||||
await expect(
|
||||
page.getByLabel('Username or email').or(page.getByRole('button', { name: /Continue with/ })),
|
||||
).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,9 @@ test.describe('security nudge (#110, #123)', () => {
|
||||
await expect(strip(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('no key backup: "Set up key backup" opens Settings → Devices', async ({ page }) => {
|
||||
test('no key backup: "Set up key backup" opens Settings → Privacy & security', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
await loginUI(page, user);
|
||||
await pastGrace(page);
|
||||
@@ -83,7 +85,7 @@ test.describe('security nudge (#110, #123)', () => {
|
||||
const nudge = strip(page);
|
||||
await expect(nudge).toContainText("Your encryption keys aren't backed up");
|
||||
await nudge.getByRole('button', { name: 'Set up' }).click();
|
||||
await expect(page.getByText('Device Verification').first()).toBeVisible();
|
||||
await expect(page.getByText('Device verification').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ensureUser, hsReachable, loginUI, uniq } from './localHs';
|
||||
|
||||
// [Gitea #463] Search across all settings.
|
||||
test.describe('settings search (#463)', () => {
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||
});
|
||||
|
||||
test('finds a setting, opens its page and shows it', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const user = await ensureUser(uniq('ss'));
|
||||
await loginUI(page, user);
|
||||
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||
const dialog = page.getByRole('dialog').first();
|
||||
const box = dialog.getByRole('textbox', { name: 'Search settings' });
|
||||
|
||||
// Ctrl+F focuses the box while settings are open (after the dialog has
|
||||
// placed its own initial focus).
|
||||
await expect(box).toBeVisible();
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('ControlOrMeta+f');
|
||||
await expect(box).toBeFocused();
|
||||
|
||||
await box.fill('deafen');
|
||||
const results = dialog.getByRole('list', { name: 'Matching settings' });
|
||||
const first = results.getByRole('listitem').first();
|
||||
await expect(first).toContainText('Push to deafen');
|
||||
await expect(first).toContainText('Calls & audio');
|
||||
await expect(dialog.getByRole('navigation', { name: 'Settings sections' })).toBeHidden();
|
||||
await first.click();
|
||||
await expect(dialog.getByText('Calls & audio', { exact: true }).last()).toBeVisible();
|
||||
await expect(dialog.getByText('Push to deafen', { exact: true })).toBeInViewport();
|
||||
await expect(dialog.getByRole('navigation', { name: 'Settings sections' })).toBeVisible();
|
||||
|
||||
// A description word finds it too; Enter picks the top result.
|
||||
await box.fill('utm');
|
||||
await expect(results.getByRole('listitem').first()).toContainText(
|
||||
'Strip tracking parameters from links',
|
||||
);
|
||||
await box.press('Enter');
|
||||
await expect(dialog.getByText('Strip tracking parameters from links')).toBeInViewport();
|
||||
|
||||
// No match, then Esc clears.
|
||||
await box.fill('zzzz-nothing');
|
||||
await expect(dialog.getByText(/No settings match/)).toBeVisible();
|
||||
await box.press('Escape');
|
||||
await expect(box).toHaveValue('');
|
||||
await expect(dialog).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import fs from 'node:fs';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { api, ensureUser, hsReachable, loginUI, uniq } from './localHs';
|
||||
|
||||
// [Gitea #114] Export / import "your data": a round trip through the file, the
|
||||
// merge summary, and the keep-mine / use-the-file's choice.
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
test.describe('your data (#114)', () => {
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||
});
|
||||
|
||||
test('export, change things, import merges with the chosen conflict rule', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
const user = await ensureUser(uniq('yd'));
|
||||
const put = (type: string, content: unknown) =>
|
||||
api(
|
||||
'PUT',
|
||||
`/_matrix/client/v3/user/${enc(user.userId)}/account_data/${type}`,
|
||||
user.token,
|
||||
content,
|
||||
);
|
||||
const get = (type: string) =>
|
||||
api<Record<string, unknown>>(
|
||||
'GET',
|
||||
`/_matrix/client/v3/user/${enc(user.userId)}/account_data/${type}`,
|
||||
user.token,
|
||||
);
|
||||
const bm = (eventId: string) => ({ roomId: '!r:localhost', eventId, savedAt: 1 });
|
||||
await put('io.lotus.bookmarks', { bookmarks: [bm('$one'), bm('$two')] });
|
||||
await put('io.lotus.user_notes', { '@friend:localhost': 'met at the LAN party' });
|
||||
|
||||
await loginUI(page, user);
|
||||
await page
|
||||
.getByText('Connecting...')
|
||||
.first()
|
||||
.waitFor({ state: 'detached', timeout: 90_000 })
|
||||
.catch(() => undefined);
|
||||
const openGeneral = async () => {
|
||||
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||
await page.getByRole('dialog').getByText('Data & storage', { exact: true }).first().click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByText('Your data', { exact: true })
|
||||
.scrollIntoViewIfNeeded();
|
||||
};
|
||||
await openGeneral();
|
||||
|
||||
// Export.
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.getByRole('dialog').getByRole('button', { name: 'Export', exact: true }).click(),
|
||||
]);
|
||||
const path = await download.path();
|
||||
const file = JSON.parse(fs.readFileSync(path!, 'utf8'));
|
||||
expect(file.format).toBe('lotus-chat-data');
|
||||
expect(file.accountData['io.lotus.bookmarks'].bookmarks).toHaveLength(2);
|
||||
expect(file.local.settings).toBeTruthy();
|
||||
|
||||
// Lose a bookmark, change the note.
|
||||
await put('io.lotus.bookmarks', { bookmarks: [bm('$one')] });
|
||||
await put('io.lotus.user_notes', { '@friend:localhost': 'changed my mind' });
|
||||
await page.waitForTimeout(3000); // let sync deliver the new account data
|
||||
|
||||
const importFile = async () => {
|
||||
await page
|
||||
.locator('input[type="file"][aria-label="Lotus Chat data file"]')
|
||||
.setInputFiles(path!);
|
||||
await expect(page.getByRole('region', { name: 'Import summary' })).toBeVisible();
|
||||
};
|
||||
await importFile();
|
||||
const summary = page.getByRole('region', { name: 'Import summary' });
|
||||
await expect(summary).toContainText('Bookmarks: 1 new');
|
||||
await expect(summary).toContainText('Private notes about people: 1 differ');
|
||||
|
||||
// Keep mine: the bookmark comes back, the note stays as it is now.
|
||||
await summary.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
await expect(page.getByText('Imported.')).toBeVisible({ timeout: 20_000 });
|
||||
expect(((await get('io.lotus.bookmarks')).bookmarks as unknown[]).length).toBe(2);
|
||||
expect((await get('io.lotus.user_notes'))['@friend:localhost']).toBe('changed my mind');
|
||||
|
||||
// Again, using the file's: the note goes back.
|
||||
await page.waitForTimeout(2000);
|
||||
await importFile();
|
||||
await summary.getByText("Use the file's").click();
|
||||
await summary.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
await expect(page.getByText('Imported.')).toBeVisible({ timeout: 20_000 });
|
||||
expect((await get('io.lotus.user_notes'))['@friend:localhost']).toBe('met at the LAN party');
|
||||
|
||||
// Wrong files are refused with a plain message.
|
||||
fs.writeFileSync(`${path}.bad`, '{"format":"something-else"}');
|
||||
await page
|
||||
.locator('input[type="file"][aria-label="Lotus Chat data file"]')
|
||||
.setInputFiles(`${path}.bad`);
|
||||
await expect(page.getByText("This isn't a Lotus Chat data file.")).toBeVisible();
|
||||
});
|
||||
});
|
||||
Generated
+61
-61
@@ -45,7 +45,7 @@
|
||||
"html-react-parser": "6.1.2",
|
||||
"i18next": "26.2.0",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-http-backend": "4.0.2",
|
||||
"i18next-http-backend": "4.0.3",
|
||||
"immer": "11.1.8",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.20.0",
|
||||
@@ -53,7 +53,7 @@
|
||||
"katex": "0.18.11",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"matrix-js-sdk": "41.7.0",
|
||||
"matrix-js-sdk": "44.0.0",
|
||||
"matrix-widget-api": "1.18.0",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
@@ -70,7 +70,7 @@
|
||||
"react-i18next": "17.0.8",
|
||||
"react-range": "1.10.0",
|
||||
"react-router-dom": "7.18.3",
|
||||
"sanitize-html": "2.17.7",
|
||||
"sanitize-html": "2.18.1",
|
||||
"slate": "0.124.1",
|
||||
"slate-dom": "0.124.1",
|
||||
"slate-history": "0.113.1",
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.28",
|
||||
"@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,15 +2695,15 @@
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||
},
|
||||
"node_modules/@lotusguild/element-call-embedded": {
|
||||
"version": "0.25.0-lotus.28",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.28/element-call-embedded-0.25.0-lotus.28.tgz",
|
||||
"integrity": "sha512-IFBHHSVw0CzrrLgx3oIIcJmBB4YMb8at4oVuSKXifZ7MmyHydxg9U3sTlZGgLUjMVrrAXj4gFzCksGHet4LSQw==",
|
||||
"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": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz",
|
||||
"integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==",
|
||||
"version": "18.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.9.0.tgz",
|
||||
"integrity": "sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@@ -5580,12 +5580,12 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-3.1.1.tgz",
|
||||
"integrity": "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -6057,6 +6057,7 @@
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -8168,9 +8169,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.2.tgz",
|
||||
"integrity": "sha512-oay62dIB2kL7+WHzoUXBjWfL3+mwijD3pQkQkIEaRLhy6kjXxUhrRenV0gInwlCASAaJaDy94+XvYizK+cAGdA==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.3.tgz",
|
||||
"integrity": "sha512-B5GJysCsJwqiDQRpUcikWsut82Vmekix58WIyiCpYtxaZ/wY8kwJCicMRvVcMoIa1x84siiRl5vBt6iCBVO6zg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -8704,9 +8705,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
|
||||
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -9152,15 +9154,6 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwt-decode": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.18.11",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.11.tgz",
|
||||
@@ -9216,14 +9209,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/launder": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
|
||||
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.2.tgz",
|
||||
"integrity": "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.7"
|
||||
"dayjs": "^1.11.23"
|
||||
}
|
||||
},
|
||||
"node_modules/launder/node_modules/dayjs": {
|
||||
"version": "1.11.23",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
|
||||
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
@@ -10037,21 +10036,19 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/matrix-js-sdk": {
|
||||
"version": "41.7.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz",
|
||||
"integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==",
|
||||
"version": "44.0.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-44.0.0.tgz",
|
||||
"integrity": "sha512-5wjcYMIX1aqhJC9hSUGjfSW+7SWEaLC8NUaek0xH5KGXqAO11M4Z5Mk7Voodsk+16Jm9PYB8/wMtyqIKFm0VAQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1",
|
||||
"@babel/runtime": "^8.0.0",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^18.9.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^6.0.0",
|
||||
"content-type": "^2.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"content-type": "^3.0.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-widget-api": "^1.16.1",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
"matrix-widget-api": "^1.19.0",
|
||||
"p-retry": "8",
|
||||
"sdp-transform": "^3.0.0",
|
||||
"unhomoglyph": "^1.0.6"
|
||||
@@ -10060,6 +10057,22 @@
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/matrix-js-sdk/node_modules/@babel/runtime": {
|
||||
"version": "8.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.8.tgz",
|
||||
"integrity": "sha512-dEu8nGlRqjsJsvviUUoSMfcRHpwomCiRlXsc2mPfJ79+ffTpDm+JtfUtTMzUrqCApHQnCsVPD3gT1ZT8RgBRCQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/matrix-js-sdk/node_modules/matrix-widget-api": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.20.0.tgz",
|
||||
"integrity": "sha512-qwFP37rgns7mPesM5/vvZgknzb+kvyRB5BISv5sCuBxsGMceMr+Kogf7OkjRxBXdMCPBFuf6gIuYRe5S+lpzQw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/events": "^3.0.0",
|
||||
"events": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/matrix-widget-api": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.18.0.tgz",
|
||||
@@ -10405,18 +10418,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oidc-client-ts": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.1.tgz",
|
||||
"integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jwt-decode": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
@@ -11713,18 +11714,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.7",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz",
|
||||
"integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",
|
||||
"version": "2.18.1",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz",
|
||||
"integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"launder": "^1.7.1",
|
||||
"is-plain-object": "^5.1.0",
|
||||
"launder": "^1.7.2",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
"postcss": "^8.5.27"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
|
||||
+4
-4
@@ -72,7 +72,7 @@
|
||||
"html-react-parser": "6.1.2",
|
||||
"i18next": "26.2.0",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-http-backend": "4.0.2",
|
||||
"i18next-http-backend": "4.0.3",
|
||||
"immer": "11.1.8",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.20.0",
|
||||
@@ -80,7 +80,7 @@
|
||||
"katex": "0.18.11",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"matrix-js-sdk": "41.7.0",
|
||||
"matrix-js-sdk": "44.0.0",
|
||||
"matrix-widget-api": "1.18.0",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
@@ -97,7 +97,7 @@
|
||||
"react-i18next": "17.0.8",
|
||||
"react-range": "1.10.0",
|
||||
"react-router-dom": "7.18.3",
|
||||
"sanitize-html": "2.17.7",
|
||||
"sanitize-html": "2.18.1",
|
||||
"slate": "0.124.1",
|
||||
"slate-dom": "0.124.1",
|
||||
"slate-history": "0.113.1",
|
||||
@@ -108,7 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.28",
|
||||
"@lotusguild/element-call-embedded": "0.28.0-lotus.2",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
|
||||
@@ -23,7 +23,12 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
|
||||
|
||||
const [state, load] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const result = await Promise.allSettled([mx.loginFlows(), mx.registerRequest({})]);
|
||||
const result = await Promise.allSettled([
|
||||
mx.loginFlows(),
|
||||
mx.registerRequest({}),
|
||||
// Rejects when the server has no native OAuth: normal for most servers.
|
||||
mx.getAuthMetadata(),
|
||||
]);
|
||||
const loginFlows = promiseFulfilledResult(result[0]);
|
||||
const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined;
|
||||
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
|
||||
@@ -42,6 +47,7 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
|
||||
const authFlows: AuthFlows = {
|
||||
loginFlows,
|
||||
registerFlows,
|
||||
authMetadata: promiseFulfilledResult(result[2]),
|
||||
};
|
||||
|
||||
return authFlows;
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
useCallThemeSync,
|
||||
useCallStart,
|
||||
} from '../hooks/useCallEmbed';
|
||||
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||
import { callChatAtom, callEmbedAtom, immersiveCallAtom } from '../state/callEmbed';
|
||||
import { toastQueueAtom } from '../state/toast';
|
||||
import { CallEmbed, useCallControlState, useScreensharePromptOpen } from '../plugins/call';
|
||||
import { hangupCallAndWait } from '../plugins/call/hangup';
|
||||
@@ -52,6 +52,7 @@ import { useCallEndedToast } from '../hooks/useCallEndedToast';
|
||||
import { useCallRejoin } from '../hooks/useCallRejoin';
|
||||
import { usePttHaptics } from '../hooks/usePttHaptics';
|
||||
import { useScreenshareNotices } from '../hooks/useScreenshareNotices';
|
||||
import { ExpectedDevicesPrompt } from '../features/call/ExpectedDevicesPrompt';
|
||||
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
|
||||
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
|
||||
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
||||
@@ -571,7 +572,11 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
? content.sender_ts
|
||||
: event.getTs();
|
||||
const lifetime = Math.min(content.lifetime, 120000);
|
||||
const notificationType = content.notification_type;
|
||||
// `notification_type` is deprecated in MSC4075 (matrix-js-sdk 43+): newer
|
||||
// senders may omit it and leave the presentation to us. Then ring in a
|
||||
// direct chat and notify quietly elsewhere, like Element Web.
|
||||
const notificationType =
|
||||
content.notification_type ?? (directs.has(room.roomId) ? 'ring' : 'notification');
|
||||
const relation =
|
||||
event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined;
|
||||
const refEventId = relation?.event_id;
|
||||
@@ -653,7 +658,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
mx.getRooms().forEach((r) => {
|
||||
mx.matrixRTC
|
||||
.getActiveRoomSession(r)
|
||||
?._onRTCSessionMemberUpdate()
|
||||
?.ensureRecalculateSessionMembers()
|
||||
.catch(() => undefined);
|
||||
});
|
||||
};
|
||||
@@ -854,7 +859,14 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
};
|
||||
}, [joined]);
|
||||
|
||||
return null;
|
||||
return (
|
||||
<ExpectedDevicesPrompt
|
||||
embed={embed}
|
||||
onCancel={() => {
|
||||
setCallEmbed(undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -942,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();
|
||||
@@ -1446,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}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useModalStyle } from '../hooks/useModalStyle';
|
||||
* cinny-desktop #5: the first time the window is closed, ask whether Lotus Chat
|
||||
* should keep running in the tray (calls, messages, notifications) or quit —
|
||||
* and, in the same moment, whether it should start when you sign in. Asked
|
||||
* once; changeable in Settings → General. The native side sends
|
||||
* once; changeable in Settings → Desktop. The native side sends
|
||||
* `lotus-close-requested` only while the choice is still "ask", and never
|
||||
* during a call (a call always goes to the tray).
|
||||
*/
|
||||
@@ -149,7 +149,7 @@ export function CloseBehaviorPrompt() {
|
||||
)}
|
||||
</Box>
|
||||
<Text size="T200" priority="300">
|
||||
You can change this in Settings → General.
|
||||
You can change this in Settings → Desktop.
|
||||
</Text>
|
||||
</Box>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ReactNode, useCallback, useMemo } from 'react';
|
||||
import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk';
|
||||
import { Capabilities, ValidatedAuthMetadata } from 'matrix-js-sdk';
|
||||
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { MediaConfig } from '../hooks/useMediaConfig';
|
||||
@@ -28,23 +28,14 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
|
||||
|
||||
const capabilities = promiseFulfilledResult(result[0]);
|
||||
const mediaConfig = promiseFulfilledResult(result[1]);
|
||||
// getAuthMetadata validates (and rejects when the server has no native
|
||||
// OAuth, which is normal for servers using traditional SSO).
|
||||
const authMetadata = promiseFulfilledResult(result[2]);
|
||||
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
|
||||
|
||||
// Only validate if the server returned metadata — a rejected promise means no native
|
||||
// Matrix OIDC (MSC3861/MAS), which is normal for servers using traditional SSO.
|
||||
if (authMetadata !== undefined) {
|
||||
try {
|
||||
validatedAuthMetadata = validateAuthMetadata(authMetadata);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
capabilities,
|
||||
mediaConfig,
|
||||
authMetadata: validatedAuthMetadata,
|
||||
authMetadata,
|
||||
};
|
||||
}, [mx]),
|
||||
);
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { useNearViewport } from '../../../hooks/useNearViewport';
|
||||
import { whenMediaAuthReady } from '../../../../sw-session';
|
||||
|
||||
type RenderViewerProps = {
|
||||
src: string;
|
||||
@@ -115,13 +116,24 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
const handleLoad = () => {
|
||||
setLoad(true);
|
||||
};
|
||||
// An image requested before the service worker controls the page goes out
|
||||
// without the access token and fails (first visit, just after an update).
|
||||
// Retry once by itself when that's ready; only then offer "Retry".
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const autoRetried = useRef(false);
|
||||
const handleError = () => {
|
||||
setLoad(false);
|
||||
if (!autoRetried.current && !encInfo) {
|
||||
autoRetried.current = true;
|
||||
whenMediaAuthReady().then(() => setAttempt((n) => n + 1));
|
||||
return;
|
||||
}
|
||||
setError(true);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setError(false);
|
||||
setAttempt((n) => n + 1);
|
||||
loadSrc();
|
||||
};
|
||||
|
||||
@@ -182,7 +194,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
</Box>
|
||||
)}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||
<Box key={attempt} className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||
{renderImage({
|
||||
alt: body,
|
||||
title: body,
|
||||
|
||||
@@ -9,46 +9,14 @@ import { keyframes } from '@vanilla-extract/css';
|
||||
* component; this module is motion only.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Lantern bob — a hung lantern rises a touch and sinks again on a long, lazy
|
||||
* cycle, as if buoyed by warm air. translateY + a whisper of scale only; the
|
||||
* per-lantern duration/delay desynchronise the swarm.
|
||||
*/
|
||||
export const animLanternBob = keyframes({
|
||||
'0%': { transform: 'translate3d(0, 0, 0) scale(1)' },
|
||||
'50%': { transform: 'translate3d(0, -2.2vh, 0) scale(1.015)' },
|
||||
'100%': { transform: 'translate3d(0, 0, 0) scale(1)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Lantern pendulum — a gentle rotational sway about the top mount, so each
|
||||
* lantern rocks like it hangs from a string. Pairs with the bob on a different
|
||||
* period to read as organic drift rather than a metronome.
|
||||
*/
|
||||
export const animLanternSway = keyframes({
|
||||
'0%': { transform: 'rotate(-2.4deg)' },
|
||||
'50%': { transform: 'rotate(2.4deg)' },
|
||||
'100%': { transform: 'rotate(-2.4deg)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Tassel sway — the silk tassel under a lantern trails its parent's motion with
|
||||
* a wider, slightly lagging swing. transformOrigin is the top of the tassel.
|
||||
*/
|
||||
export const animTasselSway = keyframes({
|
||||
'0%': { transform: 'rotate(5deg)' },
|
||||
'50%': { transform: 'rotate(-5deg)' },
|
||||
'100%': { transform: 'rotate(5deg)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Lantern inner glow — the warm light inside each lantern swells and dims, like
|
||||
* a candle breathing. Opacity + scale only.
|
||||
*/
|
||||
export const animGlowBreathe = keyframes({
|
||||
'0%': { transform: 'scale(0.94)', opacity: '0.55' },
|
||||
'50%': { transform: 'scale(1.06)', opacity: '0.9' },
|
||||
'100%': { transform: 'scale(0.94)', opacity: '0.55' },
|
||||
'0%': { opacity: '0.7' },
|
||||
'50%': { opacity: '1' },
|
||||
'100%': { opacity: '0.7' },
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -77,16 +45,6 @@ export const animPetalSway = keyframes({
|
||||
'100%': { transform: 'translate3d(0, 0, 0)' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Dragon drift — the gold dragon silhouette breathes and undulates almost
|
||||
* imperceptibly across the scene. translate + scale + opacity only, very slow.
|
||||
*/
|
||||
export const animDragonDrift = keyframes({
|
||||
'0%': { transform: 'translate3d(-2%, 0, 0) scale(1)', opacity: '0.42' },
|
||||
'50%': { transform: 'translate3d(2%, -1%, 0) scale(1.04)', opacity: '0.6' },
|
||||
'100%': { transform: 'translate3d(-2%, 0, 0) scale(1)', opacity: '0.42' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Lacquer-tint breathing — a barely-there pulse of the warm red ambient wash so
|
||||
* the static base feels alive without distracting motion.
|
||||
@@ -107,3 +65,13 @@ export const animEmberRise = keyframes({
|
||||
'80%': { opacity: '0.5' },
|
||||
'100%': { transform: 'translate3d(0.6vw, -26vh, 0) scale(1)', opacity: '0' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Lantern garland sway — the lantern set shifts a pixel or two side to side, as
|
||||
* if in a breeze. Transform only.
|
||||
*/
|
||||
export const animGarlandSway = keyframes({
|
||||
'0%': { transform: 'translate3d(-1.5px, 0, 0)' },
|
||||
'50%': { transform: 'translate3d(1.5px, 0, 0)' },
|
||||
'100%': { transform: 'translate3d(-1.5px, 0, 0)' },
|
||||
});
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { SeasonalOverlayProps } from '../types';
|
||||
import {
|
||||
animLanternBob,
|
||||
animLanternSway,
|
||||
animTasselSway,
|
||||
animGarlandSway,
|
||||
animGlowBreathe,
|
||||
animPetalFall,
|
||||
animPetalSway,
|
||||
animDragonDrift,
|
||||
animLacquerPulse,
|
||||
animEmberRise,
|
||||
} from './LunarNewYear.css';
|
||||
@@ -31,46 +28,6 @@ const GOLD_HI = 'oklch(0.92 0.10 92)';
|
||||
// data-URI so it costs a single GPU-composited layer (no DOM weight). The curve
|
||||
// is intentionally abstract and very subtle — a calligraphic ribbon-body with a
|
||||
// suggestion of a head, mane and tail arcing across the upper scene.
|
||||
const dragonUri = ((): string => {
|
||||
const svg =
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' width='760' height='320' viewBox='0 0 760 320'>` +
|
||||
`<defs>` +
|
||||
`<linearGradient id='g' x1='0' y1='0' x2='1' y2='0'>` +
|
||||
`<stop offset='0' stop-color='oklch(0.86 0.13 88)' stop-opacity='0.85'/>` +
|
||||
`<stop offset='0.55' stop-color='oklch(0.82 0.14 85)' stop-opacity='0.7'/>` +
|
||||
`<stop offset='1' stop-color='oklch(0.78 0.13 80)' stop-opacity='0.45'/>` +
|
||||
`</linearGradient>` +
|
||||
`</defs>` +
|
||||
`<g fill='none' stroke='url(%23g)' stroke-linecap='round' stroke-linejoin='round'>` +
|
||||
// Sinuous body — a thick tapering serpentine ribbon.
|
||||
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
|
||||
`stroke-width='26' opacity='0.5'/>` +
|
||||
// Inner highlight running along the body for a calligraphic sheen.
|
||||
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
|
||||
`stroke-width='7' opacity='0.7'/>` +
|
||||
// Head + horn flourish at the leading end.
|
||||
`<path d='M30 180 C10 160 8 130 26 120 M26 120 C36 112 50 116 52 130' ` +
|
||||
`stroke-width='9' opacity='0.6'/>` +
|
||||
// Mane / whisker strokes flaring back from the head.
|
||||
`<path d='M44 134 C70 120 96 132 110 152 M40 150 C66 148 92 160 104 180' ` +
|
||||
`stroke-width='5' opacity='0.45'/>` +
|
||||
// Tail wisps.
|
||||
`<path d='M740 150 C754 138 758 160 748 172 M726 158 C742 168 744 186 732 196' ` +
|
||||
`stroke-width='5' opacity='0.45'/>` +
|
||||
`</g></svg>`;
|
||||
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
|
||||
})();
|
||||
|
||||
type Lantern = {
|
||||
left: number;
|
||||
top: number;
|
||||
scale: number;
|
||||
bobDuration: number;
|
||||
swayDuration: number;
|
||||
delay: number;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
type Petal = {
|
||||
left: number;
|
||||
size: number;
|
||||
@@ -90,8 +47,6 @@ type Ember = {
|
||||
delay: number;
|
||||
};
|
||||
|
||||
// A single five-petal plum blossom (gold), inline SVG so each petal sliver is
|
||||
// one cheap element. Returned as a data-URI background painted on a square.
|
||||
const blossomUri = ((): string => {
|
||||
const petals = Array.from({ length: 5 }, (_, i) => {
|
||||
const a = (i * 72 * Math.PI) / 180;
|
||||
@@ -107,28 +62,56 @@ const blossomUri = ((): string => {
|
||||
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
|
||||
})();
|
||||
|
||||
export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
// Paper lanterns strung across the upper third, gently staggered in depth.
|
||||
const lanterns = useMemo<Lantern[]>(() => {
|
||||
const slots = [
|
||||
{ left: 9, top: 7, scale: 1.0 },
|
||||
{ left: 27, top: 13, scale: 0.82 },
|
||||
{ left: 46, top: 6, scale: 1.12 },
|
||||
{ left: 64, top: 15, scale: 0.78 },
|
||||
{ left: 82, top: 9, scale: 0.95 },
|
||||
{ left: 92, top: 20, scale: 0.7 },
|
||||
];
|
||||
return slots.map((s, i) => ({
|
||||
left: s.left,
|
||||
top: s.top,
|
||||
scale: s.scale,
|
||||
bobDuration: 7 + rand(i + 1) * 4,
|
||||
swayDuration: 5.5 + rand(i + 4) * 3,
|
||||
delay: -rand(i + 7) * 6,
|
||||
opacity: 0.78 + rand(i + 2) * 0.18,
|
||||
}));
|
||||
}, []);
|
||||
// [Gitea #231] Lantern garland: one 480px SVG tile (four scallops) repeats
|
||||
// across the width; small lanterns hang at the scallop low points, all within
|
||||
// the top ~20px.
|
||||
const TILE_W = 480;
|
||||
const TILE_H = 20;
|
||||
const SCALLOP = 120;
|
||||
const tile = (body: string) =>
|
||||
`url("data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' width='${TILE_W}' height='${TILE_H}' viewBox='0 0 ${TILE_W} ${TILE_H}'>${body}</svg>`,
|
||||
)}")`;
|
||||
const CORD = tile(
|
||||
`<path d='M0 1 Q${SCALLOP / 2} 9 ${SCALLOP} 1 ${Array.from(
|
||||
{ length: TILE_W / SCALLOP - 1 },
|
||||
(_, i) => `T${(i + 2) * SCALLOP} 1`,
|
||||
).join(' ')}' fill='none' stroke='${GOLD}' stroke-width='1.1' stroke-opacity='0.8'/>`,
|
||||
);
|
||||
const lanternSvg = (which: 0 | 1) =>
|
||||
Array.from({ length: TILE_W / SCALLOP }, (_, k) => {
|
||||
if (k % 2 !== which) return '';
|
||||
const x = SCALLOP / 2 + k * SCALLOP;
|
||||
return (
|
||||
`<ellipse cx='${x}' cy='11.5' rx='9' ry='7' fill='${VERMILION}' opacity='0.28'/>` +
|
||||
`<rect x='${x - 3}' y='5' width='6' height='1.6' rx='0.6' fill='${GOLD}'/>` +
|
||||
`<ellipse cx='${x}' cy='11' rx='5.6' ry='4.4' fill='${CRIMSON}' stroke='${GOLD}' stroke-width='0.6'/>` +
|
||||
`<path d='M${x - 2.4} 7.2 Q${x - 3.4} 11 ${x - 2.4} 14.8 M${x} 6.6 V15.4 M${x + 2.4} 7.2 Q${x + 3.4} 11 ${x + 2.4} 14.8' fill='none' stroke='${GOLD}' stroke-width='0.4' stroke-opacity='0.7'/>` +
|
||||
`<ellipse cx='${x - 1.6}' cy='9.6' rx='1.6' ry='1.2' fill='${GOLD_HI}' opacity='0.45'/>` +
|
||||
`<rect x='${x - 2.4}' y='15.2' width='4.8' height='1.4' rx='0.5' fill='${GOLD}'/>` +
|
||||
`<path d='M${x} 16.6 V19.4' stroke='${GOLD}' stroke-width='0.9' stroke-linecap='round'/>`
|
||||
);
|
||||
}).join('');
|
||||
const LANTERNS_A = tile(lanternSvg(0));
|
||||
const LANTERNS_B = tile(lanternSvg(1));
|
||||
|
||||
export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
const garlandLayer = (image: string, animated: boolean, delay = 0): React.CSSProperties => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: `${TILE_H}px`,
|
||||
backgroundImage: image,
|
||||
backgroundRepeat: 'repeat-x',
|
||||
backgroundSize: `${TILE_W}px ${TILE_H}px`,
|
||||
backgroundPosition: '-20px 0',
|
||||
animation:
|
||||
reduced || !animated
|
||||
? 'none'
|
||||
: `${animGarlandSway} 5.5s ease-in-out ${delay}s infinite, ${animGlowBreathe} 3.8s ease-in-out ${delay}s infinite`,
|
||||
willChange: reduced || !animated ? undefined : 'transform, opacity',
|
||||
});
|
||||
// Drifting gold plum-blossom petals — two parallax bands (far small/dim/slow,
|
||||
// near large/bright/fast) for depth.
|
||||
const petals = useMemo<Petal[]>(() => {
|
||||
@@ -185,216 +168,33 @@ export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: [
|
||||
`radial-gradient(120% 80% at 50% -8%, ${CRIMSON.replace(')', ' / 0.16)')} 0%, transparent 56%)`,
|
||||
`radial-gradient(90% 70% at 50% 112%, oklch(0.42 0.17 28 / 0.1) 0%, transparent 60%)`,
|
||||
`linear-gradient(180deg, oklch(0.55 0.20 28 / 0.07) 0%, transparent 26%, transparent 82%, oklch(0.40 0.16 28 / 0.08) 100%)`,
|
||||
`radial-gradient(120% 70% at 50% -8%, ${CRIMSON.replace(')', ' / 0.09)')} 0%, transparent 52%)`,
|
||||
`linear-gradient(180deg, oklch(0.55 0.20 28 / 0.035) 0%, transparent 22%)`,
|
||||
].join(','),
|
||||
animation: reduced ? 'none' : `${animLacquerPulse} 13s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Imperial-gold dragon silhouette arcing across the upper scene — a
|
||||
single composited SVG layer, blurred and screen-blended so it reads as
|
||||
an ethereal gilt apparition, never a hard graphic. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8%',
|
||||
left: '-6%',
|
||||
right: '-6%',
|
||||
height: '46%',
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: dragonUri,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: 'contain',
|
||||
mixBlendMode: 'screen',
|
||||
filter: 'blur(1.1px)',
|
||||
opacity: reduced ? 0.52 : undefined,
|
||||
animation: reduced ? 'none' : `${animDragonDrift} 30s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Warm vignette frame — crimson edges, clear center, with a faint cheap
|
||||
backdrop-filter for a silken haze around the rim. */}
|
||||
{/* Warm vignette frame — a faint crimson rim, clear center. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backdropFilter: 'blur(0.4px) saturate(1.05)',
|
||||
WebkitBackdropFilter: 'blur(0.4px) saturate(1.05)',
|
||||
backgroundImage:
|
||||
'radial-gradient(135% 120% at 50% 40%, transparent 54%, oklch(0.55 0.16 28 / 0.06) 76%, oklch(0.40 0.16 28 / 0.16) 100%)',
|
||||
'radial-gradient(135% 120% at 50% 40%, transparent 60%, oklch(0.55 0.16 28 / 0.05) 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* The garland string the lanterns hang from — a faint warm line. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '6%',
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: `radial-gradient(140% 80% at 50% -40%, ${GOLD.replace(
|
||||
')',
|
||||
' / 0.14)',
|
||||
)} 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Paper lanterns. Each is a hung group: a sway wrapper rotating about its
|
||||
mount, an inner bob, then the lantern body (glow + ribs + caps) and a
|
||||
trailing tassel. */}
|
||||
{lanterns.map((l, i) => {
|
||||
const W = 30 * l.scale;
|
||||
const H = 38 * l.scale;
|
||||
const cap = Math.max(8, W * 0.5);
|
||||
return (
|
||||
<div
|
||||
key={`lantern-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${l.left}%`,
|
||||
top: `${l.top}%`,
|
||||
marginLeft: `${-W / 2}px`,
|
||||
transformOrigin: 'top center',
|
||||
opacity: l.opacity,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animLanternSway} ${l.swayDuration}s ease-in-out ${l.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animLanternBob} ${l.bobDuration}s ease-in-out ${l.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
}}
|
||||
>
|
||||
{/* short cord from the string to the top cap */}
|
||||
<div
|
||||
style={{
|
||||
width: '2px',
|
||||
height: `${10 * l.scale}px`,
|
||||
margin: '0 auto',
|
||||
background: `linear-gradient(${GOLD}, ${GOLD.replace(')', ' / 0.3)')})`,
|
||||
}}
|
||||
/>
|
||||
{/* top gold cap */}
|
||||
<div
|
||||
style={{
|
||||
width: `${cap}px`,
|
||||
height: `${4 * l.scale}px`,
|
||||
margin: '0 auto',
|
||||
borderRadius: `${2 * l.scale}px`,
|
||||
background: `linear-gradient(90deg, ${GOLD.replace(
|
||||
')',
|
||||
' / 0.6)',
|
||||
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
|
||||
boxShadow: `0 0 ${5 * l.scale}px ${GOLD.replace(')', ' / 0.55)')}`,
|
||||
}}
|
||||
/>
|
||||
{/* lantern body */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: `${W}px`,
|
||||
height: `${H}px`,
|
||||
margin: `${1 * l.scale}px auto`,
|
||||
borderRadius: '50% / 42%',
|
||||
background: `radial-gradient(circle at 38% 32%, ${VERMILION.replace(
|
||||
')',
|
||||
' / 0.95)',
|
||||
)} 0%, ${CRIMSON} 58%, oklch(0.40 0.18 26 / 0.95) 100%)`,
|
||||
border: `${1.2 * l.scale}px solid ${GOLD.replace(')', ' / 0.8)')}`,
|
||||
boxShadow: `0 0 ${16 * l.scale}px ${CRIMSON.replace(
|
||||
')',
|
||||
' / 0.5)',
|
||||
)}, inset 0 0 ${10 * l.scale}px oklch(0.78 0.16 60 / 0.35)`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* breathing inner candle glow */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '52%',
|
||||
width: `${W * 0.6}px`,
|
||||
height: `${H * 0.55}px`,
|
||||
marginLeft: `${-W * 0.3}px`,
|
||||
marginTop: `${-H * 0.275}px`,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${GOLD_HI.replace(
|
||||
')',
|
||||
' / 0.9)',
|
||||
)} 0%, oklch(0.80 0.16 65 / 0.5) 45%, transparent 75%)`,
|
||||
filter: 'blur(1px)',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animGlowBreathe} ${l.bobDuration * 0.7}s ease-in-out ${
|
||||
l.delay
|
||||
}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
{/* vertical paper ribs */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: `repeating-linear-gradient(90deg, transparent 0, transparent ${
|
||||
W / 6 - 0.6
|
||||
}px, ${GOLD.replace(')', ' / 0.18)')} ${W / 6 - 0.6}px, ${GOLD.replace(
|
||||
')',
|
||||
' / 0.18)',
|
||||
)} ${W / 6}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* bottom gold cap */}
|
||||
<div
|
||||
style={{
|
||||
width: `${cap}px`,
|
||||
height: `${4 * l.scale}px`,
|
||||
margin: '0 auto',
|
||||
borderRadius: `${2 * l.scale}px`,
|
||||
background: `linear-gradient(90deg, ${GOLD.replace(
|
||||
')',
|
||||
' / 0.6)',
|
||||
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
|
||||
}}
|
||||
/>
|
||||
{/* swaying silk tassel */}
|
||||
<div
|
||||
style={{
|
||||
width: `${2 * l.scale}px`,
|
||||
height: `${16 * l.scale}px`,
|
||||
margin: '0 auto',
|
||||
transformOrigin: 'top center',
|
||||
background: `linear-gradient(${CRIMSON}, ${GOLD.replace(')', ' / 0.8)')})`,
|
||||
borderRadius: '1px',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animTasselSway} ${l.swayDuration * 0.8}s ease-in-out ${l.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* [Gitea #231] A garland of small paper lanterns on a scalloped cord, all
|
||||
within the top ~20px so it stays above header icons and text (the old
|
||||
lanterns hung 7-20% down, over Create Room, search boxes and messages).
|
||||
Two lantern sets glow and sway a little, alternately. */}
|
||||
<div aria-hidden="true" style={garlandLayer(CORD, false)} />
|
||||
<div aria-hidden="true" style={garlandLayer(LANTERNS_A, true, 0)} />
|
||||
<div aria-hidden="true" style={garlandLayer(LANTERNS_B, true, -2.2)} />
|
||||
|
||||
{/* Drifting gold plum-blossom petals (motion only). Static settled
|
||||
blossoms render below for the reduced/preview scene. */}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
config,
|
||||
Dialog,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { useAtom } from 'jotai';
|
||||
import { callSummaryViewAtom } from '../../state/callSummary';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
||||
/**
|
||||
* [Gitea #253] The post-call "Details" card: opened from the call-ended toast,
|
||||
* shows who was in the call, connection quality and whose side it was on,
|
||||
* network numbers and your own activity (technical wording).
|
||||
*/
|
||||
export function CallSummaryDialog() {
|
||||
const [view, setView] = useAtom(callSummaryViewAtom);
|
||||
const modalStyle = useModalStyle(400);
|
||||
if (!view) return null;
|
||||
const close = () => setView(undefined);
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: close,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog
|
||||
variant="Surface"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="call-summary-title"
|
||||
style={modalStyle}
|
||||
>
|
||||
<Header
|
||||
variant="Surface"
|
||||
size="500"
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Phone} size="100" />
|
||||
<Text id="call-summary-title" size="H4" truncate>
|
||||
Call summary · {view.roomName}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" onClick={close} aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||
{view.sections.map((section) => (
|
||||
<Box key={section.title} direction="Column" gap="100">
|
||||
<Text size="L400">{section.title}</Text>
|
||||
{section.lines.map((line) => (
|
||||
<Text key={line} size="T300" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
|
||||
import { CallMemberRenderer } from './CallMemberCard';
|
||||
import * as css from './styles.css';
|
||||
import { CallControls } from './CallControls';
|
||||
import { ImmersiveCallOverlay } from './ImmersiveCallOverlay';
|
||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { CallsUnavailableMessage } from './CallsUnavailable';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
@@ -198,10 +199,24 @@ function CallLoadErrorMessage() {
|
||||
type CallJoinedProps = {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
joined: boolean;
|
||||
/** [Gitea #126] Full screen: controls float over the call instead. */
|
||||
onExitImmersive?: () => void;
|
||||
};
|
||||
function CallJoined({ joined, containerRef }: CallJoinedProps) {
|
||||
function CallJoined({ joined, containerRef, onExitImmersive }: CallJoinedProps) {
|
||||
const callEmbed = useCallEmbed();
|
||||
|
||||
if (onExitImmersive) {
|
||||
return (
|
||||
<Box grow="Yes" direction="Column" style={{ position: 'relative' }}>
|
||||
<Box grow="Yes" ref={containerRef} />
|
||||
{callEmbed && joined && (
|
||||
<ImmersiveCallOverlay callEmbed={callEmbed} onExit={onExitImmersive} />
|
||||
)}
|
||||
{callEmbed && joined && <LotusDecorationPusher callEmbed={callEmbed} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Box grow="Yes" ref={containerRef} />
|
||||
@@ -212,7 +227,7 @@ function CallJoined({ joined, containerRef }: CallJoinedProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function CallView() {
|
||||
export function CallView({ onExitImmersive }: { onExitImmersive?: () => void } = {}) {
|
||||
const room = useRoom();
|
||||
const callContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||
useCallEmbedPlacementSync(callContainerRef);
|
||||
@@ -227,6 +242,26 @@ export function CallView() {
|
||||
// made it into the call (a hung iframe / blank spinner otherwise).
|
||||
const showLoadError = isCurrentRoom && !currentJoined && Boolean(loadError);
|
||||
|
||||
if (onExitImmersive && currentJoined) {
|
||||
// [Gitea #126] Cover the screen with a transparent layer above the call
|
||||
// frame: the frame follows the placeholder (useCallEmbedPlacementSync), so
|
||||
// it goes full screen too; the layer only takes touches on its own
|
||||
// tap-catcher / controls, so the call underneath stays usable.
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
paddingTop: 'env(safe-area-inset-top)',
|
||||
}}
|
||||
>
|
||||
<CallJoined joined containerRef={callContainerRef} onExitImmersive={onExitImmersive} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'Surface' })}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
Header,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { CallEmbed } from '../../plugins/call';
|
||||
import {
|
||||
MissingDevice,
|
||||
acceptForSession,
|
||||
describeLost,
|
||||
describeMissing,
|
||||
isAcceptedThisSession,
|
||||
parseDeviceLost,
|
||||
parseDeviceMissing,
|
||||
} from '../../plugins/call/expectedDevices';
|
||||
import { toastQueueAtom } from '../../state/toast';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
|
||||
/**
|
||||
* [Gitea #121] "Your usual headset isn't connected": the fork holds the join
|
||||
* until we answer. Join anyway (not asked again this session for the same
|
||||
* device) or Cancel (closes the call before it starts). A device lost mid-call
|
||||
* is only a toast.
|
||||
*/
|
||||
export function ExpectedDevicesPrompt({
|
||||
embed,
|
||||
onCancel,
|
||||
}: {
|
||||
embed: CallEmbed;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [missing, setMissing] = useState<MissingDevice[]>([]);
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
const modalStyle = useModalStyle(440);
|
||||
|
||||
useEffect(() => {
|
||||
const offMissing = embed.listenAction<{ data?: unknown }>('io.lotus.device_missing', (evt) => {
|
||||
const list = parseDeviceMissing(evt.detail?.data);
|
||||
if (list.length === 0) return;
|
||||
if (isAcceptedThisSession(list)) {
|
||||
embed.control.sendDeviceDecision(true);
|
||||
return;
|
||||
}
|
||||
setMissing(list);
|
||||
});
|
||||
const offLost = embed.listenAction<{ data?: unknown }>('io.lotus.device_lost', (evt) => {
|
||||
const lost = parseDeviceLost(evt.detail?.data);
|
||||
if (!lost) return;
|
||||
setToast({
|
||||
id: `device-lost-${lost.kind}-${Date.now()}`,
|
||||
iconSrc: lost.kind === 'audioinput' ? Icons.Mic : Icons.VolumeHigh,
|
||||
displayName: 'Audio device',
|
||||
body: describeLost(lost),
|
||||
roomName: embed.room.name ?? 'Voice call',
|
||||
roomId: embed.roomId,
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
offMissing();
|
||||
offLost();
|
||||
};
|
||||
}, [embed, setToast]);
|
||||
|
||||
if (missing.length === 0) return null;
|
||||
|
||||
const joinAnyway = () => {
|
||||
acceptForSession(missing);
|
||||
embed.control.sendDeviceDecision(true);
|
||||
setMissing([]);
|
||||
};
|
||||
const cancel = () => {
|
||||
embed.control.sendDeviceDecision(false);
|
||||
setMissing([]);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap focusTrapOptions={{ initialFocus: false, escapeDeactivates: false }}>
|
||||
<Dialog
|
||||
variant="Surface"
|
||||
role="alertdialog"
|
||||
aria-labelledby="expected-devices-title"
|
||||
style={modalStyle}
|
||||
>
|
||||
<Header
|
||||
variant="Surface"
|
||||
size="500"
|
||||
style={{
|
||||
padding: `0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
>
|
||||
<Text id="expected-devices-title" as="h2" size="H4">
|
||||
{missing.length === 1
|
||||
? describeMissing(missing[0]).title
|
||||
: "Your usual audio devices aren't connected"}
|
||||
</Text>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
{missing.map((d) => {
|
||||
const t = describeMissing(d);
|
||||
return (
|
||||
<Text key={d.kind} size="T300">
|
||||
<b>{t.expected}</b> isn't connected. Join with <b>{t.fallback}</b> instead?
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
<Text size="T200" priority="300">
|
||||
Plug it in and choose Cancel to start over with it.
|
||||
</Text>
|
||||
<Box direction="Column" gap="200">
|
||||
<Button variant="Primary" onClick={joinAnyway}>
|
||||
<Text size="B400">Join anyway</Text>
|
||||
</Button>
|
||||
<Button variant="Secondary" fill="Soft" onClick={cancel}>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
|
||||
import { CallEmbed } from '../../plugins/call';
|
||||
import { CallControls } from './CallControls';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { CONTROLS_AUTOHIDE_MS, wantsBrowserFullscreen } from './landscapeCall';
|
||||
|
||||
const isStandalone = (): boolean =>
|
||||
window.matchMedia?.('(display-mode: standalone)').matches ||
|
||||
(navigator as { standalone?: boolean }).standalone === true;
|
||||
|
||||
/**
|
||||
* [Gitea #126] Full-screen call controls. Taps inside the call frame never
|
||||
* reach us, so while the controls are hidden a transparent layer covers the
|
||||
* call: the first tap shows them (and is otherwise swallowed, as in native
|
||||
* video apps); with them up the layer is gone, so the call stays usable. They
|
||||
* hide again after a few seconds without a touch. The first tap in an Android
|
||||
* browser tab also enters the browser's own full screen (hides the address
|
||||
* bar); it is left again when this unmounts (rotate back, hang up, exit).
|
||||
*/
|
||||
export function ImmersiveCallOverlay({
|
||||
callEmbed,
|
||||
onExit,
|
||||
}: {
|
||||
callEmbed: CallEmbed;
|
||||
onExit: () => void;
|
||||
}) {
|
||||
const [shown, setShown] = useState(false);
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
const enteredFullscreen = useRef(false);
|
||||
const setChat = useSetAtom(callChatAtom);
|
||||
|
||||
const armHide = useCallback(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setShown(false), CONTROLS_AUTOHIDE_MS);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timer.current);
|
||||
if (enteredFullscreen.current && document.fullscreenElement === document.documentElement) {
|
||||
document.exitFullscreen().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reveal = () => {
|
||||
if (
|
||||
wantsBrowserFullscreen({
|
||||
userAgent: navigator.userAgent,
|
||||
standalone: isStandalone(),
|
||||
fullscreenEnabled: !!document.fullscreenEnabled,
|
||||
alreadyFullscreen: !!document.fullscreenElement,
|
||||
})
|
||||
) {
|
||||
document.documentElement
|
||||
.requestFullscreen({ navigationUI: 'hide' })
|
||||
.then(() => {
|
||||
enteredFullscreen.current = true;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
setShown(true);
|
||||
armHide();
|
||||
};
|
||||
|
||||
if (!shown) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Show call controls"
|
||||
onClick={reveal}
|
||||
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && reveal()}
|
||||
style={{ position: 'absolute', inset: 0, pointerEvents: 'auto' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
// Any touch on the controls keeps them up a little longer.
|
||||
onPointerDown={armHide}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: 'auto',
|
||||
paddingLeft: 'env(safe-area-inset-left)',
|
||||
paddingRight: 'env(safe-area-inset-right)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.7), rgba(0,0,0,0))',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
justifyContent="End"
|
||||
gap="200"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
||||
>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
before={<Icon size="100" src={Icons.Message} />}
|
||||
onClick={() => setChat(true)}
|
||||
>
|
||||
<Text size="B300">Chat</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
before={<Icon size="100" src={Icons.Cross} />}
|
||||
onClick={onExit}
|
||||
style={{ minWidth: toRem(40) }}
|
||||
>
|
||||
<Text size="B300">Exit full screen</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
<CallControls callEmbed={callEmbed} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { shouldGoImmersive, wantsBrowserFullscreen } from './landscapeCall';
|
||||
|
||||
const base = { landscapePhone: true, joinedHere: true, chatOpen: false, dismissed: false };
|
||||
|
||||
test('[#126] full screen only for a joined call on a sideways phone', () => {
|
||||
assert.equal(shouldGoImmersive(base), true);
|
||||
assert.equal(shouldGoImmersive({ ...base, landscapePhone: false }), false, 'upright / tablet');
|
||||
assert.equal(shouldGoImmersive({ ...base, joinedHere: false }), false, 'prescreen / other room');
|
||||
assert.equal(shouldGoImmersive({ ...base, chatOpen: true }), false, 'chat open');
|
||||
assert.equal(shouldGoImmersive({ ...base, dismissed: true }), false, 'user exited');
|
||||
});
|
||||
|
||||
test('[#126] browser full screen only in an Android browser tab', () => {
|
||||
const env = {
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) Chrome/140 Mobile',
|
||||
standalone: false,
|
||||
fullscreenEnabled: true,
|
||||
alreadyFullscreen: false,
|
||||
};
|
||||
assert.equal(wantsBrowserFullscreen(env), true);
|
||||
assert.equal(wantsBrowserFullscreen({ ...env, standalone: true }), false, 'installed app');
|
||||
assert.equal(
|
||||
wantsBrowserFullscreen({
|
||||
...env,
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari',
|
||||
}),
|
||||
false,
|
||||
'iPhone',
|
||||
);
|
||||
assert.equal(wantsBrowserFullscreen({ ...env, alreadyFullscreen: true }), false);
|
||||
assert.equal(wantsBrowserFullscreen({ ...env, fullscreenEnabled: false }), false);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* [Gitea #126] Phone turned sideways in a call: the call takes the whole
|
||||
* screen. A phone in landscape is ~840px wide (the "Tablet" size class), so
|
||||
* the trigger is the shape of the screen, not the size class: landscape, at
|
||||
* most 500px tall, touch input. Tablets and desktops never match.
|
||||
*/
|
||||
export const LANDSCAPE_PHONE_QUERY =
|
||||
'(orientation: landscape) and (max-height: 500px) and (pointer: coarse)';
|
||||
|
||||
/** Controls hide again after this long without a touch on them. */
|
||||
export const CONTROLS_AUTOHIDE_MS = 4000;
|
||||
|
||||
export type ImmersiveInputs = {
|
||||
landscapePhone: boolean;
|
||||
/** This room's call is the one we are in, and we have joined it. */
|
||||
joinedHere: boolean;
|
||||
/** The in-call chat is open (it replaces the call on small screens). */
|
||||
chatOpen: boolean;
|
||||
/** "Exit full screen" was pressed since the phone was last upright. */
|
||||
dismissed: boolean;
|
||||
};
|
||||
|
||||
/** Pure: whether the call should be full screen. */
|
||||
export const shouldGoImmersive = (i: ImmersiveInputs): boolean =>
|
||||
i.landscapePhone && i.joinedHere && !i.chatOpen && !i.dismissed;
|
||||
|
||||
/**
|
||||
* Pure: use the browser's own full-screen mode too? Only where an address bar
|
||||
* takes room: an Android browser tab (not the installed app / iOS, which have
|
||||
* none or don't support it), when the page isn't already full screen.
|
||||
*/
|
||||
export const wantsBrowserFullscreen = (env: {
|
||||
userAgent: string;
|
||||
standalone: boolean;
|
||||
fullscreenEnabled: boolean;
|
||||
alreadyFullscreen: boolean;
|
||||
}): boolean =>
|
||||
/Android/i.test(env.userAgent) &&
|
||||
!env.standalone &&
|
||||
env.fullscreenEnabled &&
|
||||
!env.alreadyFullscreen;
|
||||
@@ -48,7 +48,7 @@ export function RoomRetention({ permissions }: RoomRetentionProps) {
|
||||
>
|
||||
<SettingTile
|
||||
title="Message Retention"
|
||||
description="Messages older than this window disappear from the timeline. Each member can opt in to permanently delete their own expired messages in Settings → General; full server-side deletion also requires homeserver retention to be configured."
|
||||
description="Messages older than this window disappear from the timeline. Each member can opt in to permanently delete their own expired messages in Settings → Messages; full server-side deletion also requires homeserver retention to be configured."
|
||||
>
|
||||
<Box gap="200" alignItems="Center" style={{ flexWrap: 'wrap' }}>
|
||||
{RETENTION_PRESETS.map((preset) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Line } from 'folds';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
@@ -11,7 +11,6 @@ import { WidgetsPanel } from './widgets/WidgetsPanel';
|
||||
import { widgetsPanelAtom } from '../../state/widgetsPanel';
|
||||
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { useMediaQuery } from '../../hooks/useMediaQuery';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
@@ -21,10 +20,12 @@ import { markAsRead } from '../../utils/notifications';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
import { CallView } from '../call/CallView';
|
||||
import { LANDSCAPE_PHONE_QUERY, shouldGoImmersive } from '../call/landscapeCall';
|
||||
import { useMediaQuery } from '../../hooks/useMediaQuery';
|
||||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { callChatAtom, immersiveCallAtom } from '../../state/callEmbed';
|
||||
import { CallChatView } from './CallChatView';
|
||||
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
||||
import { useCallEmbed, useCallJoined } from '../../hooks/useCallEmbed';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||
import { threadsListAtom } from '../../state/threadsList';
|
||||
@@ -57,6 +58,25 @@ export function Room() {
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const members = useRoomMembers(mx, room.roomId);
|
||||
const chat = useAtomValue(callChatAtom);
|
||||
// [Gitea #126] Phone turned sideways in this room's call: full screen.
|
||||
const landscapePhone = useMediaQuery(LANDSCAPE_PHONE_QUERY);
|
||||
const joinedAnywhere = useCallJoined(callEmbed);
|
||||
const [immersiveDismissed, setImmersiveDismissed] = useState(false);
|
||||
useEffect(() => {
|
||||
// "Exit full screen" lasts until the phone is upright again.
|
||||
if (!landscapePhone) setImmersiveDismissed(false);
|
||||
}, [landscapePhone]);
|
||||
const setImmersiveCall = useSetAtom(immersiveCallAtom);
|
||||
const immersive = shouldGoImmersive({
|
||||
landscapePhone,
|
||||
joinedHere: joinedAnywhere && callEmbed?.roomId === room.roomId,
|
||||
chatOpen: chat,
|
||||
dismissed: immersiveDismissed,
|
||||
});
|
||||
useEffect(() => {
|
||||
setImmersiveCall(immersive);
|
||||
return () => setImmersiveCall(false);
|
||||
}, [immersive, setImmersiveCall]);
|
||||
// Hoisted here (instead of inside RoomTimeline) so ThreadPanel/ThreadTimeline —
|
||||
// rendered as siblings of RoomView, not descendants — share the same computed
|
||||
// positions instead of falling back to the context default (Gitea #38).
|
||||
@@ -170,7 +190,8 @@ export function Room() {
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
<ReadPositionsContext.Provider value={readPositions}>
|
||||
<Box grow="Yes">
|
||||
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
|
||||
{immersive && <CallView onExitImmersive={() => setImmersiveDismissed(true)} />}
|
||||
{!immersive && callView && (screenSize === ScreenSize.Desktop || !chat) && (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomViewHeader callView />
|
||||
<Box grow="Yes">
|
||||
|
||||
@@ -128,7 +128,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
||||
const editor = useEditor();
|
||||
const thread = useThreadInstance(room, threadId);
|
||||
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
|
||||
// "Hide Typing & Read Receipts" must also make thread receipts private (matches markAsRead).
|
||||
// "Hide typing and read receipts" must also make thread receipts private (matches markAsRead).
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
|
||||
@@ -23,8 +23,11 @@ import {
|
||||
nudgeFor,
|
||||
NudgeRecord,
|
||||
parseNudgeRecord,
|
||||
shouldShowNudge,
|
||||
pickNudge,
|
||||
staleSessions,
|
||||
staleSessionsText,
|
||||
} from '../../utils/securityNudge';
|
||||
import { preselectSignOutAtom } from '../../state/staleSessions';
|
||||
import { StatusStrip } from '../server-status/ServerStatusBanner';
|
||||
|
||||
/** This device's backup connection: undefined until known, then true/false. */
|
||||
@@ -40,6 +43,35 @@ const useBackupActive = (crypto: CryptoApi): boolean | undefined => {
|
||||
return active;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #113] Ids of this account's sessions unused for 90+ days. Fetched
|
||||
* once sync has settled, then hourly; [] until known or on error.
|
||||
*/
|
||||
const useStaleSessionIds = (enabled: boolean): string[] => {
|
||||
const mx = useMatrixClient();
|
||||
const alive = useAlive();
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const load = () =>
|
||||
mx
|
||||
.getDevices()
|
||||
.then(({ devices }) => {
|
||||
if (alive())
|
||||
setIds(
|
||||
staleSessions(devices, mx.getDeviceId() ?? undefined, Date.now()).map(
|
||||
(d) => d.device_id,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
load();
|
||||
const t = window.setInterval(load, 60 * 60 * 1000);
|
||||
return () => window.clearInterval(t);
|
||||
}, [mx, enabled, alive]);
|
||||
return ids;
|
||||
};
|
||||
|
||||
const readRecord = (key: string): NudgeRecord => {
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
@@ -84,12 +116,15 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
let deviceVerified: boolean | undefined;
|
||||
if (status === VerificationStatus.Verified) deviceVerified = true;
|
||||
else if (status === VerificationStatus.Unverified) deviceVerified = false;
|
||||
const kind = nudgeFor({
|
||||
const securityKind = nudgeFor({
|
||||
crossSigning,
|
||||
deviceVerified,
|
||||
backupOnServer: backupInfo === undefined ? undefined : backupInfo !== null,
|
||||
backupActive,
|
||||
});
|
||||
const staleIds = useStaleSessionIds(settled);
|
||||
const setPreselect = useSetAtom(preselectSignOutAtom);
|
||||
const kind = pickNudge(securityKind, staleIds.length, record, Date.now());
|
||||
|
||||
// One strip at a time: outages, maintenance and a lost connection win.
|
||||
// [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't
|
||||
@@ -97,10 +132,12 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
const otherStrip =
|
||||
syncLost ||
|
||||
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
|
||||
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
|
||||
return null;
|
||||
if (!settled || !deviceId || !kind || otherStrip) return null;
|
||||
|
||||
const copy = NUDGE_COPY[kind];
|
||||
const copy =
|
||||
kind === 'stale-sessions'
|
||||
? { ...NUDGE_COPY[kind], text: staleSessionsText(staleIds.length) }
|
||||
: NUDGE_COPY[kind];
|
||||
const dismiss = () => {
|
||||
const next = dismissNudge(kind, record, Date.now());
|
||||
try {
|
||||
@@ -113,8 +150,15 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
return (
|
||||
<StatusStrip
|
||||
banner={{ key: kind, tone: 'Primary', text: copy.text, dismissable: true }}
|
||||
icon={Icons.ShieldUser}
|
||||
action={{ label: copy.action, onClick: () => requestSettings('devices') }}
|
||||
action={{
|
||||
label: copy.action,
|
||||
onClick: () => {
|
||||
// Review: the Devices page opens with these ticked for sign-out.
|
||||
if (kind === 'stale-sessions') setPreselect(staleIds);
|
||||
requestSettings('devices');
|
||||
},
|
||||
}}
|
||||
icon={kind === 'stale-sessions' ? Icons.Category : Icons.ShieldUser}
|
||||
dismissLabel="Not now"
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -13,9 +13,19 @@ import {
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
color,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { General } from './general';
|
||||
import {
|
||||
AdvancedDisplayOptions,
|
||||
AppearancePage,
|
||||
CallsPage,
|
||||
DataStoragePage,
|
||||
DesktopPage,
|
||||
MessagesPage,
|
||||
Privacy,
|
||||
} from './general';
|
||||
import { isTauri as isTauriEnv } from '../../hooks/useTauri';
|
||||
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { Account } from './account';
|
||||
@@ -34,6 +44,8 @@ import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||
import { SettingsPages } from './pages';
|
||||
import { SettingsSearch } from './search/SettingsSearch';
|
||||
import type { SettingsEntry } from './search/settingsIndex';
|
||||
|
||||
export { SettingsPages };
|
||||
|
||||
@@ -45,43 +57,23 @@ type SettingsMenuItem = {
|
||||
|
||||
const useSettingsMenuItems = (): SettingsMenuItem[] =>
|
||||
useMemo(
|
||||
() => [
|
||||
{
|
||||
page: SettingsPages.GeneralPage,
|
||||
name: 'General',
|
||||
icon: Icons.Setting,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.AccountPage,
|
||||
name: 'Account',
|
||||
icon: Icons.User,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.NotificationPage,
|
||||
name: 'Notifications',
|
||||
icon: Icons.Bell,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.DevicesPage,
|
||||
name: 'Devices',
|
||||
icon: Icons.Monitor,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.EmojisStickersPage,
|
||||
name: 'Emojis & Stickers',
|
||||
icon: Icons.Smile,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.DeveloperToolsPage,
|
||||
name: 'Developer Tools',
|
||||
icon: Icons.Terminal,
|
||||
},
|
||||
{
|
||||
page: SettingsPages.AboutPage,
|
||||
name: 'About',
|
||||
icon: Icons.Info,
|
||||
},
|
||||
],
|
||||
() =>
|
||||
[
|
||||
{ page: SettingsPages.AccountPage, name: 'Account', icon: Icons.User },
|
||||
{ page: SettingsPages.AppearancePage, name: 'Appearance', icon: Icons.Sun },
|
||||
{ page: SettingsPages.MessagesPage, name: 'Messages', icon: Icons.Message },
|
||||
{ page: SettingsPages.NotificationPage, name: 'Notifications', icon: Icons.Bell },
|
||||
{ page: SettingsPages.CallsPage, name: 'Calls & audio', icon: Icons.Phone },
|
||||
{ page: SettingsPages.DevicesPage, name: 'Privacy & security', icon: Icons.ShieldUser },
|
||||
{ page: SettingsPages.DataStoragePage, name: 'Data & storage', icon: Icons.Download },
|
||||
// [Gitea #459] Desktop-only options: nothing to show in a browser.
|
||||
...(isTauriEnv()
|
||||
? [{ page: SettingsPages.DesktopPage, name: 'Desktop', icon: Icons.Monitor }]
|
||||
: []),
|
||||
{ page: SettingsPages.EmojisStickersPage, name: 'Emojis & stickers', icon: Icons.Smile },
|
||||
{ page: SettingsPages.DeveloperToolsPage, name: 'Advanced', icon: Icons.Terminal },
|
||||
{ page: SettingsPages.AboutPage, name: 'About', icon: Icons.Info },
|
||||
] as SettingsMenuItem[],
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -102,9 +94,52 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||
const screenSize = useScreenSizeContext();
|
||||
const [activePage, setActivePage] = useState<SettingsPages | undefined>(() => {
|
||||
if (initialPage) return initialPage;
|
||||
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
|
||||
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.AccountPage;
|
||||
});
|
||||
const menuItems = useSettingsMenuItems();
|
||||
// [Gitea #463] Settings search: results replace the page list while typing;
|
||||
// picking one opens its page and briefly highlights the setting.
|
||||
const pageNames = useMemo(
|
||||
() => new Map(menuItems.map((item) => [item.page, item.name])),
|
||||
[menuItems],
|
||||
);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [highlight, setHighlight] = useState<string>();
|
||||
const pickResult = useCallback((entry: SettingsEntry) => {
|
||||
setActivePage(entry.page);
|
||||
setHighlight(entry.title);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!highlight) return undefined;
|
||||
let tries = 0;
|
||||
let flashTimer: number | undefined;
|
||||
const find = () => {
|
||||
const match = [...document.querySelectorAll<HTMLElement>('p, span, label, h1, h2, h3')].find(
|
||||
(el) =>
|
||||
!el.closest('nav') && el.textContent?.trim() === highlight && el.offsetParent !== null,
|
||||
);
|
||||
if (!match) {
|
||||
tries += 1;
|
||||
if (tries < 20) poll = window.setTimeout(find, 100);
|
||||
else setHighlight(undefined);
|
||||
return;
|
||||
}
|
||||
const target = (match.closest('[class*="SequenceCard"]') as HTMLElement | null) ?? match;
|
||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
const prev = target.style.boxShadow;
|
||||
target.style.boxShadow = `0 0 0 2px ${color.Primary.Main}`;
|
||||
target.style.transition = 'box-shadow 0.3s';
|
||||
flashTimer = window.setTimeout(() => {
|
||||
target.style.boxShadow = prev;
|
||||
setHighlight(undefined);
|
||||
}, 1800);
|
||||
};
|
||||
let poll = window.setTimeout(find, 50);
|
||||
return () => {
|
||||
window.clearTimeout(poll);
|
||||
window.clearTimeout(flashTimer);
|
||||
};
|
||||
}, [highlight, activePage]);
|
||||
|
||||
const handlePageRequestClose = () => {
|
||||
if (screenSize === ScreenSize.Mobile) {
|
||||
@@ -145,8 +180,13 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||
</Box>
|
||||
</PageNavHeader>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<SettingsSearch
|
||||
pageNames={pageNames}
|
||||
onActiveChange={setSearching}
|
||||
onPick={pickResult}
|
||||
/>
|
||||
<PageNavContent>
|
||||
<nav aria-label="Settings sections" style={{ flexGrow: 1 }}>
|
||||
<nav aria-label="Settings sections" style={{ flexGrow: 1 }} hidden={searching}>
|
||||
{menuItems.map((item) => (
|
||||
<MenuItem
|
||||
key={item.name}
|
||||
@@ -207,8 +247,20 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||
)
|
||||
}
|
||||
>
|
||||
{activePage === SettingsPages.GeneralPage && (
|
||||
<General requestClose={handlePageRequestClose} />
|
||||
{activePage === SettingsPages.AppearancePage && (
|
||||
<AppearancePage requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.MessagesPage && (
|
||||
<MessagesPage requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.CallsPage && (
|
||||
<CallsPage requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.DataStoragePage && (
|
||||
<DataStoragePage requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.DesktopPage && (
|
||||
<DesktopPage requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.AccountPage && (
|
||||
<Account requestClose={handlePageRequestClose} />
|
||||
@@ -217,13 +269,21 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||
<Notifications requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.DevicesPage && (
|
||||
<Devices requestClose={handlePageRequestClose} />
|
||||
<Devices
|
||||
title="Privacy & security"
|
||||
before={<Privacy />}
|
||||
requestClose={handlePageRequestClose}
|
||||
/>
|
||||
)}
|
||||
{activePage === SettingsPages.EmojisStickersPage && (
|
||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.DeveloperToolsPage && (
|
||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
||||
<DeveloperTools
|
||||
title="Advanced"
|
||||
before={<AdvancedDisplayOptions />}
|
||||
requestClose={handlePageRequestClose}
|
||||
/>
|
||||
)}
|
||||
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
|
||||
</PageRoot>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -38,11 +38,7 @@ import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getAccountData, setAccountData } from '../../../utils/accountData';
|
||||
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
|
||||
import {
|
||||
describePresenceError,
|
||||
setPresenceWithRetry,
|
||||
writePresence,
|
||||
} from '../../../utils/presenceWrite';
|
||||
import { describePresenceError, queuePresenceWrite } from '../../../utils/presenceWrite';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||
@@ -313,7 +309,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Display Name
|
||||
Display name
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
@@ -419,6 +415,10 @@ function ProfileStatus() {
|
||||
// until our own echo lands or a short window elapses (bounded so a genuinely
|
||||
// dropped echo can't block real cross-device updates forever).
|
||||
const pendingAppliedRef = useRef<{ value: string; ts: number } | null>(null);
|
||||
// The status as this device last applied or synced it. "Save" compares with
|
||||
// this, not with the server echo, so it switches off the moment a status is
|
||||
// applied instead of staying lit until the (rate-limited) write lands.
|
||||
const [savedMsg, setSavedMsg] = useState<string>(initialStatus);
|
||||
const [clearAfter, setClearAfter] = useState('0');
|
||||
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
||||
const { presets, addPreset, removePreset } = useStatusPresets();
|
||||
@@ -442,7 +442,7 @@ function ProfileStatus() {
|
||||
if (pending) {
|
||||
if (remoteStatus === pending.value) {
|
||||
pendingAppliedRef.current = null; // our own echo landed — accept it
|
||||
} else if (Date.now() - pending.ts < 15_000) {
|
||||
} else if (Date.now() - pending.ts < 30_000) {
|
||||
return; // stale echo of the previous status; ignore within the window
|
||||
} else {
|
||||
pendingAppliedRef.current = null; // window elapsed — accept external changes
|
||||
@@ -451,6 +451,7 @@ function ProfileStatus() {
|
||||
// [Gitea #283] "dnd" is the Do Not Disturb sentinel, not something the user
|
||||
// typed: don't show it in the status field or keep it as their status.
|
||||
if (remoteStatus === DND_STATUS_SENTINEL) return;
|
||||
setSavedMsg(remoteStatus);
|
||||
if (remoteStatus) {
|
||||
setStatusMsg(remoteStatus);
|
||||
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
|
||||
@@ -467,9 +468,10 @@ function ProfileStatus() {
|
||||
useCallback(
|
||||
(msg: string) =>
|
||||
// Synapse allows ONE presence write per 10 s per user (shared by all
|
||||
// devices) and our heartbeat spends that budget too — wait out a 429
|
||||
// instead of failing the user's save.
|
||||
setPresenceWithRetry(mx, {
|
||||
// devices) and our heartbeat spends that budget too. The shared queue
|
||||
// waits out a 429 and folds quick successive changes into one write;
|
||||
// the UI doesn't wait on it (the status is applied locally at once).
|
||||
queuePresenceWrite(mx, {
|
||||
// Derive presence from the user's chosen setting so writing a status
|
||||
// never overrides Invisible/DND/Idle (e.g. outing an Invisible user).
|
||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||
@@ -498,6 +500,7 @@ function ProfileStatus() {
|
||||
(rawMsg: string, clearAfterValue: string) => {
|
||||
statusDirtyRef.current = false;
|
||||
const msg = rawMsg.trim();
|
||||
setSavedMsg(msg);
|
||||
// Guard against a stale presence echo reverting this value (see the sync effect).
|
||||
pendingAppliedRef.current = { value: msg, ts: Date.now() };
|
||||
noteLocalStatusWrite();
|
||||
@@ -527,19 +530,17 @@ function ProfileStatus() {
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (saving) return;
|
||||
applyStatus(statusMsg, clearAfter);
|
||||
};
|
||||
|
||||
// Preset click = one-click apply: reflect it in the inputs and save immediately.
|
||||
const applyPreset = useCallback(
|
||||
(preset: StatusPreset) => {
|
||||
if (saving) return;
|
||||
setStatusMsg(preset.label);
|
||||
setClearAfter(preset.clearAfter);
|
||||
applyStatus(preset.label, preset.clearAfter);
|
||||
},
|
||||
[saving, applyStatus],
|
||||
[applyStatus],
|
||||
);
|
||||
|
||||
const handleSaveCurrent = useCallback(() => {
|
||||
@@ -560,23 +561,24 @@ function ProfileStatus() {
|
||||
pendingAppliedRef.current = { value: '', ts: Date.now() };
|
||||
noteLocalStatusWrite();
|
||||
setStatusMsg('');
|
||||
setSavedMsg('');
|
||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
writeStatusExpiry(mx, '').catch(() => undefined);
|
||||
// Preserve the user's chosen presence when clearing the status message.
|
||||
writePresence(mx, {
|
||||
queuePresenceWrite(mx, {
|
||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||
status_msg: '',
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const hasChanges = statusMsg !== (presence?.status ?? '');
|
||||
const hasChanges = statusMsg.trim() !== savedMsg;
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Status Message
|
||||
Status message
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
@@ -599,7 +601,6 @@ function ProfileStatus() {
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
disabled={saving}
|
||||
onClick={() => applyPreset(preset)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
@@ -625,7 +626,6 @@ function ProfileStatus() {
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
disabled={saving}
|
||||
onClick={() => applyPreset(preset)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
@@ -651,7 +651,7 @@ function ProfileStatus() {
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
outlined
|
||||
disabled={saving || !statusMsg.trim()}
|
||||
disabled={!statusMsg.trim()}
|
||||
onClick={handleSaveCurrent}
|
||||
before={<Icon size="100" src={Icons.Plus} />}
|
||||
>
|
||||
@@ -662,7 +662,7 @@ function ProfileStatus() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}>
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center">
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Input
|
||||
name="statusMsgInput"
|
||||
@@ -672,7 +672,6 @@ function ProfileStatus() {
|
||||
placeholder="What's on your mind?"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
readOnly={saving}
|
||||
maxLength={64}
|
||||
/>
|
||||
<Text
|
||||
@@ -733,13 +732,17 @@ function ProfileStatus() {
|
||||
fill={hasChanges ? 'Solid' : 'Soft'}
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={!hasChanges || saving}
|
||||
disabled={!hasChanges}
|
||||
type="submit"
|
||||
>
|
||||
{saving && <Spinner variant="Success" fill="Solid" size="300" />}
|
||||
<Text size="B400">Save</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{saving && (
|
||||
<Text size="T200" priority="300" role="status">
|
||||
Status set. Sharing it with others, this can take a few seconds.
|
||||
</Text>
|
||||
)}
|
||||
{saveState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{describePresenceError(saveState.error)}
|
||||
@@ -764,7 +767,6 @@ function ProfileStatus() {
|
||||
radii="300"
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text size="B300">Clear Status</Text>
|
||||
</Button>
|
||||
|
||||
@@ -102,6 +102,8 @@ export function ProfileDecoration() {
|
||||
|
||||
const [current, setCurrent] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
// [Gitea #460] The catalog stays closed until "Change"; it was always open.
|
||||
const [browsing, setBrowsing] = useState(false);
|
||||
// Distinguish "confirmed no decoration" from "failed to load": a fetch
|
||||
// failure must not be shown as (and saved over) "None".
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
@@ -199,7 +201,7 @@ export function ProfileDecoration() {
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Avatar Decoration
|
||||
Avatar decoration
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
@@ -282,6 +284,20 @@ export function ProfileDecoration() {
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{!loadError && (
|
||||
<Button
|
||||
type="button"
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
aria-expanded={browsing}
|
||||
onClick={() => setBrowsing((b) => !b)}
|
||||
>
|
||||
<Text size="B300">{browsing ? 'Done' : 'Change'}</Text>
|
||||
</Button>
|
||||
)}
|
||||
{hasChanges && !loadError && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -324,73 +340,77 @@ export function ProfileDecoration() {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Search + category tabs. Only one category (or the search results)
|
||||
{browsing && (
|
||||
<>
|
||||
{/* Search + category tabs. Only one category (or the search results)
|
||||
is mounted at a time so the picker never loads the whole catalog. */}
|
||||
<Input
|
||||
aria-label="Search decorations"
|
||||
placeholder="Search decorations"
|
||||
value={search}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
radii="300"
|
||||
before={<Icon size="100" src={Icons.Search} />}
|
||||
/>
|
||||
{!query && (
|
||||
<Box gap="100" wrap="Wrap" role="tablist" aria-label="Decoration categories">
|
||||
{DECORATION_CATEGORIES.map((category) => (
|
||||
<Chip
|
||||
key={category.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category.id === activeCategory}
|
||||
variant={category.id === activeCategory ? 'Primary' : 'Secondary'}
|
||||
fill="Soft"
|
||||
outlined={category.id === activeCategory}
|
||||
radii="Pill"
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
{category.label}
|
||||
</Text>
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<div
|
||||
role={query ? undefined : 'tabpanel'}
|
||||
style={{
|
||||
maxHeight: 360,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{visible.length === 0 ? (
|
||||
<Text size="T300" priority="300">
|
||||
No decorations match “{search.trim()}”.
|
||||
</Text>
|
||||
) : (
|
||||
<Input
|
||||
aria-label="Search decorations"
|
||||
placeholder="Search decorations"
|
||||
value={search}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
radii="300"
|
||||
before={<Icon size="100" src={Icons.Search} />}
|
||||
/>
|
||||
{!query && (
|
||||
<Box gap="100" wrap="Wrap" role="tablist" aria-label="Decoration categories">
|
||||
{DECORATION_CATEGORIES.map((category) => (
|
||||
<Chip
|
||||
key={category.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category.id === activeCategory}
|
||||
variant={category.id === activeCategory ? 'Primary' : 'Secondary'}
|
||||
fill="Soft"
|
||||
outlined={category.id === activeCategory}
|
||||
radii="Pill"
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
{category.label}
|
||||
</Text>
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<div
|
||||
role={query ? undefined : 'tabpanel'}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
|
||||
gap: 20,
|
||||
padding: 2,
|
||||
maxHeight: 360,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{visible.map((d) => (
|
||||
<DecorationPreviewCell
|
||||
key={d.slug}
|
||||
slug={d.slug}
|
||||
name={d.name}
|
||||
selected={selected === d.slug}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
{visible.length === 0 ? (
|
||||
<Text size="T300" priority="300">
|
||||
No decorations match “{search.trim()}”.
|
||||
</Text>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
|
||||
gap: 20,
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
{visible.map((d) => (
|
||||
<DecorationPreviewCell
|
||||
key={d.slug}
|
||||
slug={d.slug}
|
||||
name={d.name}
|
||||
selected={selected === d.slug}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</SettingTile>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useState, ReactNode } from 'react';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
@@ -18,8 +18,12 @@ import { CryptoDiagnostics } from '../developer/CryptoDiagnostics';
|
||||
|
||||
type DeveloperToolsProps = {
|
||||
requestClose: () => void;
|
||||
/** [Gitea #459] Shown as the page title (the menu entry's name). */
|
||||
title?: string;
|
||||
/** [Gitea #459] Sections shown above this page's own. */
|
||||
before?: ReactNode;
|
||||
};
|
||||
export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
export function DeveloperTools({ requestClose, title, before }: DeveloperToolsProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||
const [expand, setExpend] = useState(false);
|
||||
@@ -49,7 +53,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
Developer Tools
|
||||
{title ?? 'Developer Tools'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
@@ -63,6 +67,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
{before}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Options</Text>
|
||||
<SequenceCard
|
||||
@@ -72,7 +77,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Enable Developer Tools"
|
||||
title="Enable developer tools"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
@@ -90,7 +95,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">
|
||||
|
||||
@@ -137,7 +137,7 @@ function DeviceRename({ device, onCancel, onRename, refreshDeviceList }: DeviceR
|
||||
|
||||
return (
|
||||
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
|
||||
<Text size="L400">Device Name</Text>
|
||||
<Text size="L400">Device name</Text>
|
||||
<Box gap="200">
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Input
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
@@ -38,8 +38,12 @@ function DevicesPlaceholder() {
|
||||
|
||||
type DevicesProps = {
|
||||
requestClose: () => void;
|
||||
/** [Gitea #459] Shown as the page title (the menu entry's name). */
|
||||
title?: string;
|
||||
/** [Gitea #459] Sections shown above this page's own. */
|
||||
before?: ReactNode;
|
||||
};
|
||||
export function Devices({ requestClose }: DevicesProps) {
|
||||
export function Devices({ requestClose, title, before }: DevicesProps) {
|
||||
const mx = useMatrixClient();
|
||||
const crypto = mx.getCrypto();
|
||||
const crossSigningActive = useCrossSigningActive();
|
||||
@@ -70,7 +74,7 @@ export function Devices({ requestClose }: DevicesProps) {
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
Devices
|
||||
{title ?? 'Devices'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
@@ -84,6 +88,7 @@ export function Devices({ requestClose }: DevicesProps) {
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
{before}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Security</Text>
|
||||
<SequenceCard
|
||||
@@ -93,8 +98,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} />
|
||||
|
||||
@@ -67,7 +67,7 @@ function ExportKeys() {
|
||||
{(match, doMatch, passRef, confPassRef) => (
|
||||
<>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="L400">New Password</Text>
|
||||
<Text size="L400">New password</Text>
|
||||
<PasswordInput
|
||||
ref={passRef}
|
||||
name="passwordInput"
|
||||
@@ -82,7 +82,7 @@ function ExportKeys() {
|
||||
/>
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="L400">Confirm Password</Text>
|
||||
<Text size="L400">Confirm password</Text>
|
||||
<PasswordInput
|
||||
ref={confPassRef}
|
||||
style={{ color: match ? undefined : color.Critical.Main }}
|
||||
@@ -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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,287 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Method } from 'matrix-js-sdk';
|
||||
import { Box, Button, RadioButton, Text, color } from 'folds';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { Settings, settingsAtom } from '../../../state/settings';
|
||||
import {
|
||||
buildExport,
|
||||
EXPORT_ACCOUNT_DATA,
|
||||
ExportFile,
|
||||
ImportPlanItem,
|
||||
mergeData,
|
||||
parseExport,
|
||||
planImport,
|
||||
} from '../../../utils/dataExport';
|
||||
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
||||
|
||||
const DECORATION_FIELD = 'io.lotus.avatar_decoration';
|
||||
|
||||
type Current = {
|
||||
accountData: Record<string, unknown>;
|
||||
decoration: string | null;
|
||||
localSettings: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #114] "Your data": export the account's Lotus data (settings,
|
||||
* bookmarks, notes, sections, ...) to a file and import it here or on another
|
||||
* account. Import merges: new things are added; for things that differ the
|
||||
* user picks "keep mine" (default) or "use the file's". Message history and
|
||||
* encryption keys have their own exports.
|
||||
*/
|
||||
export function YourDataSection() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getSafeUserId();
|
||||
const [settings, setSettings] = useAtom(settingsAtom);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [pending, setPending] = useState<{ file: ExportFile; plan: ImportPlanItem[] }>();
|
||||
const [preferFile, setPreferFile] = useState(false);
|
||||
const [message, setMessage] = useState<{ tone: 'ok' | 'error'; text: string }>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const readCurrent = useCallback(async (): Promise<Current> => {
|
||||
const accountData: Record<string, unknown> = {};
|
||||
EXPORT_ACCOUNT_DATA.forEach(({ type }) => {
|
||||
const c = mx.getAccountData(type as never)?.getContent();
|
||||
if (c && Object.keys(c).length > 0) accountData[type] = c;
|
||||
});
|
||||
let decoration: string | null = null;
|
||||
try {
|
||||
const profile = await mx.http.authedRequest<Record<string, unknown>>(
|
||||
Method.Get,
|
||||
`/profile/${encodeURIComponent(userId)}`,
|
||||
);
|
||||
const v = profile[DECORATION_FIELD];
|
||||
decoration = typeof v === 'string' && v ? v : null;
|
||||
} catch {
|
||||
/* no decoration */
|
||||
}
|
||||
return {
|
||||
accountData,
|
||||
decoration,
|
||||
localSettings: settings as unknown as Record<string, unknown>,
|
||||
};
|
||||
}, [mx, userId, settings]);
|
||||
|
||||
const exportData = async () => {
|
||||
setBusy(true);
|
||||
setMessage(undefined);
|
||||
try {
|
||||
const cur = await readCurrent();
|
||||
const file = buildExport({ userId, ...cur });
|
||||
const blob = new Blob([JSON.stringify(file, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `lotus-chat-data-${userId.replace(/[^a-z0-9]+/gi, '-')}-${file.exportedAt.slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setMessage({ tone: 'ok', text: 'Exported. Keep the file somewhere safe.' });
|
||||
} catch {
|
||||
setMessage({ tone: 'error', text: "Couldn't export your data. Please try again." });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const chooseFile = async (f: File | undefined) => {
|
||||
if (!f) return;
|
||||
setMessage(undefined);
|
||||
const parsed = parseExport(await f.text());
|
||||
if (!parsed.ok) {
|
||||
setMessage({ tone: 'error', text: parsed.error });
|
||||
return;
|
||||
}
|
||||
const plan = planImport(parsed.file, await readCurrent());
|
||||
setPreferFile(false);
|
||||
setPending({ file: parsed.file, plan });
|
||||
};
|
||||
|
||||
const applyImport = async () => {
|
||||
if (!pending) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const cur = await readCurrent();
|
||||
const { file } = pending;
|
||||
await Object.entries(file.accountData).reduce<Promise<void>>(
|
||||
async (prev, [type, incoming]) => {
|
||||
await prev;
|
||||
const r = mergeData(cur.accountData[type], incoming, preferFile);
|
||||
if (r.added > 0 || (preferFile && r.conflicts > 0)) {
|
||||
await mx.setAccountData(type as never, r.value as never);
|
||||
}
|
||||
},
|
||||
Promise.resolve(),
|
||||
);
|
||||
const deco = file.profile.decoration;
|
||||
if (deco && deco !== cur.decoration && (!cur.decoration || preferFile)) {
|
||||
await mx.http.authedRequest(
|
||||
Method.Put,
|
||||
`/profile/${encodeURIComponent(userId)}/${DECORATION_FIELD}`,
|
||||
undefined,
|
||||
{ [DECORATION_FIELD]: deco },
|
||||
);
|
||||
invalidateDecorationCache(userId);
|
||||
}
|
||||
if (file.local.settings) {
|
||||
const r = mergeData(cur.localSettings ?? {}, file.local.settings, preferFile);
|
||||
setSettings({ ...settings, ...(r.value as Partial<Settings>) });
|
||||
}
|
||||
setPending(undefined);
|
||||
setMessage({ tone: 'ok', text: 'Imported.' });
|
||||
} catch {
|
||||
setMessage({ tone: 'error', text: "Couldn't finish the import. Nothing else was changed." });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totals = pending?.plan.reduce(
|
||||
(t, p) => ({ added: t.added + p.added, conflicts: t.conflicts + p.conflicts }),
|
||||
{ added: 0, conflicts: 0 },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Your data</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Export your data"
|
||||
description="Settings, bookmarks, private notes, room sections and names, reminders, status presets, soundboard and emoji, as one file. Not your messages or encryption keys (they have their own exports)."
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
outlined
|
||||
disabled={busy}
|
||||
onClick={exportData}
|
||||
>
|
||||
<Text size="B300">Export</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Import data"
|
||||
description="Load a Lotus Chat data file into this account. New items are added; you choose what happens to things that differ."
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
outlined
|
||||
disabled={busy}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<Text size="B300">Import…</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
hidden
|
||||
aria-label="Lotus Chat data file"
|
||||
onChange={(e) => {
|
||||
chooseFile(e.target.files?.[0]).catch(() => undefined);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{pending && totals && (
|
||||
<Box direction="Column" gap="200" role="region" aria-label="Import summary">
|
||||
{pending.file.userId && pending.file.userId !== userId && (
|
||||
<Text size="T200">
|
||||
This file is from <b>{pending.file.userId}</b>.
|
||||
</Text>
|
||||
)}
|
||||
{pending.plan.map((p) => (
|
||||
<Text key={p.key} size="T200">
|
||||
{p.label}:{' '}
|
||||
{p.added === 0 && p.conflicts === 0
|
||||
? 'already up to date'
|
||||
: [p.added > 0 && `${p.added} new`, p.conflicts > 0 && `${p.conflicts} differ`]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</Text>
|
||||
))}
|
||||
{totals.conflicts > 0 && (
|
||||
<Box direction="Column" gap="100" role="radiogroup" aria-label="Things that differ">
|
||||
<Text size="T200">
|
||||
<b>{totals.conflicts}</b>{' '}
|
||||
{totals.conflicts === 1 ? 'thing differs' : 'things differ'} from what's
|
||||
here:
|
||||
</Text>
|
||||
<label>
|
||||
<RadioButton
|
||||
name="your-data-conflicts"
|
||||
variant="Primary"
|
||||
size="300"
|
||||
checked={!preferFile}
|
||||
onClick={() => setPreferFile(false)}
|
||||
/>{' '}
|
||||
<Text as="span" size="T200">
|
||||
Keep mine
|
||||
</Text>
|
||||
</label>
|
||||
<label>
|
||||
<RadioButton
|
||||
name="your-data-conflicts"
|
||||
variant="Primary"
|
||||
size="300"
|
||||
checked={preferFile}
|
||||
onClick={() => setPreferFile(true)}
|
||||
/>{' '}
|
||||
<Text as="span" size="T200">
|
||||
Use the file's
|
||||
</Text>
|
||||
</label>
|
||||
</Box>
|
||||
)}
|
||||
<Box gap="200">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
radii="300"
|
||||
disabled={busy || (totals.added === 0 && !(preferFile && totals.conflicts > 0))}
|
||||
onClick={applyImport}
|
||||
>
|
||||
<Text size="B300">Import</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
disabled={busy}
|
||||
onClick={() => setPending(undefined)}
|
||||
>
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{message && (
|
||||
<Text
|
||||
size="T200"
|
||||
role="status"
|
||||
style={{ color: message.tone === 'error' ? color.Critical.Main : undefined }}
|
||||
>
|
||||
{message.text}
|
||||
</Text>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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,15 @@ 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>
|
||||
{/* [Gitea #459] Matrix terms kept, with one line on what they are. */}
|
||||
<Text size="T200" priority="300">
|
||||
The raw Matrix notification rules behind the options above, checked in this order: override,
|
||||
room, sender, then underride. You rarely need to change these.
|
||||
</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 +353,7 @@ export function PushRuleEditor() {
|
||||
}
|
||||
/>
|
||||
<RuleSection
|
||||
title="Sender Rules"
|
||||
title="Sender rules"
|
||||
kind={PushRuleKind.SenderSpecific}
|
||||
rules={senderRules}
|
||||
addForm={
|
||||
@@ -359,7 +364,7 @@ export function PushRuleEditor() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<RuleSection title="Underride Rules" kind={PushRuleKind.Underride} rules={underrideRules} />
|
||||
<RuleSection title="Underride rules" kind={PushRuleKind.Underride} rules={underrideRules} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export function SpecialMessagesNotifications() {
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="L400">Special Messages</Text>
|
||||
<Text size="L400">Special messages</Text>
|
||||
<Box gap="100">
|
||||
<Text size="T200">Badge: </Text>
|
||||
<Badge radii="300" variant="Success" fill="Solid">
|
||||
|
||||
@@ -63,7 +63,7 @@ function EmailNotification() {
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title="Email Notification"
|
||||
title="Email notifications"
|
||||
description={
|
||||
<>
|
||||
{result && !result.email && (
|
||||
@@ -161,7 +161,7 @@ function PauseNotifications() {
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title="Pause Notifications"
|
||||
title="Pause notifications"
|
||||
description={<span style={active ? { color: color.Warning.Main } : undefined}>{status}</span>}
|
||||
after={
|
||||
active ? (
|
||||
@@ -221,7 +221,7 @@ export function SystemNotification() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Desktop Notifications"
|
||||
title="Desktop notifications"
|
||||
description={
|
||||
notifPermission === 'denied' ? (
|
||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||
@@ -230,7 +230,7 @@ export function SystemNotification() {
|
||||
: 'Notifications are not supported by the system.'}
|
||||
</Text>
|
||||
) : (
|
||||
<span>Show desktop notifications when message arrive.</span>
|
||||
<span>Show desktop notifications when messages arrive.</span>
|
||||
)
|
||||
}
|
||||
after={
|
||||
@@ -263,8 +263,8 @@ export function SystemNotification() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Notification Sound"
|
||||
description="Play sound when new message arrive."
|
||||
title="Notification sound"
|
||||
description="Play a sound when a new message arrives."
|
||||
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
||||
/>
|
||||
{isNotificationSounds && (
|
||||
@@ -353,7 +353,7 @@ export function SystemNotification() {
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Quiet Hours"
|
||||
title="Quiet hours"
|
||||
description="Notifications are silenced between these times. Handles overnight (e.g. 11:00 PM – 8:00 AM)."
|
||||
after={<Switch value={quietHoursEnabled} onChange={setQuietHoursEnabled} />}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
/** Settings dialog pages. Kept apart from Settings.tsx so callers can name a
|
||||
* page without pulling the dialog into the entry chunk. */
|
||||
export enum SettingsPages {
|
||||
GeneralPage,
|
||||
AccountPage,
|
||||
// [Gitea #459] These replace the old catch-all General page.
|
||||
AppearancePage,
|
||||
MessagesPage,
|
||||
NotificationPage,
|
||||
CallsPage,
|
||||
/** "Privacy & security": privacy options plus devices and encryption. */
|
||||
DevicesPage,
|
||||
DataStoragePage,
|
||||
/** Desktop app only. */
|
||||
DesktopPage,
|
||||
EmojisStickersPage,
|
||||
/** "Advanced": developer tools and display plumbing. */
|
||||
DeveloperToolsPage,
|
||||
AboutPage,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Input, MenuItem, Text, config } from 'folds';
|
||||
import { SettingsPages } from '../pages';
|
||||
import { searchSettings, SettingsEntry } from './settingsIndex';
|
||||
import { loadSettingsIndex } from './loadSettingsIndex';
|
||||
import { isTauri } from '../../../hooks/useTauri';
|
||||
|
||||
type SettingsSearchProps = {
|
||||
pageNames: Map<SettingsPages, string>;
|
||||
/** Whether a query is active (the page list is hidden meanwhile). */
|
||||
onActiveChange: (active: boolean) => void;
|
||||
onPick: (entry: SettingsEntry) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #463] Search box at the top of the settings sidebar. Ctrl/Cmd+F
|
||||
* focuses it while settings are open; Esc clears it.
|
||||
*/
|
||||
export function SettingsSearch({ pageNames, onActiveChange, onPick }: SettingsSearchProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [entries, setEntries] = useState<SettingsEntry[]>();
|
||||
const active = query.trim().length > 0;
|
||||
|
||||
useEffect(() => onActiveChange(active), [active, onActiveChange]);
|
||||
|
||||
const ensureIndex = () => {
|
||||
if (!entries) loadSettingsIndex().then(setEntries, () => setEntries([]));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') {
|
||||
// Capture phase + stop: settings is on top, so this beats the app's
|
||||
// own Ctrl/Cmd+F (message search) underneath.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, []);
|
||||
|
||||
// Only desktop-app options in the desktop app; only pages that exist here.
|
||||
const results = entries
|
||||
? searchSettings(entries, query, { desktop: isTauri() }).filter((e) => pageNames.has(e.page))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="200" style={{ padding: `0 ${config.space.S200}` }}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
size="400"
|
||||
variant="Background"
|
||||
radii="400"
|
||||
placeholder="Search settings"
|
||||
aria-label="Search settings"
|
||||
value={query}
|
||||
onFocus={ensureIndex}
|
||||
onChange={(e) => {
|
||||
ensureIndex();
|
||||
setQuery(e.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && query) {
|
||||
e.stopPropagation();
|
||||
setQuery('');
|
||||
}
|
||||
if (e.key === 'Enter' && results[0]) {
|
||||
onPick(results[0]);
|
||||
setQuery('');
|
||||
}
|
||||
}}
|
||||
before={<Icon size="100" src={Icons.Search} />}
|
||||
after={
|
||||
query ? (
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Background"
|
||||
aria-label="Clear search"
|
||||
onClick={() => setQuery('')}
|
||||
>
|
||||
<Icon size="50" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{active && (
|
||||
<Box direction="Column" role="list" aria-label="Matching settings">
|
||||
{entries === undefined && (
|
||||
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
||||
Searching…
|
||||
</Text>
|
||||
)}
|
||||
{entries !== undefined && results.length === 0 && (
|
||||
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
||||
No settings match “{query.trim()}”.
|
||||
</Text>
|
||||
)}
|
||||
{results.map((r) => (
|
||||
<MenuItem
|
||||
key={`${r.page}-${r.title}`}
|
||||
role="listitem"
|
||||
variant="Background"
|
||||
radii="400"
|
||||
onClick={() => {
|
||||
onPick(r);
|
||||
setQuery('');
|
||||
}}
|
||||
>
|
||||
<Box direction="Column" style={{ padding: `${config.space.S100} 0`, minWidth: 0 }}>
|
||||
<Text size="T300" truncate>
|
||||
{r.title}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{pageNames.get(r.page)}
|
||||
{r.section && r.section !== r.title ? ` · ${r.section}` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { parseSettingsSource, SettingsEntry } from './settingsIndex';
|
||||
|
||||
// [Gitea #463] The settings source, as text, in its own lazily loaded chunk:
|
||||
// fetched the first time someone searches, never at startup.
|
||||
const sources = import.meta.glob(['../**/*.tsx', '!../**/*.test.tsx'], {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
}) as Record<string, () => Promise<string>>;
|
||||
|
||||
let index: Promise<SettingsEntry[]> | undefined;
|
||||
|
||||
export const loadSettingsIndex = (): Promise<SettingsEntry[]> => {
|
||||
index ??= Promise.all(
|
||||
Object.entries(sources).map(async ([path, load]) =>
|
||||
// '../general/General.tsx' -> '/general/General.tsx' (what the parser matches)
|
||||
parseSettingsSource(path.replace(/^\.\./, ''), await load()),
|
||||
),
|
||||
).then((all) => all.flat());
|
||||
return index;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { parseSettingsSource, searchSettings } from './settingsIndex';
|
||||
import { SettingsPages } from '../pages';
|
||||
|
||||
const general = fs.readFileSync('src/app/features/settings/general/General.tsx', 'utf8');
|
||||
const devices = fs.readFileSync('src/app/features/settings/devices/Devices.tsx', 'utf8');
|
||||
const sys = fs.readFileSync(
|
||||
'src/app/features/settings/notifications/SystemNotification.tsx',
|
||||
'utf8',
|
||||
);
|
||||
const entries = [
|
||||
...parseSettingsSource('src/app/features/settings/general/General.tsx', general),
|
||||
...parseSettingsSource('src/app/features/settings/devices/Devices.tsx', devices),
|
||||
...parseSettingsSource('src/app/features/settings/notifications/SystemNotification.tsx', sys),
|
||||
];
|
||||
const find = (t: string) => entries.find((e) => e.title === t);
|
||||
|
||||
test('[#463] indexes the real settings source with the right pages', () => {
|
||||
assert.equal(find('Push to deafen')?.page, SettingsPages.CallsPage);
|
||||
assert.equal(find('Strip tracking parameters from links')?.page, SettingsPages.DevicesPage);
|
||||
assert.equal(find('Page zoom')?.page, SettingsPages.AppearancePage);
|
||||
assert.equal(find('Enter for a new line')?.page, SettingsPages.MessagesPage);
|
||||
assert.equal(find('Show hidden events')?.page, SettingsPages.DeveloperToolsPage);
|
||||
assert.equal(find('Device verification')?.page, SettingsPages.DevicesPage);
|
||||
assert.equal(find('Quiet hours')?.page, SettingsPages.NotificationPage);
|
||||
assert.equal(find('Launch on login')?.desktopOnly, true);
|
||||
assert.match(find('Strip tracking parameters from links')?.description ?? '', /utm_/);
|
||||
assert.ok(entries.length > 70, `indexed ${entries.length}`);
|
||||
});
|
||||
|
||||
test('[#463] search: words anywhere, title first, desktop-only hidden in a browser', () => {
|
||||
assert.equal(searchSettings(entries, 'deafen')[0]?.title, 'Push to deafen');
|
||||
assert.equal(
|
||||
searchSettings(entries, 'tracking')[0]?.title,
|
||||
'Strip tracking parameters from links',
|
||||
);
|
||||
assert.equal(searchSettings(entries, 'zoom')[0]?.title, 'Page zoom');
|
||||
assert.ok(searchSettings(entries, 'utm').some((e) => e.title.startsWith('Strip tracking')));
|
||||
assert.equal(searchSettings(entries, 'launch on login').length, 0);
|
||||
assert.equal(
|
||||
searchSettings(entries, 'launch on login', { desktop: true })[0]?.title,
|
||||
'Launch on login',
|
||||
);
|
||||
assert.deepEqual(searchSettings(entries, ' '), []);
|
||||
});
|
||||
|
||||
test('[#463] parser handles the attribute forms and skips dynamic titles', () => {
|
||||
const src = `
|
||||
<Text size="L400">Things</Text>
|
||||
<SettingTile title="Plain" description="A & B" after={<Switch value={x} />} />
|
||||
<SettingTile title={'Quoted'} description={\`tick\`} />
|
||||
<SettingTile title={\`Dynamic \${name}\`} />
|
||||
<SettingTile title={someVar} />`;
|
||||
const got = parseSettingsSource('src/app/features/settings/about/About.tsx', src);
|
||||
assert.deepEqual(
|
||||
got.map((e) => [e.title, e.description, e.section]),
|
||||
[
|
||||
['Plain', 'A & B', 'Things'],
|
||||
['Quoted', 'tick', 'Things'],
|
||||
['Things', '', undefined],
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { SettingsPages } from '../pages';
|
||||
|
||||
/**
|
||||
* [Gitea #463] Search across all settings. The index is built from the
|
||||
* settings source itself (each <SettingTile title=… description=…> and each
|
||||
* section header), so options added later are found without a hand-kept list.
|
||||
* Pure: path + source text in, entries out.
|
||||
*/
|
||||
export type SettingsEntry = {
|
||||
title: string;
|
||||
description: string;
|
||||
page: SettingsPages;
|
||||
/** Section header the setting sits under, when known. */
|
||||
section?: string;
|
||||
/** Desktop-only (hidden in a browser). */
|
||||
desktopOnly?: boolean;
|
||||
};
|
||||
|
||||
// General.tsx: which page each section (or helper used by a section) is on.
|
||||
const GENERAL_SECTIONS: Record<string, SettingsPages> = {
|
||||
Appearance: SettingsPages.AppearancePage,
|
||||
SystemThemePreferences: SettingsPages.AppearancePage,
|
||||
SelectTheme: SettingsPages.AppearancePage,
|
||||
PageZoomInput: SettingsPages.AppearancePage,
|
||||
CollapsiblePicker: SettingsPages.AppearancePage,
|
||||
SeasonalBgGrid: SettingsPages.AppearancePage,
|
||||
ChatBgGrid: SettingsPages.AppearancePage,
|
||||
Messages: SettingsPages.MessagesPage,
|
||||
SelectMessageLayout: SettingsPages.MessagesPage,
|
||||
SelectMessageSpacing: SettingsPages.MessagesPage,
|
||||
Editor: SettingsPages.MessagesPage,
|
||||
ComposerToolbarReorder: SettingsPages.MessagesPage,
|
||||
ComposerToolbarButtonRow: SettingsPages.MessagesPage,
|
||||
DateAndTime: SettingsPages.MessagesPage,
|
||||
SelectDateFormat: SettingsPages.MessagesPage,
|
||||
DateHint: SettingsPages.MessagesPage,
|
||||
CustomDateFormat: SettingsPages.MessagesPage,
|
||||
PresetDateFormat: SettingsPages.MessagesPage,
|
||||
Calls: SettingsPages.CallsPage,
|
||||
Privacy: SettingsPages.DevicesPage,
|
||||
SettingsSyncSection: SettingsPages.DataStoragePage,
|
||||
AdvancedDisplayOptions: SettingsPages.DeveloperToolsPage,
|
||||
DesktopChromeSetting: SettingsPages.DesktopPage,
|
||||
AutostartSetting: SettingsPages.DesktopPage,
|
||||
KeychainMirrorSetting: SettingsPages.DesktopPage,
|
||||
DesktopSettings: SettingsPages.DesktopPage,
|
||||
PackageUpdate: SettingsPages.DesktopPage,
|
||||
AppUpdates: SettingsPages.DesktopPage,
|
||||
};
|
||||
|
||||
const FOLDER_PAGES: [RegExp, SettingsPages][] = [
|
||||
[/\/account\//, SettingsPages.AccountPage],
|
||||
[/\/notifications\//, SettingsPages.NotificationPage],
|
||||
[/\/devices\//, SettingsPages.DevicesPage],
|
||||
[/\/developer(-tools)?\//, SettingsPages.DeveloperToolsPage],
|
||||
[/\/emojis-stickers\//, SettingsPages.EmojisStickersPage],
|
||||
[/\/about\//, SettingsPages.AboutPage],
|
||||
[/\/general\/(StorageUsage|YourData)\.tsx$/, SettingsPages.DataStoragePage],
|
||||
];
|
||||
|
||||
const decode = (s: string): string =>
|
||||
s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/** A JSX attribute's static text: "…", {'…'}, {"…"} or {`…`} without ${}. */
|
||||
const attr = (tag: string, name: string): string | undefined => {
|
||||
const m = new RegExp(
|
||||
`\\b${name}=(?:"([^"]*)"|\\{\\s*'((?:[^'\\\\]|\\\\.)*)'\\s*\\}|\\{\\s*"([^"]*)"\\s*\\}|\\{\\s*\`([^\`$]*)\`\\s*\\})`,
|
||||
).exec(tag);
|
||||
const v = m && (m[1] ?? m[2] ?? m[3] ?? m[4]);
|
||||
return v ? decode(v) : undefined;
|
||||
};
|
||||
|
||||
/** The opening tag starting at `start` (up to the matching '>' outside braces/strings). */
|
||||
const openingTag = (src: string, start: number): string => {
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
for (let i = start; i < src.length; i += 1) {
|
||||
const c = src[i];
|
||||
if (quote) {
|
||||
if (c === quote && src[i - 1] !== '\\') quote = null;
|
||||
} else if (c === '"' || c === "'" || c === '`') quote = c;
|
||||
else if (c === '{') depth += 1;
|
||||
else if (c === '}') depth -= 1;
|
||||
else if (c === '>' && depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
return src.slice(start);
|
||||
};
|
||||
|
||||
export const parseSettingsSource = (path: string, src: string): SettingsEntry[] => {
|
||||
const isGeneral = /\/general\/General\.tsx$/.test(path);
|
||||
const folderPage = FOLDER_PAGES.find(([re]) => re.test(path))?.[1];
|
||||
if (!isGeneral && folderPage === undefined) return [];
|
||||
|
||||
// For General.tsx: positions of each top-level function, to know the section.
|
||||
const fns = isGeneral
|
||||
? [...src.matchAll(/\n(?:export )?function (\w+)\(/g)].map((m) => ({
|
||||
at: m.index ?? 0,
|
||||
name: m[1],
|
||||
}))
|
||||
: [];
|
||||
const pageAt = (pos: number): SettingsPages | undefined => {
|
||||
if (!isGeneral) return folderPage;
|
||||
let name: string | undefined;
|
||||
fns.forEach((f) => {
|
||||
if (f.at < pos) name = f.name;
|
||||
});
|
||||
return name ? GENERAL_SECTIONS[name] : undefined;
|
||||
};
|
||||
|
||||
const out: SettingsEntry[] = [];
|
||||
const headers = [...src.matchAll(/<Text size="L400">([^<{]+)<\/Text>/g)].map((m) => ({
|
||||
at: m.index ?? 0,
|
||||
text: decode(m[1]),
|
||||
}));
|
||||
const sectionAt = (pos: number): string | undefined => {
|
||||
let s: string | undefined;
|
||||
headers.forEach((h) => {
|
||||
if (h.at < pos) s = h.text;
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
for (const m of src.matchAll(/<SettingTile\b/g)) {
|
||||
const at = m.index ?? 0;
|
||||
const tag = openingTag(src, at);
|
||||
const title = attr(tag, 'title');
|
||||
const page = pageAt(at);
|
||||
if (title && page !== undefined) {
|
||||
out.push({
|
||||
title,
|
||||
description: attr(tag, 'description') ?? '',
|
||||
page,
|
||||
section: sectionAt(at),
|
||||
desktopOnly: page === SettingsPages.DesktopPage || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
headers.forEach((h) => {
|
||||
const page = pageAt(h.at);
|
||||
if (page !== undefined && !out.some((e) => e.title === h.text && e.page === page)) {
|
||||
out.push({
|
||||
title: h.text,
|
||||
description: '',
|
||||
page,
|
||||
desktopOnly: page === SettingsPages.DesktopPage || undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
const norm = (s: string): string => s.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
|
||||
|
||||
/**
|
||||
* Pure: rank entries for a query. Every word must appear in the title,
|
||||
* description or section; title matches rank first.
|
||||
*/
|
||||
export const searchSettings = (
|
||||
entries: SettingsEntry[],
|
||||
query: string,
|
||||
{ desktop = false, limit = 30 }: { desktop?: boolean; limit?: number } = {},
|
||||
): SettingsEntry[] => {
|
||||
const words = norm(query).split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return [];
|
||||
const scored: { e: SettingsEntry; score: number }[] = [];
|
||||
entries.forEach((e) => {
|
||||
if (e.desktopOnly && !desktop) return;
|
||||
const title = norm(e.title);
|
||||
const rest = norm(`${e.description} ${e.section ?? ''}`);
|
||||
let score = 0;
|
||||
const all = words.every((w) => {
|
||||
if (title.startsWith(w)) score += 3;
|
||||
else if (title.includes(w)) score += 2;
|
||||
else if (rest.includes(w)) score += 1;
|
||||
else return false;
|
||||
return true;
|
||||
});
|
||||
if (all) scored.push({ e, score });
|
||||
});
|
||||
return scored
|
||||
.sort((a, b) => b.score - a.score || a.e.title.localeCompare(b.e.title))
|
||||
.slice(0, limit)
|
||||
.map((s) => s.e);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, CSSProperties } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { color, config, Icon, IconButton, Icons } from 'folds';
|
||||
import { Button, color, config, Icon, IconButton, Icons, Text } from 'folds';
|
||||
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
@@ -43,13 +43,17 @@ function ToastCard({ toast }: ToastCardProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (toast.sticky) return;
|
||||
timerRef.current = setTimeout(() => {
|
||||
dismiss(toast.id);
|
||||
}, 4000);
|
||||
timerRef.current = setTimeout(
|
||||
() => {
|
||||
dismiss(toast.id);
|
||||
},
|
||||
// [Gitea #253] A toast with a button stays long enough to use it.
|
||||
toast.actionLabel ? 10_000 : 4000,
|
||||
);
|
||||
return () => {
|
||||
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [dismiss, toast.id, toast.sticky]);
|
||||
}, [dismiss, toast.id, toast.sticky, toast.actionLabel]);
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (toast.onClick) {
|
||||
@@ -212,6 +216,24 @@ function ToastCard({ toast }: ToastCardProps) {
|
||||
</div>
|
||||
<div style={bodyStyle}>{toast.body}</div>
|
||||
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
|
||||
{toast.actionLabel && toast.onAction && (
|
||||
<div style={{ marginTop: config.space.S200 }}>
|
||||
<Button
|
||||
type="button"
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toast.onAction?.();
|
||||
dismiss(toast.id);
|
||||
}}
|
||||
>
|
||||
<Text size="B300">{toast.actionLabel}</Text>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useSetAtom } from 'jotai';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
import { useCallHangupEvent } from './useCallEmbed';
|
||||
import { toastQueueAtom } from '../state/toast';
|
||||
import { describeCallEnd } from '../utils/callSummary';
|
||||
import { describeCallDetails, describeCallEnd } from '../utils/callSummary';
|
||||
import { callSummaryViewAtom } from '../state/callSummary';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
|
||||
// The fork sends io.lotus.call_summary on SFU disconnect, which normally lands
|
||||
// before Element Call's hangup echo; give a late one this long before toasting.
|
||||
@@ -16,6 +19,8 @@ const SUMMARY_GRACE_MS = 400;
|
||||
*/
|
||||
export function useCallEndedToast(embed: CallEmbed): void {
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
const setSummaryView = useSetAtom(callSummaryViewAtom);
|
||||
const [callSummaryToast] = useSetting(settingsAtom, 'callSummaryToast');
|
||||
const fired = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,14 +30,25 @@ export function useCallEndedToast(embed: CallEmbed): void {
|
||||
useCallHangupEvent(embed, () => {
|
||||
if (fired.current || !embed.joined || embed.joinedAt === undefined) return;
|
||||
fired.current = true;
|
||||
if (!callSummaryToast) return;
|
||||
const durationMs = Date.now() - embed.joinedAt;
|
||||
const deafenedMs = embed.deafenedMs();
|
||||
const roomName = embed.room.name ?? 'Voice call';
|
||||
const nameOf = (userId: string) => embed.room.getMember(userId)?.name ?? userId;
|
||||
const toast = () =>
|
||||
setToast({
|
||||
id: `call-ended-${Date.now()}`,
|
||||
displayName: 'Lotus Chat',
|
||||
body: describeCallEnd(durationMs, embed.lastSummary),
|
||||
roomName: embed.room.name ?? 'Voice call',
|
||||
roomName,
|
||||
roomId: embed.roomId,
|
||||
// [Gitea #253] The full breakdown, snapshotted at hangup.
|
||||
actionLabel: 'Details',
|
||||
onAction: () =>
|
||||
setSummaryView({
|
||||
roomName,
|
||||
sections: describeCallDetails(embed.lastSummary, durationMs, deafenedMs, nameOf),
|
||||
}),
|
||||
});
|
||||
if (embed.lastSummary) toast();
|
||||
else setTimeout(toast, SUMMARY_GRACE_MS);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { SetPresence } from 'matrix-js-sdk';
|
||||
import { PresenceDeduper, PresenceWrite, writePresence } from '../utils/presenceWrite';
|
||||
import { PresenceDeduper, PresenceWrite, queuePresenceWrite } from '../utils/presenceWrite';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
@@ -70,9 +70,25 @@ export function usePresenceUpdater() {
|
||||
// by all devices) and /sync already keeps us online — only send when the
|
||||
// state or status actually changes, so a manual status save isn't 429'd
|
||||
// by a heartbeat that changed nothing.
|
||||
// What the server reported in the last resolveStatus() (consumed by send).
|
||||
let serverNow: { presence: string; status_msg?: string } | undefined;
|
||||
const send = (write: PresenceWrite, label: string) => {
|
||||
const server = serverNow;
|
||||
serverNow = undefined;
|
||||
if (!deduper.shouldSend(write)) return Promise.resolve();
|
||||
return writePresence(mx, write)
|
||||
// The server already shows exactly this (e.g. /sync keeps us online and
|
||||
// the status is unchanged): writing it again would only spend the
|
||||
// 10-second budget a status save may need next.
|
||||
if (
|
||||
server &&
|
||||
server.presence === write.presence &&
|
||||
(server.status_msg ?? '') === (write.status_msg ?? '')
|
||||
) {
|
||||
deduper.sent(write);
|
||||
return Promise.resolve();
|
||||
}
|
||||
// Shared queue with the status save: waits out a 429, newest write wins.
|
||||
return queuePresenceWrite(mx, write)
|
||||
.then(() => deduper.sent(write))
|
||||
.catch((err) => warnPresenceFailure(label, err));
|
||||
};
|
||||
@@ -84,6 +100,7 @@ export function usePresenceUpdater() {
|
||||
const local = readStatus();
|
||||
if (!userId) return local;
|
||||
const server = await mx.getPresence(userId).catch(() => undefined);
|
||||
serverNow = server;
|
||||
const status = pickHeartbeatStatus({
|
||||
local,
|
||||
server,
|
||||
|
||||
@@ -140,7 +140,7 @@ export function useSettingsSync(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual actions for Settings → General → Sync. `pushNow` stamps a fresh
|
||||
* Manual actions for Settings → Data & storage → Sync. `pushNow` stamps a fresh
|
||||
* snapshot of THIS device so it wins on every other device; `clearRemote`
|
||||
* blanks the account-data event (other devices ignore the invalid content and
|
||||
* keep their local settings until someone pushes again).
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useCompositionEndTracking } from '../hooks/useComposingCheck';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { isWithinTimeWindow } from '../utils/timeWindow';
|
||||
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
||||
import { CallSummaryDialog } from '../features/call-summary/CallSummaryDialog';
|
||||
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
||||
import { useSessionAppeared } from '../hooks/useSessionAppeared';
|
||||
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
|
||||
@@ -248,6 +249,7 @@ function App() {
|
||||
<SeasonalEffect />
|
||||
<NightLightOverlay />
|
||||
<LotusToastContainer />
|
||||
<CallSummaryDialog />
|
||||
</JotaiProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Button, Text, config } from 'folds';
|
||||
import { removeFallbackSession } from '../state/sessions';
|
||||
import type { KeychainBootResult } from '../state/keychainSession';
|
||||
import type { KeychainInvoke } from '../state/keychainMirror';
|
||||
|
||||
/**
|
||||
* [Gitea #105] Shown instead of the app when the desktop app's sign-in is kept
|
||||
* in the OS keychain but couldn't be read this time. Never a silent logout:
|
||||
* Retry boots again; signing in again is an explicit choice.
|
||||
*/
|
||||
export function KeychainUnavailable({
|
||||
boot,
|
||||
invoke,
|
||||
}: {
|
||||
boot: KeychainBootResult;
|
||||
invoke: KeychainInvoke | undefined;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const missing = boot.mode === 'missing';
|
||||
const signInAgain = async () => {
|
||||
setBusy(true);
|
||||
removeFallbackSession();
|
||||
await invoke?.('secure_session_clear').catch(() => undefined);
|
||||
window.location.reload();
|
||||
};
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="400"
|
||||
style={{ minHeight: '100vh', padding: config.space.S700, textAlign: 'center' }}
|
||||
>
|
||||
<Text size="H3">Couldn't read your sign-in</Text>
|
||||
<Text size="T300" style={{ maxWidth: 460 }}>
|
||||
{missing
|
||||
? 'Lotus Chat keeps your sign-in in Windows Credential Manager, but the entry is missing. If you removed it, sign in again.'
|
||||
: 'Lotus Chat keeps your sign-in in Windows Credential Manager and couldn’t read it just now. This is usually temporary.'}
|
||||
</Text>
|
||||
{boot.mode === 'error' && (
|
||||
<Text size="T200" priority="300">
|
||||
{boot.error}
|
||||
</Text>
|
||||
)}
|
||||
<Box gap="200">
|
||||
<Button
|
||||
variant="Primary"
|
||||
size="400"
|
||||
radii="300"
|
||||
disabled={busy}
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<Text size="B400">Retry</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="400"
|
||||
radii="300"
|
||||
disabled={busy}
|
||||
onClick={signInAgain}
|
||||
>
|
||||
<Text size="B400">Sign in again</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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,21 +57,19 @@ 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">
|
||||
Login
|
||||
</Text>
|
||||
<OidcLogin
|
||||
issuer={oidc.issuer}
|
||||
homeserverBaseUrl={discovery['m.homeserver'].base_url}
|
||||
serverName={server}
|
||||
/>
|
||||
<OidcLogin homeserverBaseUrl={discovery['m.homeserver'].base_url} serverName={server} />
|
||||
<span data-spacing-node />
|
||||
<Text align="Center">
|
||||
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { startOidcLogin } from '../oidc/oidcLoginUtil';
|
||||
|
||||
type OidcLoginProps = {
|
||||
issuer: string;
|
||||
/** Register: open the auth server's sign-up page. */
|
||||
register?: boolean;
|
||||
homeserverBaseUrl: string;
|
||||
serverName: string;
|
||||
};
|
||||
@@ -13,7 +14,7 @@ type OidcLoginProps = {
|
||||
* with the homeserver's OIDC provider and redirects to it. Mirrors `SSOLogin`'s
|
||||
* single-button styling.
|
||||
*/
|
||||
export function OidcLogin({ issuer, homeserverBaseUrl, serverName }: OidcLoginProps) {
|
||||
export function OidcLogin({ homeserverBaseUrl, serverName, register }: OidcLoginProps) {
|
||||
const [state, startLogin] = useAsyncCallback<void, unknown, Parameters<typeof startOidcLogin>>(
|
||||
useCallback(startOidcLogin, []),
|
||||
);
|
||||
@@ -26,7 +27,9 @@ export function OidcLogin({ issuer, homeserverBaseUrl, serverName }: OidcLoginPr
|
||||
<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
|
||||
@@ -35,7 +38,7 @@ export function OidcLogin({ issuer, homeserverBaseUrl, serverName }: OidcLoginPr
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
onClick={() => startLogin(issuer, homeserverBaseUrl)}
|
||||
onClick={() => startLogin(homeserverBaseUrl, register ? 'create' : undefined)}
|
||||
disabled={ongoing}
|
||||
before={ongoing ? <Spinner size="200" variant="Secondary" /> : undefined}
|
||||
>
|
||||
|
||||
@@ -1,43 +1,45 @@
|
||||
import { Box, Button, Icon, Icons, Spinner, Text, color, config } from 'folds';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { createClient } from 'matrix-js-sdk';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { setFallbackSession } from '../../../state/sessions';
|
||||
import { completeAuthorizationCodeGrant } from './oidcLoginUtil';
|
||||
import { getOidcCallbackUrl } from './oidcConfig';
|
||||
import { completeOidcCodeGrant } from './oidcLoginUtil';
|
||||
import {
|
||||
invalidateCachedClient,
|
||||
isStaleClientError,
|
||||
parseOidcCallbackParams,
|
||||
readStoredOidcIssuer,
|
||||
takePendingOidcLogin,
|
||||
} from './oidcState';
|
||||
|
||||
/**
|
||||
* Exchange the authorization code for a Matrix session and persist it. The SDK
|
||||
* (oidc-client-ts) reads the transient PKCE/state it stored in sessionStorage
|
||||
* before the redirect and validates `state` (CSRF). `redirectUri` is the
|
||||
* deterministic callback URL, recomputed here.
|
||||
* Exchange the authorization code for a Matrix session and persist it. The
|
||||
* pending sign-in saved before the redirect must exist for this `state`
|
||||
* (CSRF check) and is consumed here.
|
||||
*/
|
||||
const completeOidcLogin = async (code: string, state: string): Promise<void> => {
|
||||
const { tokenResponse, homeserverUrl, oidcClientSettings, idTokenClaims } =
|
||||
await completeAuthorizationCodeGrant(code, state);
|
||||
const pending = takePendingOidcLogin(state);
|
||||
if (!pending) throw new Error('No pending sign-in for this state.');
|
||||
const tokenResponse = await completeOidcCodeGrant(code, pending);
|
||||
|
||||
// Derive Matrix user_id + device_id from the freshly-issued access token (the
|
||||
// device is owned by the authentication service under MSC3861).
|
||||
const tmp = createClient({ baseUrl: homeserverUrl, accessToken: tokenResponse.access_token });
|
||||
const tmp = createClient({
|
||||
baseUrl: pending.homeserverUrl,
|
||||
accessToken: tokenResponse.access_token,
|
||||
});
|
||||
const { user_id: userId, device_id: deviceId } = await tmp.whoami();
|
||||
if (!userId || !deviceId) {
|
||||
throw new Error('Homeserver did not return a user/device for the OIDC session.');
|
||||
}
|
||||
|
||||
setFallbackSession(tokenResponse.access_token, deviceId, userId, homeserverUrl, {
|
||||
setFallbackSession(tokenResponse.access_token, deviceId, userId, pending.homeserverUrl, {
|
||||
refreshToken: tokenResponse.refresh_token,
|
||||
expiresInMs: tokenResponse.expires_in ? tokenResponse.expires_in * 1000 : undefined,
|
||||
oidc: {
|
||||
issuer: oidcClientSettings.issuer,
|
||||
clientId: oidcClientSettings.clientId,
|
||||
redirectUri: getOidcCallbackUrl(),
|
||||
idTokenClaims: idTokenClaims as unknown as Record<string, unknown>,
|
||||
issuer: pending.issuer,
|
||||
clientId: pending.clientId,
|
||||
redirectUri: pending.redirectUri,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -71,14 +73,19 @@ function CallbackError({ message }: { message: string }) {
|
||||
* router, since the redirect_uri must be a real non-hash path).
|
||||
*/
|
||||
export function OidcCallback() {
|
||||
const params = parseOidcCallbackParams(window.location.search);
|
||||
// Parsed once: a fresh object per render would re-run the effects below on
|
||||
// every state change, and the code (and its pending sign-in) is single-use.
|
||||
const params = useMemo(() => parseOidcCallbackParams(window.location.search), []);
|
||||
const redeemed = useRef(false);
|
||||
|
||||
const [state, complete] = useAsyncCallback<void, unknown, [string, string]>(
|
||||
useCallback(completeOidcLogin, []),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (params.kind === 'success') complete(params.code, params.state);
|
||||
if (params.kind !== 'success' || redeemed.current) return;
|
||||
redeemed.current = true;
|
||||
complete(params.code, params.state);
|
||||
}, [params, complete]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk';
|
||||
import type { OAuthRegistrationRequest } from 'matrix-js-sdk';
|
||||
import { OIDC_CALLBACK_PATH } from '../../paths';
|
||||
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
|
||||
|
||||
@@ -17,22 +17,21 @@ export const getOidcCallbackUrl = (): string =>
|
||||
withOriginBaseUrl(getOriginBaseUrl(), OIDC_CALLBACK_PATH);
|
||||
|
||||
/**
|
||||
* Client metadata sent during MSC2966 dynamic client registration.
|
||||
* Client metadata sent during dynamic client registration.
|
||||
*
|
||||
* `registerOidcClient` drops any URI that doesn't share `clientUri` as a common
|
||||
* base, so every URI here lives under our origin base.
|
||||
* The SDK drops any URI that doesn't share `client_uri` as a common base, so
|
||||
* every URI here lives under our origin base.
|
||||
*/
|
||||
export const getOidcClientMetadata = (): OidcRegistrationClientMetadata => {
|
||||
export const getOidcClientMetadata = (): OAuthRegistrationRequest => {
|
||||
// `${origin}${BASE_URL}` (with trailing slash) — the common base for all URIs.
|
||||
const clientUri = getOriginBaseUrl();
|
||||
return {
|
||||
clientName: 'Lotus Chat',
|
||||
clientUri,
|
||||
logoUri: new URL(LotusLogo, window.location.origin).href,
|
||||
applicationType: 'web',
|
||||
contacts: ['support@lotusguild.org'],
|
||||
tosUri: clientUri,
|
||||
policyUri: clientUri,
|
||||
redirectUris: [getOidcCallbackUrl()],
|
||||
client_name: 'Lotus Chat',
|
||||
client_uri: clientUri,
|
||||
logo_uri: new URL(LotusLogo, window.location.origin).href,
|
||||
application_type: 'web',
|
||||
tos_uri: clientUri,
|
||||
policy_uri: clientUri,
|
||||
redirect_uris: [getOidcCallbackUrl()],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,57 +1,88 @@
|
||||
import {
|
||||
completeAuthorizationCodeGrant,
|
||||
discoverAndValidateOIDCIssuerWellKnown,
|
||||
generateOidcAuthorizationUrl,
|
||||
OidcClientConfig,
|
||||
registerOidcClient,
|
||||
} from 'matrix-js-sdk';
|
||||
import { createClient, OAuth2, ValidatedAuthMetadata } from 'matrix-js-sdk';
|
||||
import { getOidcCallbackUrl, getOidcClientMetadata } from './oidcConfig';
|
||||
import {
|
||||
cacheClientId,
|
||||
getCachedClientId,
|
||||
invalidateCachedClient,
|
||||
isStaleClientError,
|
||||
PendingOidcLogin,
|
||||
savePendingOidcLogin,
|
||||
} from './oidcState';
|
||||
|
||||
export { completeAuthorizationCodeGrant };
|
||||
/**
|
||||
* The homeserver's OAuth 2.0 metadata (`GET /_matrix/client/v1/auth_metadata`).
|
||||
* This is also how the SDK finds the auth server for token refresh and logout,
|
||||
* so login uses the same source.
|
||||
*/
|
||||
export const discoverAuthMetadata = (homeserverBaseUrl: string): Promise<ValidatedAuthMetadata> =>
|
||||
createClient({ baseUrl: homeserverBaseUrl }).getAuthMetadata();
|
||||
|
||||
export const getOrRegisterClientId = async (
|
||||
config: OidcClientConfig,
|
||||
issuer: string,
|
||||
metadata: ValidatedAuthMetadata,
|
||||
redirectUri: string,
|
||||
): Promise<string> => {
|
||||
const cached = getCachedClientId(issuer, redirectUri);
|
||||
const cached = getCachedClientId(metadata.issuer, redirectUri);
|
||||
if (cached) return cached;
|
||||
const clientId = await registerOidcClient(config, getOidcClientMetadata());
|
||||
cacheClientId(issuer, clientId, redirectUri);
|
||||
const clientId = await OAuth2.registerClient(metadata, getOidcClientMetadata());
|
||||
cacheClientId(metadata.issuer, clientId, redirectUri);
|
||||
return clientId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Begin the OIDC authorization-code flow: discover + validate the issuer,
|
||||
* (re)register a client, build the PKCE authorization URL (oidc-client-ts
|
||||
* persists the transient code_verifier/state/nonce/homeserverUrl into
|
||||
* sessionStorage for the callback), then redirect.
|
||||
* 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. `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 (issuer: string, homeserverBaseUrl: string): Promise<void> => {
|
||||
export const startOidcLogin = async (
|
||||
homeserverBaseUrl: string,
|
||||
prompt?: 'create',
|
||||
): Promise<void> => {
|
||||
const redirectUri = getOidcCallbackUrl();
|
||||
let issuer: string | undefined;
|
||||
try {
|
||||
const config = await discoverAndValidateOIDCIssuerWellKnown(issuer);
|
||||
const clientId = await getOrRegisterClientId(config, issuer, redirectUri);
|
||||
const url = await generateOidcAuthorizationUrl({
|
||||
metadata: config,
|
||||
redirectUri,
|
||||
const metadata = await discoverAuthMetadata(homeserverBaseUrl);
|
||||
issuer = metadata.issuer;
|
||||
const clientId = await getOrRegisterClientId(metadata, redirectUri);
|
||||
const oauth = new OAuth2(metadata, { clientId });
|
||||
const state = crypto.randomUUID();
|
||||
const pending: PendingOidcLogin = {
|
||||
issuer,
|
||||
clientId,
|
||||
deviceId: oauth.context.deviceId,
|
||||
codeVerifier: oauth.context.codeVerifier,
|
||||
homeserverUrl: homeserverBaseUrl,
|
||||
nonce: crypto.randomUUID(),
|
||||
});
|
||||
window.location.assign(url);
|
||||
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', usePrompt),
|
||||
);
|
||||
} catch (e) {
|
||||
// #67 — drop the cached client so the next attempt re-registers, but ONLY
|
||||
// when the failure says the client id itself was rejected. Network and
|
||||
// discovery failures keep the cache, otherwise each retry would register
|
||||
// yet another throwaway dynamic client at the provider.
|
||||
if (isStaleClientError(e)) invalidateCachedClient(issuer);
|
||||
if (issuer && isStaleClientError(e)) invalidateCachedClient(issuer);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Exchange the authorization code using the pending sign-in saved before the
|
||||
* redirect. The metadata is fetched again (it isn't stored).
|
||||
*/
|
||||
export const completeOidcCodeGrant = async (code: string, pending: PendingOidcLogin) => {
|
||||
const metadata = await discoverAuthMetadata(pending.homeserverUrl);
|
||||
const oauth = new OAuth2(metadata, {
|
||||
clientId: pending.clientId,
|
||||
deviceId: pending.deviceId,
|
||||
codeVerifier: pending.codeVerifier,
|
||||
});
|
||||
return oauth.completeAuthorizationCodeGrant(code, pending.redirectUri);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
parseOidcCallbackParams,
|
||||
isStaleClientError,
|
||||
readStoredOidcIssuer,
|
||||
savePendingOidcLogin,
|
||||
takePendingOidcLogin,
|
||||
} from './oidcState';
|
||||
|
||||
const installStorage = (): Map<string, string> => {
|
||||
@@ -131,21 +133,29 @@ test('isStaleClientError: also recognises the OidcCallback error-redirect shape
|
||||
);
|
||||
});
|
||||
|
||||
test('readStoredOidcIssuer reads the issuer persisted by generateOidcAuthorizationUrl', () => {
|
||||
const store = installSessionStorage();
|
||||
const pending = {
|
||||
issuer: 'https://issuer.example/',
|
||||
clientId: 'client1',
|
||||
deviceId: 'DEVICEABCD',
|
||||
codeVerifier: 'v'.repeat(96),
|
||||
homeserverUrl: 'https://hs.example',
|
||||
redirectUri: 'https://app.example/oidc/callback',
|
||||
};
|
||||
|
||||
test('pending sign-in: saved by state, read for the issuer, consumed once', () => {
|
||||
installSessionStorage();
|
||||
assert.equal(readStoredOidcIssuer('xyz'), undefined); // nothing stored yet
|
||||
store.set(
|
||||
'mx_oidc_xyz',
|
||||
JSON.stringify({ authority: 'https://issuer.example', client_id: 'abc' }),
|
||||
);
|
||||
assert.equal(readStoredOidcIssuer('xyz'), 'https://issuer.example');
|
||||
savePendingOidcLogin('xyz', pending);
|
||||
assert.equal(readStoredOidcIssuer('xyz'), 'https://issuer.example/');
|
||||
assert.equal(readStoredOidcIssuer('other-state'), undefined); // different state = miss
|
||||
assert.deepEqual(takePendingOidcLogin('xyz'), pending);
|
||||
assert.equal(takePendingOidcLogin('xyz'), undefined, 'single use');
|
||||
});
|
||||
|
||||
test('readStoredOidcIssuer tolerates corrupt or missing storage', () => {
|
||||
test('pending sign-in tolerates corrupt or incomplete storage', () => {
|
||||
const store = installSessionStorage();
|
||||
store.set('mx_oidc_bad', '{ not json');
|
||||
store.set('lotus_oidc_pending_bad', '{ not json');
|
||||
assert.equal(readStoredOidcIssuer('bad'), undefined);
|
||||
store.set('mx_oidc_noauth', JSON.stringify({ client_id: 'abc' }));
|
||||
assert.equal(readStoredOidcIssuer('noauth'), undefined);
|
||||
store.set('lotus_oidc_pending_partial', JSON.stringify({ ...pending, codeVerifier: '' }));
|
||||
assert.equal(takePendingOidcLogin('partial'), undefined);
|
||||
});
|
||||
|
||||
@@ -97,23 +97,60 @@ export const parseOidcCallbackParams = (search: string): OidcCallbackParams => {
|
||||
};
|
||||
|
||||
/**
|
||||
* #102 — the issuer for a pending sign-in isn't available on the callback's
|
||||
* error branch (we bail out before `completeAuthorizationCodeGrant`, which is
|
||||
* what would otherwise surface it). oidc-client-ts's `generateOidcAuthorizationUrl`
|
||||
* persists the pending sign-in's `SigninState` (including `authority` and
|
||||
* `client_id`) into sessionStorage before redirecting, keyed by
|
||||
* `mx_oidc_<state>` (WebStorageStateStore prefix `mx_oidc_`, keyed by the same
|
||||
* `state` value the provider echoes back on redirect). Read it directly so a
|
||||
* genuinely stale cached client id (`invalid_client`) can be evicted even when
|
||||
* the callback errors before a token exchange is attempted.
|
||||
* [Gitea #201, SDK 44] The app keeps a pending OAuth sign-in itself (the SDK's
|
||||
* OAuth2 class no longer persists anything). Saved in sessionStorage right
|
||||
* before the redirect, keyed by the `state` we send, which the provider echoes
|
||||
* back: a callback whose state has no entry is rejected (CSRF), and an entry is
|
||||
* used once. Holds the PKCE code verifier, so it never goes to localStorage.
|
||||
*/
|
||||
export const readStoredOidcIssuer = (state: string): string | undefined => {
|
||||
export type PendingOidcLogin = {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
deviceId: string;
|
||||
codeVerifier: string;
|
||||
homeserverUrl: string;
|
||||
redirectUri: string;
|
||||
};
|
||||
const PENDING_PREFIX = 'lotus_oidc_pending_';
|
||||
|
||||
const isPending = (v: unknown): v is PendingOidcLogin => {
|
||||
if (!v || typeof v !== 'object') return false;
|
||||
const p = v as Record<string, unknown>;
|
||||
return ['issuer', 'clientId', 'deviceId', 'codeVerifier', 'homeserverUrl', 'redirectUri'].every(
|
||||
(k) => typeof p[k] === 'string' && (p[k] as string).length > 0,
|
||||
);
|
||||
};
|
||||
|
||||
export const savePendingOidcLogin = (state: string, pending: PendingOidcLogin): void => {
|
||||
sessionStorage.setItem(`${PENDING_PREFIX}${state}`, JSON.stringify(pending));
|
||||
};
|
||||
|
||||
/** Read the pending sign-in for `state` without consuming it. */
|
||||
export const readPendingOidcLogin = (state: string): PendingOidcLogin | undefined => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(`mx_oidc_${state}`);
|
||||
const raw = sessionStorage.getItem(`${PENDING_PREFIX}${state}`);
|
||||
if (!raw) return undefined;
|
||||
const { authority } = JSON.parse(raw) as { authority?: unknown };
|
||||
return typeof authority === 'string' ? authority : undefined;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isPending(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Read and remove: an authorization code can only be redeemed once anyway. */
|
||||
export const takePendingOidcLogin = (state: string): PendingOidcLogin | undefined => {
|
||||
const pending = readPendingOidcLogin(state);
|
||||
try {
|
||||
sessionStorage.removeItem(`${PENDING_PREFIX}${state}`);
|
||||
} catch {
|
||||
// storage unavailable — nothing to clean up
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
|
||||
/**
|
||||
* #102 — on the callback's error branch, the pending sign-in's issuer tells
|
||||
* us whose cached client id to evict when the provider rejected it.
|
||||
*/
|
||||
export const readStoredOidcIssuer = (state: string): string | undefined =>
|
||||
readPendingOidcLogin(state)?.issuer;
|
||||
|
||||
@@ -12,6 +12,8 @@ import { SupportedUIAFlowsLoader } from '../../../components/SupportedUIAFlowsLo
|
||||
import { getLoginPath } from '../../pathUtils';
|
||||
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
||||
import { RegisterPathSearchParams } from '../../paths';
|
||||
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||
import { OidcLogin } from '../login/OidcLogin';
|
||||
|
||||
const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams =>
|
||||
useMemo(
|
||||
@@ -25,7 +27,8 @@ const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSea
|
||||
|
||||
export function Register() {
|
||||
const server = useAuthServer();
|
||||
const { loginFlows, registerFlows } = useAuthFlows();
|
||||
const { loginFlows, registerFlows, authMetadata } = useAuthFlows();
|
||||
const discovery = useAutoDiscoveryInfo();
|
||||
const [searchParams] = useSearchParams();
|
||||
const registerSearchParams = useRegisterSearchParams(searchParams);
|
||||
const { sso } = useParsedLoginFlows(loginFlows.flows);
|
||||
@@ -33,6 +36,27 @@ export function Register() {
|
||||
// redirect to /login because only that path handle m.login.token
|
||||
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
|
||||
|
||||
// Native OAuth servers (e.g. matrix.org via MAS) create accounts at the auth
|
||||
// server; /register here would only offer the legacy forms. Mirrors Login.
|
||||
if (authMetadata) {
|
||||
return (
|
||||
<Box direction="Column" gap="500">
|
||||
<Text as="h2" size="H2" priority="400">
|
||||
Register
|
||||
</Text>
|
||||
<OidcLogin
|
||||
register
|
||||
homeserverBaseUrl={discovery['m.homeserver'].base_url}
|
||||
serverName={server}
|
||||
/>
|
||||
<span data-spacing-node />
|
||||
<Text align="Center">
|
||||
Already have an account? <Link to={getLoginPath(server)}>Login</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="500">
|
||||
<Text as="h2" size="H2" priority="400">
|
||||
|
||||
@@ -1015,13 +1015,13 @@ function TauriUpdateFeature() {
|
||||
dismissToast(UPDATE_PROGRESS_TOAST);
|
||||
if (!failure) return;
|
||||
// Short on purpose; the full explanation and a Download installer button
|
||||
// are in Settings → General → App Updates.
|
||||
// are in Settings → Desktop → 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 → Desktop → App updates.`,
|
||||
roomName: 'System',
|
||||
roomId: '',
|
||||
sticky: true,
|
||||
@@ -1062,7 +1062,7 @@ function TauriUpdateFeature() {
|
||||
roomName: 'System',
|
||||
roomId: '',
|
||||
onClick: () => {
|
||||
if (viaPackage) requestSettings('general');
|
||||
if (viaPackage) requestSettings('desktop');
|
||||
else installFromToast();
|
||||
},
|
||||
sticky: true,
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useSyncState } from '../../hooks/useSyncState';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { SyncStatus } from './SyncStatus';
|
||||
import { ClockSkewBanner } from './ClockSkewBanner';
|
||||
import { KeyUploadBanner } from './KeyUploadBanner';
|
||||
import {
|
||||
ServerStatusBanner,
|
||||
ServerStatusFeature,
|
||||
@@ -54,14 +55,13 @@ import { SecurityBanner } from '../../features/security-nudge/SecurityBanner';
|
||||
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
|
||||
import { pushSessionToSW } from '../../../sw-session';
|
||||
import { revokeOidcTokens } from '../../../client/oidcLogout';
|
||||
import { useSessionSync } from '../../hooks/useSessionSync';
|
||||
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
|
||||
import { AutoDiscovery } from './AutoDiscovery';
|
||||
|
||||
// Capture-only E2EE diagnostics ring buffer (KE-1→4 signatures) — installed at
|
||||
// module load so it sees crypto warnings from the very first sync. Idempotent;
|
||||
// report download lives in Settings → Developer Tools → Crypto Diagnostics.
|
||||
// report download lives in Settings → Advanced → Crypto diagnostics.
|
||||
installCryptoDiagLog();
|
||||
|
||||
function ClientRootLoading() {
|
||||
@@ -158,11 +158,11 @@ const useLogoutListener = (mx?: MatrixClient) => {
|
||||
pushSessionToSW();
|
||||
mx?.stopClient();
|
||||
// Best-effort issuer revocation for OIDC sessions (the token is already
|
||||
// server-revoked here, but revoke the refresh token too). Before we drop
|
||||
// the stored session below.
|
||||
const loggedOutSession = getFallbackSession();
|
||||
if (loggedOutSession?.oidc) {
|
||||
await revokeOidcTokens(loggedOutSession).catch(() => undefined);
|
||||
// server-revoked here, but revoke the refresh token too). For an OAuth
|
||||
// session the SDK's logout() only revokes at the auth server (no /logout
|
||||
// call). Before we drop the stored session below.
|
||||
if (getFallbackSession()?.oidc) {
|
||||
await mx?.logout().catch(() => undefined);
|
||||
}
|
||||
await mx?.clearStores();
|
||||
// The opt-in local search index holds DECRYPTED message plaintext. Wipe it
|
||||
@@ -260,6 +260,12 @@ export function ClientRoot({ children }: ClientRootProps) {
|
||||
{mx && !syncError && <ServerStatusBanner />}
|
||||
{mx && !syncError && <SyncStatus mx={mx} />}
|
||||
{mx && !syncError && <ClockSkewBanner />}
|
||||
{mx && !syncError && (
|
||||
// The fix opens LogoutDialog, which reads the client from context.
|
||||
<MatrixClientProvider value={mx}>
|
||||
<KeyUploadBanner />
|
||||
</MatrixClientProvider>
|
||||
)}
|
||||
{loading && <ClientRootOptions mx={mx} />}
|
||||
{(loadState.status === AsyncStatus.Error ||
|
||||
startState.status === AsyncStatus.Error ||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useState, useSyncExternalStore } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Box, Button, config, Line, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { getKeyUploadWatch, subscribeKeyUploadWatch } from '../../utils/keyUploadWatch';
|
||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
||||
/**
|
||||
* [Gitea #201 KE-1] "Encryption on this device is out of sync." Shown while
|
||||
* the server keeps rejecting this device's key uploads (keyUploadWatch). The
|
||||
* fix is a fresh login, so the action opens the normal logout dialog, which
|
||||
* already warns if the device isn't verified / backed up. Hideable for this
|
||||
* session only: the problem doesn't go away by itself.
|
||||
*/
|
||||
export function KeyUploadBanner() {
|
||||
const { stuck, lastError } = useSyncExternalStore(subscribeKeyUploadWatch, getKeyUploadWatch);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [details, setDetails] = useState(false);
|
||||
const [logout, setLogout] = useState(false);
|
||||
|
||||
if (!stuck || hidden) return null;
|
||||
|
||||
return (
|
||||
<Box direction="Column" shrink="No">
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'Critical' })}
|
||||
style={{ padding: `${config.space.S100} ${config.space.S300}` }}
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
role="alert"
|
||||
>
|
||||
<Box alignItems="Center" gap="300" wrap="Wrap" justifyContent="Center">
|
||||
<Text size="L400" align="Center">
|
||||
Encryption on this device is out of sync with the server. New encrypted messages and
|
||||
calls may fail until you sign out and back in.
|
||||
</Text>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={() => setLogout(true)}
|
||||
>
|
||||
<Text size="B300">Fix: sign out…</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={() => setDetails((v) => !v)}
|
||||
aria-expanded={details}
|
||||
>
|
||||
<Text size="B300">Details</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
radii="300"
|
||||
onClick={() => setHidden(true)}
|
||||
>
|
||||
<Text size="B300">Hide</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{details && (
|
||||
<Box direction="Column" gap="100" style={{ maxWidth: 640 }}>
|
||||
<Text size="T200" align="Center">
|
||||
The server already has encryption keys this device thinks it hasn't sent, so it
|
||||
rejects every new upload and other people can't start fresh encrypted sessions
|
||||
with you. This usually happens after the browser cleared this site's storage
|
||||
while you stayed signed in. Before signing out, make sure this device is verified or
|
||||
key backup is on (Settings → Privacy & security), so your message history comes back
|
||||
when you sign in again.
|
||||
</Text>
|
||||
{lastError && (
|
||||
<Text size="T200" align="Center" priority="300" style={{ wordBreak: 'break-word' }}>
|
||||
Server said: {lastError}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Line variant="Critical" size="300" />
|
||||
{logout && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
onDeactivate: () => setLogout(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<LogoutDialog handleClose={() => setLogout(false)} />
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -180,7 +180,10 @@ export function SettingsTab() {
|
||||
useEffect(() => {
|
||||
if (!settingsRequest) return;
|
||||
const page = {
|
||||
general: SettingsPages.GeneralPage,
|
||||
// [Gitea #459] General is gone; its old deep links land on Appearance.
|
||||
general: SettingsPages.AppearancePage,
|
||||
appearance: SettingsPages.AppearancePage,
|
||||
desktop: SettingsPages.DesktopPage,
|
||||
account: SettingsPages.AccountPage,
|
||||
notifications: SettingsPages.NotificationPage,
|
||||
devices: SettingsPages.DevicesPage,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -26,13 +26,14 @@ import {
|
||||
ElementMediaStateDetail,
|
||||
ElementWidgetActions,
|
||||
} from './types';
|
||||
import { CallControl } from './CallControl';
|
||||
import { CallControl, CallControlEvent } from './CallControl';
|
||||
import { CallControlState } from './CallControlState';
|
||||
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
||||
import { canDelegateCapability } from './utils';
|
||||
import { getCallPageUrl } from './callPageUrl';
|
||||
import { restrictWidgetMessages } from '../widgetTransport';
|
||||
import { isRingableRoom } from '../../utils/callRing';
|
||||
import { parseCallSummaryDetails, type LotusCallSummaryDetails } from '../../utils/callSummary';
|
||||
|
||||
// Maximum time to wait for the embedded Element Call iframe to progress from
|
||||
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
||||
@@ -59,6 +60,8 @@ export interface LotusCallSummary {
|
||||
reconnects: number;
|
||||
poorMs: number;
|
||||
verdict: 'good' | 'fair' | 'poor' | 'unknown';
|
||||
/** [Gitea #253] Breakdown for the Details card (fork lotus.29+). */
|
||||
details?: LotusCallSummaryDetails;
|
||||
}
|
||||
|
||||
export class CallEmbed {
|
||||
@@ -78,6 +81,25 @@ export class CallEmbed {
|
||||
/** [Gitea #143] The fork's end-of-call summary, once it arrives. */
|
||||
public lastSummary: LotusCallSummary | undefined;
|
||||
|
||||
// [Gitea #253] Time spent deafened (sound off), for the post-call Details.
|
||||
private deafenedSince: number | undefined;
|
||||
|
||||
private deafenedTotal = 0;
|
||||
|
||||
public deafenedMs(now = Date.now()): number {
|
||||
return this.deafenedTotal + (this.deafenedSince === undefined ? 0 : now - this.deafenedSince);
|
||||
}
|
||||
|
||||
private trackDeafened = (): void => {
|
||||
if (!this.joined) return;
|
||||
const deaf = !this.control.sound;
|
||||
if (deaf && this.deafenedSince === undefined) this.deafenedSince = Date.now();
|
||||
if (!deaf && this.deafenedSince !== undefined) {
|
||||
this.deafenedTotal += Date.now() - this.deafenedSince;
|
||||
this.deafenedSince = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// C-M4: set once dispose() has run so the hangup fallback timer can tell
|
||||
// whether the embed was already torn down by the normal Close/Hangup echo.
|
||||
public disposed = false;
|
||||
@@ -235,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') {
|
||||
@@ -339,6 +364,8 @@ export class CallEmbed {
|
||||
const controlState = initialControlState ?? new CallControlState(true, false, true);
|
||||
this.control = new CallControl(controlState, call, iframe);
|
||||
this.initialState = controlState;
|
||||
this.control.on(CallControlEvent.StateUpdate, this.trackDeafened);
|
||||
this.disposables.push(() => this.control.off(CallControlEvent.StateUpdate, this.trackDeafened));
|
||||
// If the iframe document itself fails to load, fail fast.
|
||||
iframe.onerror = () => {
|
||||
this.settleLoad('iframe');
|
||||
@@ -434,6 +461,7 @@ export class CallEmbed {
|
||||
reconnects: data.reconnects ?? 0,
|
||||
poorMs: data.poorMs ?? 0,
|
||||
verdict: data.verdict ?? 'unknown',
|
||||
details: parseCallSummaryDetails((data as { details?: unknown }).details),
|
||||
};
|
||||
}
|
||||
}),
|
||||
@@ -623,6 +651,7 @@ export class CallEmbed {
|
||||
}
|
||||
this.joined = true;
|
||||
this.joinedAt = Date.now();
|
||||
this.trackDeafened();
|
||||
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
|
||||
this.control.forceState(this.initialState);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const store = new Map<string, string>();
|
||||
(globalThis as { sessionStorage?: unknown }).sessionStorage = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
store.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
store.delete(k);
|
||||
},
|
||||
};
|
||||
const m = await import('./expectedDevices');
|
||||
|
||||
test('[#121] parses the fork messages defensively', () => {
|
||||
assert.deepEqual(
|
||||
m.parseDeviceMissing({
|
||||
missing: [
|
||||
{ kind: 'audioinput', expected: 'Arctis 7', fallback: 'Laptop Mic' },
|
||||
{ kind: 'audiooutput', expected: 'Arctis 7', fallback: null },
|
||||
{ kind: 'videoinput', expected: 'Cam' },
|
||||
{ kind: 'audioinput' },
|
||||
],
|
||||
}),
|
||||
[
|
||||
{ kind: 'audioinput', expected: 'Arctis 7', fallback: 'Laptop Mic' },
|
||||
{ kind: 'audiooutput', expected: 'Arctis 7', fallback: null },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(m.parseDeviceMissing(undefined), []);
|
||||
assert.deepEqual(m.parseDeviceMissing({ missing: 'x' }), []);
|
||||
assert.equal(m.parseDeviceLost({ kind: 'nope' }), undefined);
|
||||
});
|
||||
|
||||
test('[#121] wording', () => {
|
||||
assert.deepEqual(
|
||||
m.describeMissing({ kind: 'audioinput', expected: 'Arctis 7', fallback: 'Laptop Mic' }),
|
||||
{
|
||||
title: "Your usual microphone isn't connected",
|
||||
expected: 'Arctis 7',
|
||||
fallback: 'Laptop Mic',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
m.describeLost({ kind: 'audiooutput', expected: 'Arctis 7', fallback: null }),
|
||||
'Arctis 7 disconnected — now using the default output.',
|
||||
);
|
||||
});
|
||||
|
||||
test('[#121] "Join anyway" is remembered for the session, per device set', () => {
|
||||
store.clear();
|
||||
const a = [{ kind: 'audioinput' as const, expected: 'Arctis 7', fallback: 'Laptop Mic' }];
|
||||
const b = [{ kind: 'audiooutput' as const, expected: 'Arctis 7', fallback: null }];
|
||||
assert.equal(m.isAcceptedThisSession(a), false);
|
||||
m.acceptForSession(a);
|
||||
assert.equal(m.isAcceptedThisSession(a), true);
|
||||
assert.equal(m.isAcceptedThisSession(b), false, 'a different device still asks');
|
||||
assert.equal(m.isAcceptedThisSession([...a, ...b]), false, 'more missing still asks');
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* [Gitea #121] The fork's expected-device check. Before joining, EC reports
|
||||
* the user's usual mic / output (the one they last picked) as missing; we ask
|
||||
* "Join anyway" or "Cancel". In a call, losing it is a toast. Pure helpers.
|
||||
*/
|
||||
|
||||
export type DeviceKind = 'audioinput' | 'audiooutput';
|
||||
export type MissingDevice = { kind: DeviceKind; expected: string; fallback: string | null };
|
||||
|
||||
const isKind = (k: unknown): k is DeviceKind => k === 'audioinput' || k === 'audiooutput';
|
||||
|
||||
const parseOne = (v: unknown): MissingDevice | undefined => {
|
||||
if (!v || typeof v !== 'object') return undefined;
|
||||
const { kind, expected, fallback } = v as Record<string, unknown>;
|
||||
if (!isKind(kind) || typeof expected !== 'string' || !expected) return undefined;
|
||||
return { kind, expected, fallback: typeof fallback === 'string' && fallback ? fallback : null };
|
||||
};
|
||||
|
||||
/** `io.lotus.device_missing` → the missing devices (empty if malformed). */
|
||||
export const parseDeviceMissing = (data: unknown): MissingDevice[] => {
|
||||
const list = (data as { missing?: unknown } | undefined)?.missing;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.map(parseOne).filter((d): d is MissingDevice => !!d);
|
||||
};
|
||||
|
||||
/** `io.lotus.device_lost` → the device that went away. */
|
||||
export const parseDeviceLost = (data: unknown): MissingDevice | undefined => parseOne(data);
|
||||
|
||||
const what = (kind: DeviceKind): string => (kind === 'audioinput' ? 'microphone' : 'audio output');
|
||||
|
||||
/** "Your usual microphone **Arctis 7** isn't connected." lines + the fallback. */
|
||||
export const describeMissing = (
|
||||
d: MissingDevice,
|
||||
): { title: string; expected: string; fallback: string } => ({
|
||||
title: `Your usual ${what(d.kind)} isn't connected`,
|
||||
expected: d.expected,
|
||||
fallback:
|
||||
d.fallback ?? (d.kind === 'audioinput' ? 'the default microphone' : 'the default output'),
|
||||
});
|
||||
|
||||
export const describeLost = (d: MissingDevice): string =>
|
||||
`${d.expected} disconnected — now using ${
|
||||
d.fallback ?? (d.kind === 'audioinput' ? 'the default microphone' : 'the default output')
|
||||
}.`;
|
||||
|
||||
// "Join anyway" holds for the rest of the session for the same device(s).
|
||||
const ACCEPTED_KEY = 'lotus-device-check-accepted';
|
||||
const keyOf = (missing: MissingDevice[]): string =>
|
||||
missing
|
||||
.map((d) => `${d.kind}:${d.expected}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
|
||||
const readAccepted = (): string[] => {
|
||||
try {
|
||||
const v: unknown = JSON.parse(sessionStorage.getItem(ACCEPTED_KEY) ?? '[]');
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const isAcceptedThisSession = (missing: MissingDevice[]): boolean =>
|
||||
readAccepted().includes(keyOf(missing));
|
||||
|
||||
export const acceptForSession = (missing: MissingDevice[]): void => {
|
||||
try {
|
||||
const list = readAccepted();
|
||||
const k = keyOf(missing);
|
||||
if (!list.includes(k)) sessionStorage.setItem(ACCEPTED_KEY, JSON.stringify([...list, k]));
|
||||
} catch {
|
||||
// storage unavailable: ask again next time
|
||||
}
|
||||
};
|
||||
@@ -18,3 +18,6 @@ export const callEmbedAtom = atom<CallEmbed | undefined, [CallEmbed | undefined]
|
||||
);
|
||||
|
||||
export const callChatAtom = atom<boolean>(false);
|
||||
|
||||
/** [Gitea #126] The call is full screen (phone turned sideways). */
|
||||
export const immersiveCallAtom = atom<boolean>(false);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { atom } from 'jotai';
|
||||
import type { CallSummarySection } from '../utils/callSummary';
|
||||
|
||||
/** [Gitea #253] The post-call Details card currently open (undefined = closed). */
|
||||
export type CallSummaryView = { roomName: string; sections: CallSummarySection[] };
|
||||
export const callSummaryViewAtom = atom<CallSummaryView | undefined>(undefined);
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getFallbackSession, onSessionPersisted, Session } from './sessions';
|
||||
import {
|
||||
getFallbackSession,
|
||||
onSessionPersisted,
|
||||
Session,
|
||||
stripTokensFromStorage,
|
||||
} from './sessions';
|
||||
|
||||
/**
|
||||
* [Gitea #105] Desktop, step 1: mirror the login tokens into the OS keychain.
|
||||
* [Gitea #105] Desktop: keep the login tokens in the OS keychain.
|
||||
*
|
||||
* The session is still read from localStorage exactly as before; this only
|
||||
* keeps a copy in the keychain (Windows Credential Manager) and checks it by
|
||||
* reading it back, so real installs prove the keychain works before step 2
|
||||
* switches reads over to it. Nothing here can log anyone out: every failure
|
||||
* just records a status for Settings.
|
||||
* Step 1 mirrored them here and verified by reading back. Step 2 (sessions.ts,
|
||||
* keychainSession.ts) reads them from here at boot; once a write is verified,
|
||||
* the tokens are dropped from localStorage. Nothing here can log anyone out:
|
||||
* a failure keeps the tokens in localStorage and records a status for
|
||||
* Settings.
|
||||
*
|
||||
* Writes are serialized (a token rotation right after login must not race
|
||||
* the login's write) and time out, so a hung credential store can't pile up.
|
||||
@@ -122,7 +127,13 @@ export const startKeychainMirror = (invoke: KeychainInvoke | undefined): void =>
|
||||
started = true;
|
||||
let chain: Promise<unknown> = Promise.resolve();
|
||||
const enqueue = (session: Session | null) => {
|
||||
chain = chain.then(async () => setStatus(await syncKeychain(invoke, session)));
|
||||
chain = chain.then(async () => {
|
||||
const result = await syncKeychain(invoke, session);
|
||||
setStatus(result);
|
||||
// [Gitea #105] step 2: the keychain verifiably holds these tokens, so
|
||||
// localStorage can drop them (only if they're still the current ones).
|
||||
if (result.state === 'ok' && session) stripTokensFromStorage(tokensOf(session));
|
||||
});
|
||||
};
|
||||
enqueue(getFallbackSession() ?? null);
|
||||
onSessionPersisted((session) => enqueue(session));
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// localStorage stand-in (these modules read it at call time).
|
||||
const store = new Map<string, string>();
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
store.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
store.delete(k);
|
||||
},
|
||||
};
|
||||
|
||||
const sessions = await import('./sessions');
|
||||
const { bootKeychainSession } = await import('./keychainSession');
|
||||
const { syncKeychain, tokensOf } = await import('./keychainMirror');
|
||||
|
||||
type Invoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
const keychain = (opts: { getThrows?: boolean; setThrows?: boolean } = {}) => {
|
||||
let stored: unknown = null;
|
||||
const invoke: Invoke = async (cmd, args) => {
|
||||
if (cmd === 'secure_session_supported') return true;
|
||||
if (cmd === 'secure_session_get') {
|
||||
if (opts.getThrows) throw new Error('Element not found.');
|
||||
return stored;
|
||||
}
|
||||
if (cmd === 'secure_session_set') {
|
||||
if (opts.setThrows) throw new Error('Access is denied.');
|
||||
stored = args?.tokens;
|
||||
return null;
|
||||
}
|
||||
if (cmd === 'secure_session_clear') {
|
||||
stored = null;
|
||||
return null;
|
||||
}
|
||||
throw new Error(cmd);
|
||||
};
|
||||
return {
|
||||
invoke,
|
||||
get: () => stored,
|
||||
set: (v: unknown) => {
|
||||
stored = v;
|
||||
},
|
||||
};
|
||||
};
|
||||
const blob = () => JSON.parse(store.get('cinny_session_v1') ?? 'null');
|
||||
const tokensOnDisk = () =>
|
||||
[...store.entries()].some(([, v]) => v.includes('syt_') || v.includes('mar_'));
|
||||
const login = (access: string, refresh?: string) =>
|
||||
sessions.setFallbackSession(access, 'DEV1', '@alice:example.org', 'https://hs.example', {
|
||||
refreshToken: refresh,
|
||||
expiresInMs: 300_000,
|
||||
});
|
||||
/** A fresh page load: drop the in-memory state, keep storage and keychain. */
|
||||
const reload = () => sessions.leaveKeychainMode();
|
||||
/** What the mirror does after a write: verify in the keychain, then strip. */
|
||||
const mirror = async (invoke: Invoke) => {
|
||||
const s = sessions.getFallbackSession() ?? null;
|
||||
const r = await syncKeychain(invoke, s);
|
||||
if (r.state === 'ok' && s) sessions.stripTokensFromStorage(tokensOf(s));
|
||||
return r;
|
||||
};
|
||||
|
||||
test('[#105] web / no keychain: localStorage stays authoritative', async () => {
|
||||
store.clear();
|
||||
reload();
|
||||
login('syt_web', 'mar_web');
|
||||
assert.deepEqual(await bootKeychainSession(undefined), { mode: 'off' });
|
||||
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_web');
|
||||
assert.equal(blob().accessToken, 'syt_web');
|
||||
});
|
||||
|
||||
test('[#105] migration: existing install moves its tokens to the keychain, then off disk', async () => {
|
||||
store.clear();
|
||||
reload();
|
||||
login('syt_old', 'mar_old'); // a pre-step-2 install
|
||||
const kc = keychain();
|
||||
const boot = await bootKeychainSession(kc.invoke);
|
||||
assert.equal(boot.mode, 'keychain');
|
||||
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_old');
|
||||
assert.equal(tokensOnDisk(), true, 'not stripped before the keychain is verified');
|
||||
assert.equal((await mirror(kc.invoke)).state, 'ok');
|
||||
assert.equal(tokensOnDisk(), false, 'tokens gone from localStorage');
|
||||
assert.equal(blob().tokensIn, 'keychain');
|
||||
assert.equal(store.get('cinny_access_token'), undefined);
|
||||
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_old', 'still signed in');
|
||||
|
||||
// Next launch: shell + keychain -> the same session.
|
||||
reload();
|
||||
const again = await bootKeychainSession(kc.invoke);
|
||||
assert.equal(again.mode, 'keychain');
|
||||
const s = sessions.getFallbackSession();
|
||||
assert.equal(s?.accessToken, 'syt_old');
|
||||
assert.equal(s?.refreshToken, 'mar_old');
|
||||
assert.equal(s?.baseUrl, 'https://hs.example');
|
||||
assert.ok((s?.expiresInMs ?? 0) > 0, 'expiry kept in the shell');
|
||||
});
|
||||
|
||||
test('[#105] token refresh: written in full first, stripped once the keychain has it', async () => {
|
||||
// continues from the previous state: keychain mode, shell on disk
|
||||
const kc = keychain();
|
||||
kc.set({
|
||||
userId: '@alice:example.org',
|
||||
deviceId: 'DEV1',
|
||||
accessToken: 'syt_old',
|
||||
refreshToken: 'mar_old',
|
||||
});
|
||||
login('syt_new', 'mar_new');
|
||||
assert.equal(blob().accessToken, 'syt_new', 'durable on disk until the keychain confirms');
|
||||
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_new');
|
||||
await mirror(kc.invoke);
|
||||
assert.equal(tokensOnDisk(), false);
|
||||
assert.equal((kc.get() as { accessToken: string }).accessToken, 'syt_new');
|
||||
});
|
||||
|
||||
test('[#105] a failing keychain write keeps the tokens in localStorage', async () => {
|
||||
store.clear();
|
||||
reload();
|
||||
login('syt_keep', 'mar_keep');
|
||||
const kc = keychain({ setThrows: true });
|
||||
await bootKeychainSession(kc.invoke);
|
||||
assert.equal((await mirror(kc.invoke)).state, 'error');
|
||||
assert.equal(blob().accessToken, 'syt_keep', 'nothing stripped');
|
||||
reload();
|
||||
await bootKeychainSession(kc.invoke);
|
||||
assert.equal(
|
||||
sessions.getFallbackSession()?.accessToken,
|
||||
'syt_keep',
|
||||
'still signed in after a restart',
|
||||
);
|
||||
});
|
||||
|
||||
test('[#105] shell but no keychain entry: "missing", never a silent logout', async () => {
|
||||
store.clear();
|
||||
reload();
|
||||
login('syt_x');
|
||||
const kc = keychain();
|
||||
await bootKeychainSession(kc.invoke);
|
||||
await mirror(kc.invoke);
|
||||
kc.set(null); // someone deleted the Credential Manager entry
|
||||
reload();
|
||||
const boot = await bootKeychainSession(kc.invoke);
|
||||
assert.deepEqual(boot, { mode: 'missing', userId: '@alice:example.org' });
|
||||
assert.equal(blob().tokensIn, 'keychain', 'shell kept for Retry');
|
||||
});
|
||||
|
||||
test('[#105] shell but the keychain read fails: "error", shell kept', async () => {
|
||||
// continues: shell on disk
|
||||
reload();
|
||||
const boot = await bootKeychainSession(keychain({ getThrows: true }).invoke);
|
||||
assert.equal(boot.mode, 'error');
|
||||
assert.equal(blob().tokensIn, 'keychain');
|
||||
});
|
||||
|
||||
test('[#105] a shell for another device never pairs with the keychain', async () => {
|
||||
store.clear();
|
||||
reload();
|
||||
login('syt_a');
|
||||
const kc = keychain();
|
||||
await bootKeychainSession(kc.invoke);
|
||||
await mirror(kc.invoke);
|
||||
kc.set({ userId: '@alice:example.org', deviceId: 'OTHER', accessToken: 'syt_b' });
|
||||
reload();
|
||||
assert.equal((await bootKeychainSession(kc.invoke)).mode, 'missing');
|
||||
});
|
||||
|
||||
test('[#105] logout clears memory, disk and (via the mirror) the keychain', async () => {
|
||||
store.clear();
|
||||
reload();
|
||||
login('syt_bye', 'mar_bye');
|
||||
const kc = keychain();
|
||||
await bootKeychainSession(kc.invoke);
|
||||
await mirror(kc.invoke);
|
||||
sessions.removeFallbackSession();
|
||||
assert.equal(sessions.getFallbackSession(), undefined);
|
||||
assert.equal(store.size, 0);
|
||||
await mirror(kc.invoke);
|
||||
assert.equal(kc.get(), null);
|
||||
reload();
|
||||
assert.deepEqual(await bootKeychainSession(kc.invoke), { mode: 'keychain', session: undefined });
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
enterKeychainMode,
|
||||
hasKeychainShell,
|
||||
KeychainBoot,
|
||||
KeychainSessionTokens,
|
||||
} from './sessions';
|
||||
import { KEYCHAIN_TIMEOUT_MS, KeychainInvoke } from './keychainMirror';
|
||||
|
||||
/**
|
||||
* [Gitea #105] Desktop, step 2: at boot, before the app renders, read the login
|
||||
* tokens from the OS keychain into memory (see sessions.ts). Returns:
|
||||
* - `off`: not the desktop app, or no keychain here (Linux, older builds) —
|
||||
* localStorage stays authoritative, exactly as before;
|
||||
* - a KeychainBoot from enterKeychainMode: `keychain` (go on), or `missing`
|
||||
* (localStorage says the tokens are in the keychain but they can't be read;
|
||||
* the caller shows Retry / Sign in again and must NOT start the mirror,
|
||||
* which would clear the keychain entry it couldn't read).
|
||||
*/
|
||||
export type KeychainBootResult = { mode: 'off' } | KeychainBoot | { mode: 'error'; error: string };
|
||||
|
||||
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T> =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('keychain timed out')), ms);
|
||||
p.then(
|
||||
(v) => {
|
||||
clearTimeout(timer);
|
||||
resolve(v);
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const asTokens = (v: unknown): KeychainSessionTokens | null => {
|
||||
if (!v || typeof v !== 'object') return null;
|
||||
const t = v as Partial<KeychainSessionTokens>;
|
||||
if (typeof t.userId !== 'string' || typeof t.deviceId !== 'string') return null;
|
||||
if (typeof t.accessToken !== 'string') return null;
|
||||
return {
|
||||
userId: t.userId,
|
||||
deviceId: t.deviceId,
|
||||
accessToken: t.accessToken,
|
||||
...(typeof t.refreshToken === 'string' ? { refreshToken: t.refreshToken } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export async function bootKeychainSession(
|
||||
invoke: KeychainInvoke | undefined,
|
||||
timeoutMs = KEYCHAIN_TIMEOUT_MS,
|
||||
): Promise<KeychainBootResult> {
|
||||
if (!invoke) return { mode: 'off' };
|
||||
let supported: unknown;
|
||||
try {
|
||||
supported = await withTimeout(invoke('secure_session_supported'), timeoutMs);
|
||||
} catch {
|
||||
supported = false;
|
||||
}
|
||||
if (supported !== true) {
|
||||
// A shell without a keychain to read from can only mean the store went
|
||||
// away (e.g. a downgrade of the desktop build): say so, don't log out.
|
||||
return hasKeychainShell()
|
||||
? { mode: 'error', error: 'keychain not available' }
|
||||
: { mode: 'off' };
|
||||
}
|
||||
let stored: unknown;
|
||||
try {
|
||||
stored = await withTimeout(invoke('secure_session_get'), timeoutMs);
|
||||
} catch (e) {
|
||||
if (hasKeychainShell())
|
||||
return { mode: 'error', error: (e instanceof Error ? e.message : String(e)).slice(0, 200) };
|
||||
return { mode: 'off' };
|
||||
}
|
||||
return enterKeychainMode(asTokens(stored));
|
||||
}
|
||||
+142
-3
@@ -5,9 +5,9 @@
|
||||
// setLocalStorageItem,
|
||||
// } from './utils/atomWithLocalStorage';
|
||||
|
||||
// OIDC/next-gen-auth (MSC3861) metadata needed to refresh + revoke tokens. Kept
|
||||
// as a generic claims object so this core module stays decoupled from
|
||||
// oidc-client-ts (the refresher casts idTokenClaims to IdTokenClaims).
|
||||
// OIDC/next-gen-auth (MSC3861) metadata needed to refresh + revoke tokens. Since
|
||||
// matrix-js-sdk 43 only `clientId` is used (the SDK finds the auth server via
|
||||
// /auth_metadata); `idTokenClaims` is kept for sessions saved by older builds.
|
||||
export type OidcSessionMeta = {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
@@ -51,6 +51,21 @@ const OIDC_KEYS = {
|
||||
// `_v1` suffix.
|
||||
const SESSION_BLOB_KEY = 'cinny_session_v1';
|
||||
|
||||
// [Gitea #105] Desktop, step 2: the login tokens live in the OS keychain.
|
||||
// localStorage then holds a SHELL blob (everything but the tokens, marked
|
||||
// `tokensIn: 'keychain'`), and the full session is kept in memory, filled at
|
||||
// boot (bootKeychainSession) before the app renders, so every reader of
|
||||
// getFallbackSession stays synchronous. A new session is written to
|
||||
// localStorage in full first; the tokens are stripped only once the keychain
|
||||
// write was read back (stripTokensFromStorage), so at every instant one durable
|
||||
// store holds the newest tokens (a token refresh invalidates the old ones).
|
||||
let keychainMode = false;
|
||||
let memorySession: PersistedSession | undefined;
|
||||
|
||||
type ShellSession = Omit<PersistedSession, 'accessToken' | 'refreshToken'> & {
|
||||
tokensIn: 'keychain';
|
||||
};
|
||||
|
||||
// The exact shape stored inside SESSION_BLOB_KEY. Note it stores an ABSOLUTE
|
||||
// `expiresAt` (ms since epoch) rather than a relative lifetime — identical to
|
||||
// the legacy `cinny_expires_at` semantics — so reads stay drift-free.
|
||||
@@ -266,6 +281,7 @@ export function setFallbackSession(
|
||||
extra?: FallbackSessionExtra,
|
||||
) {
|
||||
const persisted = buildPersisted(accessToken, deviceId, userId, baseUrl, extra);
|
||||
if (keychainMode) memorySession = persisted;
|
||||
// ONE setItem — the blob can never be observed half-written.
|
||||
localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(persisted));
|
||||
// Dual-write the legacy keys (removal of this half is a future release).
|
||||
@@ -276,6 +292,7 @@ export function setFallbackSession(
|
||||
// Clear BOTH the atomic blob and every legacy key so no reader (blob-preferring
|
||||
// or legacy-fallback) can resurrect a logged-out session.
|
||||
export const removeFallbackSession = () => {
|
||||
memorySession = undefined;
|
||||
localStorage.removeItem(SESSION_BLOB_KEY);
|
||||
Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key));
|
||||
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
|
||||
@@ -286,7 +303,23 @@ export const removeFallbackSession = () => {
|
||||
// corrupt/partial we transparently assemble from the legacy keys (migration);
|
||||
// the next setFallbackSession then persists the blob. When both exist the blob
|
||||
// wins by construction.
|
||||
// The stored session with its tokens, choosing blob vs legacy as below.
|
||||
const readStoredPersisted = (): PersistedSession | undefined => {
|
||||
const blob = readSessionBlob();
|
||||
const legacy = readLegacyKeys();
|
||||
if (
|
||||
blob &&
|
||||
legacy &&
|
||||
typeof legacy.expiresAt === 'number' &&
|
||||
(typeof blob.expiresAt !== 'number' || legacy.expiresAt > blob.expiresAt)
|
||||
) {
|
||||
return legacy;
|
||||
}
|
||||
return blob ?? legacy;
|
||||
};
|
||||
|
||||
export const getFallbackSession = (): Session | undefined => {
|
||||
if (keychainMode) return memorySession ? sessionFromPersisted(memorySession) : undefined;
|
||||
const blob = readSessionBlob();
|
||||
const legacy = readLegacyKeys();
|
||||
// Prefer the atomic blob, EXCEPT when the legacy keys carry a later expiry: a
|
||||
@@ -305,6 +338,112 @@ export const getFallbackSession = (): Session | undefined => {
|
||||
if (!persisted) return undefined;
|
||||
return sessionFromPersisted(persisted);
|
||||
};
|
||||
// --- [Gitea #105] desktop keychain session -----------------------------------
|
||||
|
||||
export type KeychainSessionTokens = {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
/** The shell blob written while the tokens are in the keychain, if any. */
|
||||
const readShell = (): Omit<ShellSession, 'tokensIn'> | undefined => {
|
||||
const raw = localStorage.getItem(SESSION_BLOB_KEY);
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const p = JSON.parse(raw) as Partial<ShellSession>;
|
||||
if (
|
||||
p?.tokensIn !== 'keychain' ||
|
||||
typeof p.deviceId !== 'string' ||
|
||||
typeof p.userId !== 'string' ||
|
||||
typeof p.baseUrl !== 'string'
|
||||
)
|
||||
return undefined;
|
||||
const { tokensIn: _drop, ...rest } = p as ShellSession;
|
||||
return rest;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** A session in localStorage that still carries its tokens (blob or legacy). */
|
||||
export const readStoredSessionWithTokens = (): Session | undefined => {
|
||||
const persisted = readStoredPersisted();
|
||||
return persisted ? sessionFromPersisted(persisted) : undefined;
|
||||
};
|
||||
|
||||
/** Whether localStorage holds a token-less shell (tokens are in the keychain). */
|
||||
export const hasKeychainShell = (): boolean => readShell() !== undefined;
|
||||
|
||||
export type KeychainBoot =
|
||||
/** Use the keychain: memory holds `session` (or none = signed out). */
|
||||
| { mode: 'keychain'; session: Session | undefined }
|
||||
/** A shell says the tokens are in the keychain, but they can't be read. */
|
||||
| { mode: 'missing'; userId: string };
|
||||
|
||||
/**
|
||||
* Decide the boot session from the keychain's tokens and localStorage, and
|
||||
* switch this tab to keychain mode. `tokens` is what the keychain returned
|
||||
* (null = no entry). Pure apart from reading localStorage and setting the
|
||||
* in-memory state.
|
||||
*/
|
||||
export const enterKeychainMode = (tokens: KeychainSessionTokens | null): KeychainBoot => {
|
||||
const withTokens = readStoredPersisted();
|
||||
const shell = readShell();
|
||||
keychainMode = true;
|
||||
if (withTokens) {
|
||||
// Pre-step-2 install, or a write not yet confirmed: localStorage has the
|
||||
// full session and stays authoritative until the mirror verifies it.
|
||||
memorySession = withTokens;
|
||||
return { mode: 'keychain', session: sessionFromPersisted(withTokens) };
|
||||
}
|
||||
if (shell) {
|
||||
if (tokens && tokens.userId === shell.userId && tokens.deviceId === shell.deviceId) {
|
||||
memorySession = {
|
||||
...shell,
|
||||
accessToken: tokens.accessToken,
|
||||
...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}),
|
||||
};
|
||||
return { mode: 'keychain', session: sessionFromPersisted(memorySession) };
|
||||
}
|
||||
// Never treat this as a logout: the shell stays, the caller offers Retry.
|
||||
memorySession = undefined;
|
||||
return { mode: 'missing', userId: shell.userId };
|
||||
}
|
||||
memorySession = undefined;
|
||||
return { mode: 'keychain', session: undefined };
|
||||
};
|
||||
|
||||
/** Leave keychain mode (the keychain failed): localStorage is authoritative again. */
|
||||
export const leaveKeychainMode = (): void => {
|
||||
keychainMode = false;
|
||||
memorySession = undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* The keychain now verifiably holds `tokens`: if they're still the current
|
||||
* session's, drop the tokens from localStorage (shell blob, no legacy keys).
|
||||
*/
|
||||
export const stripTokensFromStorage = (tokens: KeychainSessionTokens): boolean => {
|
||||
const m = memorySession;
|
||||
if (
|
||||
!keychainMode ||
|
||||
!m ||
|
||||
m.userId !== tokens.userId ||
|
||||
m.deviceId !== tokens.deviceId ||
|
||||
m.accessToken !== tokens.accessToken ||
|
||||
(m.refreshToken ?? undefined) !== (tokens.refreshToken ?? undefined)
|
||||
)
|
||||
return false;
|
||||
const { accessToken: _a, refreshToken: _r, ...rest } = m;
|
||||
const shell: ShellSession = { ...rest, tokensIn: 'keychain' };
|
||||
localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(shell));
|
||||
Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key));
|
||||
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* End of migration code for old session
|
||||
*/
|
||||
|
||||
@@ -310,6 +310,8 @@ export interface Settings {
|
||||
screenshareFramerate: ScreenshareFramerate;
|
||||
// [P5-15] In-call soundboard
|
||||
soundboardEnabled: boolean;
|
||||
// [Gitea #253] The call-ended toast (with its Details card).
|
||||
callSummaryToast: boolean;
|
||||
soundboardVolume: number; // 0–100
|
||||
|
||||
seasonalThemeOverride: SeasonalThemeOverride;
|
||||
@@ -429,6 +431,7 @@ const defaultSettings: Settings = {
|
||||
screenshareBitrate: 'auto',
|
||||
screenshareFramerate: 'auto',
|
||||
soundboardEnabled: true,
|
||||
callSummaryToast: true,
|
||||
soundboardVolume: 80,
|
||||
|
||||
seasonalThemeOverride: 'auto',
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export type SettingsRequestPage = 'general' | 'account' | 'notifications' | 'devices';
|
||||
export type SettingsRequestPage =
|
||||
| 'general'
|
||||
| 'account'
|
||||
| 'notifications'
|
||||
| 'devices'
|
||||
| 'desktop'
|
||||
| 'appearance';
|
||||
|
||||
/**
|
||||
* Ask the sidebar to open User Settings at a page from anywhere in the app
|
||||
|
||||
@@ -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[]>([]);
|
||||
@@ -13,6 +13,8 @@ export type ToastNotif = {
|
||||
onClick?: () => void; // custom click handler; skips hash navigation when set
|
||||
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
||||
onDismiss?: () => void; // called when the user closes the toast with its X (not on click/eviction)
|
||||
actionLabel?: string; // [Gitea #253] a small button on the toast, e.g. "Details"
|
||||
onAction?: () => void;
|
||||
createdAt?: number; // set by toastQueueAtom when enqueued; used for #80 eviction ordering
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { describeCallDuration, describeCallEnd } from './callSummary';
|
||||
import {
|
||||
describeCallDuration,
|
||||
describeCallEnd,
|
||||
describeCallDetails,
|
||||
parseCallSummaryDetails,
|
||||
} from './callSummary';
|
||||
|
||||
describe('describeCallDuration', () => {
|
||||
it('picks the unit', () => {
|
||||
@@ -65,3 +70,97 @@ describe('describeCallEnd', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('[#253] parseCallSummaryDetails keeps well-formed fields and drops the rest', () => {
|
||||
const d = parseCallSummaryDetails({
|
||||
participants: ['@bob:x', 7, '@carol:x'],
|
||||
peakParticipants: 3,
|
||||
quality: {
|
||||
youPoorMs: 1000,
|
||||
othersPoorMs: { '@bob:x': 500, evil: 9, '@c:x': -1 },
|
||||
everyonePoorMs: 0,
|
||||
},
|
||||
network: { rttMedianMs: 38, rttP95Ms: 'fast', inboundLossPct: 0.5 },
|
||||
activity: { speakingMs: 60_000, mutedMs: 'x', screenshareMs: 0 },
|
||||
});
|
||||
assert.deepEqual(d, {
|
||||
participants: ['@bob:x', '@carol:x'],
|
||||
peakParticipants: 3,
|
||||
quality: { youPoorMs: 1000, othersPoorMs: { '@bob:x': 500 }, everyonePoorMs: 0 },
|
||||
network: { rttMedianMs: 38, inboundLossPct: 0.5 },
|
||||
activity: { speakingMs: 60_000, mutedMs: 0, screenshareMs: 0 },
|
||||
});
|
||||
assert.equal(parseCallSummaryDetails(undefined), undefined);
|
||||
assert.equal(parseCallSummaryDetails('details'), undefined);
|
||||
});
|
||||
|
||||
it('[#253] describeCallDetails: technical sections with names, blame and numbers', () => {
|
||||
const names: Record<string, string> = { '@bob:x': 'Bob', '@carol:x': 'Carol' };
|
||||
const sections = describeCallDetails(
|
||||
{
|
||||
durationMs: 2_460_000,
|
||||
reconnects: 2,
|
||||
poorMs: 120_000,
|
||||
verdict: 'fair',
|
||||
details: {
|
||||
participants: ['@bob:x', '@carol:x'],
|
||||
peakParticipants: 3,
|
||||
quality: {
|
||||
youPoorMs: 120_000,
|
||||
othersPoorMs: { '@carol:x': 30_000 },
|
||||
everyonePoorMs: 10_000,
|
||||
},
|
||||
network: {
|
||||
rttMedianMs: 38,
|
||||
rttP95Ms: 310,
|
||||
outboundLossPct: 4.1,
|
||||
inboundLossPct: 0.5,
|
||||
jitterMs: 6,
|
||||
},
|
||||
activity: { speakingMs: 600_000, mutedMs: 1_200_000, screenshareMs: 300_000 },
|
||||
},
|
||||
},
|
||||
2_460_000,
|
||||
90_000,
|
||||
(u) => names[u] ?? u,
|
||||
);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: 'Call', lines: ['Duration 41 min · peak 3 in the call', 'With Bob, Carol'] },
|
||||
{
|
||||
title: 'Connection',
|
||||
lines: [
|
||||
'Verdict: fair · 2 reconnects',
|
||||
'Your connection poor/lost: 2 min',
|
||||
'Carol poor/lost: 30 s',
|
||||
'Everyone poor at once: 10 s (likely server or SFU side)',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Network',
|
||||
lines: [
|
||||
'RTT median 38 ms · p95 310 ms',
|
||||
'Outbound packet loss 4.1%',
|
||||
'Inbound packet loss 0.5%',
|
||||
'Jitter 6 ms',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'You',
|
||||
lines: ['Talking 10 min · muted 20 min · deafened 2 min', 'Screensharing 5 min'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('[#253] describeCallDetails without fork details (older fork) still says something useful', () => {
|
||||
const sections = describeCallDetails(
|
||||
{ durationMs: 60_000, reconnects: 0, poorMs: 0, verdict: 'good' },
|
||||
60_000,
|
||||
0,
|
||||
(u) => u,
|
||||
);
|
||||
assert.deepEqual(
|
||||
sections.map((s) => s.title),
|
||||
['Call', 'Connection'],
|
||||
);
|
||||
assert.deepEqual(sections[0].lines, ['Duration 1 min', 'Participants unknown']);
|
||||
});
|
||||
|
||||
@@ -32,3 +32,123 @@ export function describeCallEnd(durationMs: number, summary?: LotusCallSummary):
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** [Gitea #253] The fork's `details` in io.lotus.call_summary (lotus.29+). */
|
||||
export type LotusCallSummaryDetails = {
|
||||
participants: string[];
|
||||
peakParticipants: number;
|
||||
quality: { youPoorMs: number; othersPoorMs: Record<string, number>; everyonePoorMs: number };
|
||||
network: {
|
||||
rttMedianMs?: number;
|
||||
rttP95Ms?: number;
|
||||
outboundLossPct?: number;
|
||||
inboundLossPct?: number;
|
||||
jitterMs?: number;
|
||||
};
|
||||
activity: { speakingMs: number; mutedMs: number; screenshareMs: number };
|
||||
};
|
||||
|
||||
const num = (v: unknown): number | undefined =>
|
||||
typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined;
|
||||
const obj = (v: unknown): Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
||||
|
||||
/** Defensive: the frame is another origin; take only well-formed fields. */
|
||||
export function parseCallSummaryDetails(raw: unknown): LotusCallSummaryDetails | undefined {
|
||||
if (typeof raw !== 'object' || raw === null) return undefined;
|
||||
const r = obj(raw);
|
||||
const quality = obj(r.quality);
|
||||
const network = obj(r.network);
|
||||
const activity = obj(r.activity);
|
||||
const othersPoorMs: Record<string, number> = {};
|
||||
Object.entries(obj(quality.othersPoorMs)).forEach(([user, ms]) => {
|
||||
const n = num(ms);
|
||||
if (n !== undefined && user.startsWith('@')) othersPoorMs[user] = n;
|
||||
});
|
||||
const net: LotusCallSummaryDetails['network'] = {};
|
||||
(['rttMedianMs', 'rttP95Ms', 'outboundLossPct', 'inboundLossPct', 'jitterMs'] as const).forEach(
|
||||
(k) => {
|
||||
const n = num(network[k]);
|
||||
if (n !== undefined) net[k] = n;
|
||||
},
|
||||
);
|
||||
return {
|
||||
participants: Array.isArray(r.participants)
|
||||
? r.participants.filter((p): p is string => typeof p === 'string').slice(0, 200)
|
||||
: [],
|
||||
peakParticipants: num(r.peakParticipants) ?? 1,
|
||||
quality: {
|
||||
youPoorMs: num(quality.youPoorMs) ?? 0,
|
||||
othersPoorMs,
|
||||
everyonePoorMs: num(quality.everyonePoorMs) ?? 0,
|
||||
},
|
||||
network: net,
|
||||
activity: {
|
||||
speakingMs: num(activity.speakingMs) ?? 0,
|
||||
mutedMs: num(activity.mutedMs) ?? 0,
|
||||
screenshareMs: num(activity.screenshareMs) ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type CallSummarySection = { title: string; lines: string[] };
|
||||
|
||||
/**
|
||||
* [Gitea #253] The Details card, in technical wording (the owner's choice):
|
||||
* who was there, connection quality and whose side it was on, network
|
||||
* numbers, and your own activity. `nameOf` turns a user id into a display name.
|
||||
*/
|
||||
export function describeCallDetails(
|
||||
summary: LotusCallSummary | undefined,
|
||||
durationMs: number,
|
||||
deafenedMs: number,
|
||||
nameOf: (userId: string) => string,
|
||||
): CallSummarySection[] {
|
||||
const sections: CallSummarySection[] = [];
|
||||
const d = summary?.details;
|
||||
const dur = describeCallDuration;
|
||||
const people = d?.participants ?? [];
|
||||
sections.push({
|
||||
title: 'Call',
|
||||
lines: [
|
||||
`Duration ${dur(durationMs)}${d ? ` · peak ${d.peakParticipants} in the call` : ''}`,
|
||||
people.length
|
||||
? `With ${people.map(nameOf).join(', ')}`
|
||||
: d
|
||||
? 'Nobody else joined'
|
||||
: 'Participants unknown',
|
||||
],
|
||||
});
|
||||
if (summary) {
|
||||
const quality: string[] = [
|
||||
`Verdict: ${summary.verdict} · ${summary.reconnects} reconnect${summary.reconnects === 1 ? '' : 's'}`,
|
||||
];
|
||||
if (d) {
|
||||
quality.push(`Your connection poor/lost: ${dur(d.quality.youPoorMs)}`);
|
||||
Object.entries(d.quality.othersPoorMs)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.forEach(([user, ms]) => quality.push(`${nameOf(user)} poor/lost: ${dur(ms)}`));
|
||||
if (d.quality.everyonePoorMs > 0)
|
||||
quality.push(
|
||||
`Everyone poor at once: ${dur(d.quality.everyonePoorMs)} (likely server or SFU side)`,
|
||||
);
|
||||
} else quality.push(`Your connection poor/lost: ${dur(summary.poorMs)}`);
|
||||
sections.push({ title: 'Connection', lines: quality });
|
||||
}
|
||||
if (d) {
|
||||
const n = d.network;
|
||||
const net: string[] = [];
|
||||
if (n.rttMedianMs !== undefined)
|
||||
net.push(`RTT median ${n.rttMedianMs} ms · p95 ${n.rttP95Ms ?? n.rttMedianMs} ms`);
|
||||
if (n.outboundLossPct !== undefined) net.push(`Outbound packet loss ${n.outboundLossPct}%`);
|
||||
if (n.inboundLossPct !== undefined) net.push(`Inbound packet loss ${n.inboundLossPct}%`);
|
||||
if (n.jitterMs !== undefined) net.push(`Jitter ${n.jitterMs} ms`);
|
||||
if (net.length) sections.push({ title: 'Network', lines: net });
|
||||
const act = [
|
||||
`Talking ${dur(d.activity.speakingMs)} · muted ${dur(d.activity.mutedMs)} · deafened ${dur(deafenedMs)}`,
|
||||
];
|
||||
if (d.activity.screenshareMs > 0) act.push(`Screensharing ${dur(d.activity.screenshareMs)}`);
|
||||
sections.push({ title: 'You', lines: act });
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildExport, mergeData, parseExport, planImport } from './dataExport';
|
||||
|
||||
const file = buildExport({
|
||||
userId: '@a:hs',
|
||||
accountData: {
|
||||
'io.lotus.bookmarks': { bookmarks: [{ id: 'b1', note: 'x' }] },
|
||||
'io.lotus.user_notes': { notes: { '@b:hs': 'met at LAN party' } },
|
||||
'm.direct': { secret: true },
|
||||
'io.lotus.settings': { theme: 'dark', fontSize: 16 },
|
||||
},
|
||||
decoration: 'halo',
|
||||
localSettings: { callNoiseSuppression: 'ml' },
|
||||
now: new Date('2026-10-25T00:00:00Z'),
|
||||
});
|
||||
|
||||
test('[#114] export keeps only our own types', () => {
|
||||
assert.equal(file.format, 'lotus-chat-data');
|
||||
assert.equal(file.version, 1);
|
||||
assert.deepEqual(Object.keys(file.accountData).sort(), [
|
||||
'io.lotus.bookmarks',
|
||||
'io.lotus.settings',
|
||||
'io.lotus.user_notes',
|
||||
]);
|
||||
assert.equal(file.profile.decoration, 'halo');
|
||||
});
|
||||
|
||||
test('[#114] parse: round trip, wrong files, newer versions, foreign types', () => {
|
||||
const r = parseExport(JSON.stringify(file));
|
||||
assert.ok(r.ok);
|
||||
assert.equal(parseExport('nope').ok, false);
|
||||
assert.equal(parseExport('{"format":"other"}').ok, false);
|
||||
const newer = parseExport(JSON.stringify({ ...file, version: 2 }));
|
||||
assert.ok(!newer.ok && /newer Lotus Chat/.test(newer.error));
|
||||
const sneaky = parseExport(
|
||||
JSON.stringify({ ...file, accountData: { 'm.push_rules': {}, 'io.lotus.bookmarks': {} } }),
|
||||
);
|
||||
assert.ok(sneaky.ok);
|
||||
assert.deepEqual(Object.keys(sneaky.file.accountData), ['io.lotus.bookmarks']);
|
||||
});
|
||||
|
||||
test('[#114] merge: lists by id, maps by key, conflicts kept unless preferred', () => {
|
||||
const mine = { bookmarks: [{ id: 'b1', note: 'mine' }, { id: 'b2' }] };
|
||||
const theirs = { bookmarks: [{ id: 'b1', note: 'theirs' }, { id: 'b3' }] };
|
||||
const keep = mergeData(mine, theirs);
|
||||
assert.equal(keep.added, 1);
|
||||
assert.equal(keep.conflicts, 1);
|
||||
assert.deepEqual(keep.value, {
|
||||
bookmarks: [{ id: 'b1', note: 'mine' }, { id: 'b2' }, { id: 'b3' }],
|
||||
});
|
||||
const take = mergeData(mine, theirs, true);
|
||||
assert.deepEqual((take.value as typeof mine).bookmarks[0], { id: 'b1', note: 'theirs' });
|
||||
|
||||
const notes = mergeData({ a: 1, b: 2 }, { b: 3, c: 4 });
|
||||
assert.deepEqual(notes, { value: { a: 1, b: 2, c: 4 }, added: 1, conflicts: 1 });
|
||||
assert.deepEqual(mergeData(undefined, { x: 1 }), { value: { x: 1 }, added: 1, conflicts: 0 });
|
||||
assert.deepEqual(mergeData(['a'], ['a', 'b']).value, ['a', 'b']);
|
||||
});
|
||||
|
||||
test('[#114] plan summarises per category', () => {
|
||||
const plan = planImport(file, {
|
||||
accountData: { 'io.lotus.settings': { theme: 'light' } },
|
||||
decoration: null,
|
||||
localSettings: { callNoiseSuppression: 'ml' },
|
||||
});
|
||||
const by = Object.fromEntries(plan.map((p) => [p.key, p]));
|
||||
assert.deepEqual(
|
||||
[by['io.lotus.settings'].added, by['io.lotus.settings'].conflicts],
|
||||
[1, 1],
|
||||
'fontSize new, theme differs',
|
||||
);
|
||||
assert.equal(by['io.lotus.bookmarks'].added, 1);
|
||||
assert.equal(by['profile.decoration'].added, 1);
|
||||
assert.deepEqual([by['local.settings'].added, by['local.settings'].conflicts], [0, 0]);
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* [Gitea #114] Export / import "your data": the Lotus-side personal data of
|
||||
* an account (not message history, not encryption keys: those have their own
|
||||
* exports, and keys are secret). One JSON file:
|
||||
*
|
||||
* { format: 'lotus-chat-data', version: 1, exportedAt, userId,
|
||||
* accountData: { [type]: content }, profile: { decoration }, local: { settings } }
|
||||
*
|
||||
* Import MERGES: anything missing here is added; a value that differs is a
|
||||
* conflict, kept as it is unless the user chooses the file's. Pure helpers.
|
||||
*/
|
||||
|
||||
export const EXPORT_FORMAT = 'lotus-chat-data';
|
||||
export const EXPORT_VERSION = 1;
|
||||
|
||||
/** The account-data types that are the user's own Lotus data, with labels. */
|
||||
export const EXPORT_ACCOUNT_DATA: { type: string; label: string }[] = [
|
||||
{ type: 'io.lotus.settings', label: 'Synced settings' },
|
||||
{ type: 'io.lotus.bookmarks', label: 'Bookmarks' },
|
||||
{ type: 'io.lotus.user_notes', label: 'Private notes about people' },
|
||||
{ type: 'io.lotus.room_names', label: 'Your names for rooms' },
|
||||
{ type: 'in.cinny.spaces', label: 'Room sections and sidebar' },
|
||||
{ type: 'io.lotus.reminders', label: 'Reminders' },
|
||||
{ type: 'io.lotus.status_presets', label: 'Status presets' },
|
||||
{ type: 'io.lotus.thread_notifications', label: 'Thread notification settings' },
|
||||
{ type: 'io.lotus.mute_timers', label: 'Timed mutes' },
|
||||
{ type: 'io.lotus.soundboard', label: 'Soundboard (clips stay on the server)' },
|
||||
{ type: 'io.lotus.soundboard_rooms', label: 'Soundboards enabled everywhere' },
|
||||
{ type: 'im.ponies.user_emotes', label: 'Your emoji and stickers' },
|
||||
{ type: 'im.ponies.emote_rooms', label: 'Emoji packs enabled everywhere' },
|
||||
];
|
||||
|
||||
export type ExportFile = {
|
||||
format: typeof EXPORT_FORMAT;
|
||||
version: number;
|
||||
exportedAt: string;
|
||||
userId: string;
|
||||
accountData: Record<string, unknown>;
|
||||
profile: { decoration: string | null };
|
||||
local: { settings: Record<string, unknown> | null };
|
||||
};
|
||||
|
||||
export const buildExport = (input: {
|
||||
userId: string;
|
||||
accountData: Record<string, unknown>;
|
||||
decoration: string | null;
|
||||
localSettings: Record<string, unknown> | null;
|
||||
now?: Date;
|
||||
}): ExportFile => {
|
||||
const accountData: Record<string, unknown> = {};
|
||||
EXPORT_ACCOUNT_DATA.forEach(({ type }) => {
|
||||
const c = input.accountData[type];
|
||||
if (c !== undefined && c !== null) accountData[type] = c;
|
||||
});
|
||||
return {
|
||||
format: EXPORT_FORMAT,
|
||||
version: EXPORT_VERSION,
|
||||
exportedAt: (input.now ?? new Date()).toISOString(),
|
||||
userId: input.userId,
|
||||
accountData,
|
||||
profile: { decoration: input.decoration },
|
||||
local: { settings: input.localSettings },
|
||||
};
|
||||
};
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> =>
|
||||
!!v && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
export type ParseResult = { ok: true; file: ExportFile } | { ok: false; error: string };
|
||||
|
||||
/** Validate an imported file; older versions would be migrated here. */
|
||||
export const parseExport = (text: string): ParseResult => {
|
||||
let v: unknown;
|
||||
try {
|
||||
v = JSON.parse(text);
|
||||
} catch {
|
||||
return { ok: false, error: "This isn't a Lotus Chat data file (it isn't valid JSON)." };
|
||||
}
|
||||
if (!isObject(v) || v.format !== EXPORT_FORMAT)
|
||||
return { ok: false, error: "This isn't a Lotus Chat data file." };
|
||||
if (typeof v.version !== 'number' || v.version < 1)
|
||||
return { ok: false, error: 'This data file is damaged (no version).' };
|
||||
if (v.version > EXPORT_VERSION)
|
||||
return {
|
||||
ok: false,
|
||||
error: 'This file was made by a newer Lotus Chat. Update the app, then import it again.',
|
||||
};
|
||||
const accountData = isObject(v.accountData) ? v.accountData : {};
|
||||
const known = new Set(EXPORT_ACCOUNT_DATA.map((t) => t.type));
|
||||
const filtered: Record<string, unknown> = {};
|
||||
Object.entries(accountData).forEach(([type, content]) => {
|
||||
// Only our own types: a file can't be used to write arbitrary account data.
|
||||
if (known.has(type) && content !== null && content !== undefined) filtered[type] = content;
|
||||
});
|
||||
const profile = isObject(v.profile) ? v.profile : {};
|
||||
const local = isObject(v.local) ? v.local : {};
|
||||
return {
|
||||
ok: true,
|
||||
file: {
|
||||
format: EXPORT_FORMAT,
|
||||
version: v.version,
|
||||
exportedAt: typeof v.exportedAt === 'string' ? v.exportedAt : '',
|
||||
userId: typeof v.userId === 'string' ? v.userId : '',
|
||||
accountData: filtered,
|
||||
profile: {
|
||||
decoration: typeof profile.decoration === 'string' ? profile.decoration : null,
|
||||
},
|
||||
local: { settings: isObject(local.settings) ? local.settings : null },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
const itemKey = (v: unknown): string => {
|
||||
if (isObject(v)) {
|
||||
const id = v.id ?? v.eventId ?? v.event_id ?? v.key ?? v.roomId ?? v.room_id ?? v.mxc;
|
||||
if (typeof id === 'string' || typeof id === 'number') return `id:${String(id)}`;
|
||||
}
|
||||
return `json:${JSON.stringify(v)}`;
|
||||
};
|
||||
|
||||
export type MergeResult = { value: unknown; added: number; conflicts: number };
|
||||
|
||||
/**
|
||||
* Pure: merge `incoming` into `current`. Lists: add items that are missing
|
||||
* (matched by id, else by content). Objects: add missing keys, recurse into
|
||||
* shared ones. A differing leaf is a conflict: `preferIncoming` decides.
|
||||
*/
|
||||
export const mergeData = (
|
||||
current: unknown,
|
||||
incoming: unknown,
|
||||
preferIncoming = false,
|
||||
): MergeResult => {
|
||||
if (current === undefined || current === null) {
|
||||
return { value: incoming, added: incoming === undefined ? 0 : 1, conflicts: 0 };
|
||||
}
|
||||
if (Array.isArray(current) && Array.isArray(incoming)) {
|
||||
const have = new Map(current.map((v, i) => [itemKey(v), i]));
|
||||
const out = [...current];
|
||||
let added = 0;
|
||||
let conflicts = 0;
|
||||
incoming.forEach((item) => {
|
||||
const k = itemKey(item);
|
||||
const at = have.get(k);
|
||||
if (at === undefined) {
|
||||
out.push(item);
|
||||
have.set(k, out.length - 1);
|
||||
added += 1;
|
||||
} else if (!same(out[at], item)) {
|
||||
conflicts += 1;
|
||||
if (preferIncoming) out[at] = item;
|
||||
}
|
||||
});
|
||||
return { value: out, added, conflicts };
|
||||
}
|
||||
if (isObject(current) && isObject(incoming)) {
|
||||
const out: Record<string, unknown> = { ...current };
|
||||
let added = 0;
|
||||
let conflicts = 0;
|
||||
Object.entries(incoming).forEach(([k, v]) => {
|
||||
if (!(k in current)) {
|
||||
out[k] = v;
|
||||
added += 1;
|
||||
return;
|
||||
}
|
||||
const r = mergeData(current[k], v, preferIncoming);
|
||||
out[k] = r.value;
|
||||
added += r.added;
|
||||
conflicts += r.conflicts;
|
||||
});
|
||||
return { value: out, added, conflicts };
|
||||
}
|
||||
if (same(current, incoming)) return { value: current, added: 0, conflicts: 0 };
|
||||
return { value: preferIncoming ? incoming : current, added: 0, conflicts: 1 };
|
||||
};
|
||||
|
||||
export type ImportPlanItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
added: number;
|
||||
conflicts: number;
|
||||
};
|
||||
|
||||
/** What importing `file` into the current data would change, per category. */
|
||||
export const planImport = (
|
||||
file: ExportFile,
|
||||
current: {
|
||||
accountData: Record<string, unknown>;
|
||||
decoration: string | null;
|
||||
localSettings: Record<string, unknown> | null;
|
||||
},
|
||||
): ImportPlanItem[] => {
|
||||
const items: ImportPlanItem[] = [];
|
||||
EXPORT_ACCOUNT_DATA.forEach(({ type, label }) => {
|
||||
if (!(type in file.accountData)) return;
|
||||
const r = mergeData(current.accountData[type], file.accountData[type]);
|
||||
items.push({ key: type, label, added: r.added, conflicts: r.conflicts });
|
||||
});
|
||||
if (file.profile.decoration !== null) {
|
||||
const differs = current.decoration !== file.profile.decoration;
|
||||
items.push({
|
||||
key: 'profile.decoration',
|
||||
label: 'Avatar decoration',
|
||||
added: !current.decoration && differs ? 1 : 0,
|
||||
conflicts: current.decoration && differs ? 1 : 0,
|
||||
});
|
||||
}
|
||||
if (file.local.settings) {
|
||||
const r = mergeData(current.localSettings ?? {}, file.local.settings);
|
||||
items.push({
|
||||
key: 'local.settings',
|
||||
label: "This device's settings",
|
||||
added: r.added,
|
||||
conflicts: r.conflicts,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
getKeyUploadWatch,
|
||||
isKeyUploadUrl,
|
||||
isOtkConflict,
|
||||
recordKeyUpload,
|
||||
resetKeyUploadWatch,
|
||||
STUCK_AFTER,
|
||||
subscribeKeyUploadWatch,
|
||||
WINDOW_MS,
|
||||
watchKeyUploads,
|
||||
} from './keyUploadWatch';
|
||||
|
||||
const CONFLICT = JSON.stringify({
|
||||
errcode: 'M_UNKNOWN',
|
||||
error:
|
||||
'One time key signed_curve25519:AAAAAAAAAA0 already exists. Old key: {...}; new key: {...}',
|
||||
});
|
||||
|
||||
test('[#201] recognises the upload endpoint and the conflict body', () => {
|
||||
assert.equal(isKeyUploadUrl('https://hs.example/_matrix/client/v3/keys/upload'), true);
|
||||
assert.equal(isKeyUploadUrl('https://hs.example/_matrix/client/v3/keys/upload/'), true);
|
||||
assert.equal(isKeyUploadUrl('https://hs.example/_matrix/client/v3/keys/query'), false);
|
||||
assert.equal(isKeyUploadUrl('https://hs.example/_matrix/client/v3/room_keys/keys'), false);
|
||||
assert.equal(isOtkConflict(400, CONFLICT), true);
|
||||
assert.equal(isOtkConflict(400, '{"errcode":"M_BAD_JSON"}'), false);
|
||||
assert.equal(isOtkConflict(500, CONFLICT), false);
|
||||
});
|
||||
|
||||
test('[#201] stuck only after repeated conflicts inside the window', () => {
|
||||
resetKeyUploadWatch();
|
||||
const t0 = 1_000_000;
|
||||
for (let i = 0; i < STUCK_AFTER - 1; i += 1) recordKeyUpload(400, CONFLICT, t0 + i * 1000);
|
||||
assert.equal(getKeyUploadWatch().stuck, false, 'a couple of races is not divergence');
|
||||
recordKeyUpload(400, CONFLICT, t0 + 5000);
|
||||
assert.equal(getKeyUploadWatch().stuck, true);
|
||||
assert.match(getKeyUploadWatch().lastError ?? '', /already exists/);
|
||||
});
|
||||
|
||||
test('[#201] old conflicts age out; other 400s are ignored', () => {
|
||||
resetKeyUploadWatch();
|
||||
recordKeyUpload(400, CONFLICT, 0);
|
||||
recordKeyUpload(400, CONFLICT, 1000);
|
||||
recordKeyUpload(400, CONFLICT, WINDOW_MS + 60_000);
|
||||
assert.equal(getKeyUploadWatch().stuck, false);
|
||||
recordKeyUpload(400, '{"errcode":"M_BAD_JSON"}', WINDOW_MS + 61_000);
|
||||
assert.equal(getKeyUploadWatch().conflicts, 1);
|
||||
});
|
||||
|
||||
test('[#201] a successful upload clears it and notifies', () => {
|
||||
resetKeyUploadWatch();
|
||||
let calls = 0;
|
||||
const off = subscribeKeyUploadWatch(() => {
|
||||
calls += 1;
|
||||
});
|
||||
for (let i = 0; i < STUCK_AFTER; i += 1) recordKeyUpload(400, CONFLICT, i);
|
||||
assert.equal(getKeyUploadWatch().stuck, true);
|
||||
recordKeyUpload(200, '');
|
||||
assert.equal(getKeyUploadWatch().stuck, false);
|
||||
assert.ok(calls >= 2);
|
||||
off();
|
||||
});
|
||||
|
||||
test('[#201] the fetch wrapper observes without changing the response', async () => {
|
||||
resetKeyUploadWatch();
|
||||
const fake: typeof fetch = async (input) =>
|
||||
String(input).endsWith('/keys/upload')
|
||||
? new Response(CONFLICT, { status: 400 })
|
||||
: new Response('{}', { status: 200 });
|
||||
const f = watchKeyUploads(fake);
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: STUCK_AFTER }, () =>
|
||||
f('https://hs.example/_matrix/client/v3/keys/upload', { method: 'POST' }),
|
||||
),
|
||||
);
|
||||
const bodies = await Promise.all(responses.map((res) => res.text()));
|
||||
responses.forEach((res) => assert.equal(res.status, 400));
|
||||
bodies.forEach((b) => assert.match(b, /already exists/, 'the SDK can still read the body'));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, 10);
|
||||
});
|
||||
assert.equal(getKeyUploadWatch().stuck, true);
|
||||
await f('https://hs.example/_matrix/client/v3/sync');
|
||||
assert.equal(getKeyUploadWatch().stuck, true, 'unrelated requests change nothing');
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* [Gitea #201 KE-1] Notice when this device's encryption store has diverged
|
||||
* from the server: every POST /keys/upload answers 400 "One time key ...
|
||||
* already exists", and the SDK retries forever (matrix-rust-sdk#5200, open),
|
||||
* so new encrypted messages and calls quietly degrade. The server saw 474k of
|
||||
* these between May 30 and Jul 8 2026. The only fix is a fresh login on that
|
||||
* device, so we tell the user (KeyUploadBanner) instead of looping silently.
|
||||
*
|
||||
* Watched at the fetch layer (MatrixClient `fetchFn`), so it doesn't depend on
|
||||
* SDK log wording. Pure state + a tiny subscribe API; no network I/O of its own.
|
||||
*/
|
||||
|
||||
/** Conflicts needed before we call the device stuck. The SDK retries within
|
||||
* seconds, so a real divergence crosses this quickly; a one-off race doesn't. */
|
||||
export const STUCK_AFTER = 3;
|
||||
/** Conflicts older than this don't count (a long-ago blip isn't "now"). */
|
||||
export const WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export type KeyUploadWatchState = { stuck: boolean; conflicts: number; lastError?: string };
|
||||
|
||||
let conflictTimes: number[] = [];
|
||||
let lastError: string | undefined;
|
||||
let state: KeyUploadWatchState = { stuck: false, conflicts: 0 };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const publish = (next: KeyUploadWatchState) => {
|
||||
if (next.stuck === state.stuck && next.conflicts === state.conflicts) return;
|
||||
state = next;
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
|
||||
export const isKeyUploadUrl = (url: string): boolean => {
|
||||
try {
|
||||
return /\/_matrix\/client\/[^/]+\/keys\/upload\/?$/.test(new URL(url, 'http://x').pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** The 400 body that marks a diverged one-time-key store. */
|
||||
export const isOtkConflict = (status: number, body: string): boolean =>
|
||||
status === 400 && /one time key .*already exists/i.test(body);
|
||||
|
||||
/** Feed one /keys/upload response. `now` is injectable for tests. */
|
||||
export const recordKeyUpload = (status: number, body: string, now = Date.now()): void => {
|
||||
if (status >= 200 && status < 300) {
|
||||
// A successful upload means the stores agree again.
|
||||
conflictTimes = [];
|
||||
lastError = undefined;
|
||||
publish({ stuck: false, conflicts: 0 });
|
||||
return;
|
||||
}
|
||||
if (!isOtkConflict(status, body)) return;
|
||||
conflictTimes = [...conflictTimes, now].filter((t) => now - t <= WINDOW_MS);
|
||||
lastError = body.slice(0, 300);
|
||||
publish({
|
||||
stuck: conflictTimes.length >= STUCK_AFTER,
|
||||
conflicts: conflictTimes.length,
|
||||
lastError,
|
||||
});
|
||||
};
|
||||
|
||||
export const getKeyUploadWatch = (): KeyUploadWatchState => state;
|
||||
|
||||
export const subscribeKeyUploadWatch = (cb: () => void): (() => void) => {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
};
|
||||
|
||||
/** Test/logout helper: forget everything (a new session starts clean). */
|
||||
export const resetKeyUploadWatch = (): void => {
|
||||
conflictTimes = [];
|
||||
lastError = undefined;
|
||||
publish({ stuck: false, conflicts: 0 });
|
||||
};
|
||||
|
||||
const urlOf = (input: RequestInfo | URL): string =>
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
|
||||
/**
|
||||
* Wrap a fetch so /keys/upload responses are observed. The response the SDK
|
||||
* gets is untouched; a clone is read in the background, never awaited.
|
||||
*/
|
||||
export const watchKeyUploads =
|
||||
(inner: typeof fetch): typeof fetch =>
|
||||
async (input, init) => {
|
||||
const res = await inner(input, init);
|
||||
try {
|
||||
if (isKeyUploadUrl(urlOf(input))) {
|
||||
if (res.ok) recordKeyUpload(res.status, '');
|
||||
else if (res.status === 400)
|
||||
res
|
||||
.clone()
|
||||
.text()
|
||||
.then((body) => recordKeyUpload(res.status, body))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
// observation must never break the request
|
||||
}
|
||||
return res;
|
||||
};
|
||||
@@ -1,7 +1,13 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { PresenceDeduper, describePresenceError, setPresenceWithRetry } from './presenceWrite';
|
||||
import { IPresenceOpts, MatrixError } from 'matrix-js-sdk';
|
||||
import {
|
||||
PresenceDeduper,
|
||||
describePresenceError,
|
||||
isPresenceWritePending,
|
||||
queuePresenceWrite,
|
||||
setPresenceWithRetry,
|
||||
} from './presenceWrite';
|
||||
|
||||
const limited = (retryMs = 3000) =>
|
||||
new MatrixError(
|
||||
@@ -79,3 +85,43 @@ test('error wording', () => {
|
||||
);
|
||||
assert.match(describePresenceError(new TypeError('Failed to fetch')), /couldn't reach/);
|
||||
});
|
||||
|
||||
test('[status save] queuePresenceWrite: newest wins, waits out 429 once, all callers settle', async () => {
|
||||
const sent: string[] = [];
|
||||
let calls = 0;
|
||||
const sleeps: number[] = [];
|
||||
const mx = {
|
||||
setPresence: async (w: IPresenceOpts) => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
sent.push(`429:${w.status_msg}`);
|
||||
throw new MatrixError({ errcode: 'M_LIMIT_EXCEEDED', retry_after_ms: 7000 }, 429);
|
||||
}
|
||||
sent.push(`ok:${w.status_msg}`);
|
||||
},
|
||||
};
|
||||
const sleepFn = async (ms: number) => {
|
||||
sleeps.push(ms);
|
||||
};
|
||||
// 'a' goes out at once; 'b' and 'c' arrive while it waits out the limit.
|
||||
const first = queuePresenceWrite(mx, { presence: 'online', status_msg: 'a' }, { sleepFn });
|
||||
const second = queuePresenceWrite(mx, { presence: 'online', status_msg: 'b' }, { sleepFn });
|
||||
const third = queuePresenceWrite(mx, { presence: 'online', status_msg: 'c' }, { sleepFn });
|
||||
assert.equal(isPresenceWritePending(mx), true);
|
||||
await Promise.all([first, second, third]);
|
||||
// 'a' was 429'd; after the wait only the newest ('c') goes out — 'a' isn't
|
||||
// retried and 'b' is never sent.
|
||||
assert.deepEqual(sent, ['429:a', 'ok:c']);
|
||||
assert.deepEqual(sleeps, [7000]);
|
||||
assert.equal(isPresenceWritePending(mx), false);
|
||||
});
|
||||
|
||||
test('[status save] queuePresenceWrite: a failure rejects the callers of that write', async () => {
|
||||
const mx = {
|
||||
setPresence: async () => {
|
||||
throw new MatrixError({ errcode: 'M_FORBIDDEN' }, 403);
|
||||
},
|
||||
};
|
||||
await assert.rejects(queuePresenceWrite(mx, { presence: 'online', status_msg: 'x' }));
|
||||
assert.equal(isPresenceWritePending(mx), false);
|
||||
});
|
||||
|
||||
@@ -94,3 +94,78 @@ export const describePresenceError = (e: unknown): string => {
|
||||
}
|
||||
return "Failed to save status — couldn't reach the server. Try again.";
|
||||
};
|
||||
|
||||
type PresenceQueue = {
|
||||
pending?: PresenceWrite;
|
||||
waiters: { resolve: () => void; reject: (e: unknown) => void }[];
|
||||
running: boolean;
|
||||
};
|
||||
const queues = new WeakMap<object, PresenceQueue>();
|
||||
|
||||
/**
|
||||
* One presence writer per client, newest write wins. The status save and the
|
||||
* heartbeat share Synapse's one-write-per-10-s budget, so instead of each
|
||||
* waiting out 429s on its own (and racing), writes queue here: anything still
|
||||
* waiting is replaced by the newer write, which goes out as soon as the server
|
||||
* allows. Each caller's promise settles with the outcome of the write that
|
||||
* finally carried (or superseded) theirs.
|
||||
*/
|
||||
export function queuePresenceWrite(
|
||||
mx: Pick<MatrixClient, 'setPresence'>,
|
||||
write: PresenceWrite,
|
||||
opts: { maxWaitMs?: number; sleepFn?: (ms: number) => Promise<void> } = {},
|
||||
): Promise<void> {
|
||||
let q = queues.get(mx);
|
||||
if (!q) {
|
||||
q = { waiters: [], running: false };
|
||||
queues.set(mx, q);
|
||||
}
|
||||
const queue = q;
|
||||
queue.pending = write;
|
||||
const settled = new Promise<void>((resolve, reject) => {
|
||||
queue.waiters.push({ resolve, reject });
|
||||
});
|
||||
if (!queue.running) {
|
||||
queue.running = true;
|
||||
const maxWaitMs = opts.maxWaitMs ?? 25_000;
|
||||
const sleepFn = opts.sleepFn ?? sleep;
|
||||
(async () => {
|
||||
let waited = 0;
|
||||
while (queue.pending) {
|
||||
const next = queue.pending;
|
||||
queue.pending = undefined;
|
||||
const waiters = queue.waiters.splice(0);
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await writePresence(mx, next);
|
||||
waited = 0;
|
||||
if (queue.pending) queue.waiters.unshift(...waiters);
|
||||
else waiters.forEach((w) => w.resolve());
|
||||
} catch (e) {
|
||||
const wait =
|
||||
e instanceof MatrixError && e.httpStatus === 429
|
||||
? Math.min(e.getRetryAfterMs() ?? 10_500, 15_000)
|
||||
: undefined;
|
||||
if (wait !== undefined && waited + wait <= maxWaitMs) {
|
||||
waited += wait;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await sleepFn(wait);
|
||||
// After the wait, send whatever is newest: a change made meanwhile
|
||||
// replaces this one rather than queueing behind it.
|
||||
queue.pending ??= next;
|
||||
queue.waiters.unshift(...waiters);
|
||||
} else if (queue.pending) {
|
||||
queue.waiters.unshift(...waiters);
|
||||
} else {
|
||||
waiters.forEach((w) => w.reject(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
queue.running = false;
|
||||
})();
|
||||
}
|
||||
return settled;
|
||||
}
|
||||
|
||||
/** Whether a presence write is queued or in flight for this client. */
|
||||
export const isPresenceWritePending = (mx: object): boolean => queues.get(mx)?.running ?? false;
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
parseNudgeRecord,
|
||||
SecurityState,
|
||||
shouldShowNudge,
|
||||
pickNudge,
|
||||
staleSessions,
|
||||
staleSessionsText,
|
||||
} from './securityNudge';
|
||||
|
||||
const healthy: SecurityState = {
|
||||
@@ -79,3 +82,45 @@ test('stored record: round trip, and garbage starts a fresh grace period', () =>
|
||||
assert.deepEqual(parseNudgeRecord(bad, t0), { firstSeen: t0, dismissals: {} }, bad);
|
||||
}
|
||||
});
|
||||
|
||||
test('[#113] stale sessions: 90+ days unused, never the current one', () => {
|
||||
const now = Date.UTC(2026, 9, 25);
|
||||
const day = 86_400_000;
|
||||
const devices = [
|
||||
{ device_id: 'ME', last_seen_ts: now - 400 * day },
|
||||
{ device_id: 'OLD', last_seen_ts: now - 91 * day },
|
||||
{ device_id: 'EDGE', last_seen_ts: now - 90 * day },
|
||||
{ device_id: 'RECENT', last_seen_ts: now - 89 * day },
|
||||
{ device_id: 'UNKNOWN' },
|
||||
{ device_id: 'NULL', last_seen_ts: null },
|
||||
];
|
||||
assert.deepEqual(
|
||||
staleSessions(devices, 'ME', now).map((d) => d.device_id),
|
||||
['OLD', 'EDGE'],
|
||||
);
|
||||
});
|
||||
|
||||
test('[#113] wording follows the count', () => {
|
||||
assert.match(
|
||||
staleSessionsText(1),
|
||||
/^You have a session you haven't used in 3 months\. Signing it out/,
|
||||
);
|
||||
assert.match(
|
||||
staleSessionsText(3),
|
||||
/^You have 3 sessions you haven't used in 3 months\. Signing them out/,
|
||||
);
|
||||
});
|
||||
|
||||
test('[#113] pickNudge: security first, stale sessions when that one is snoozed or absent', () => {
|
||||
const now = 10 * 86_400_000;
|
||||
const fresh = { firstSeen: 0, dismissals: {} };
|
||||
assert.equal(pickNudge('verify-device', 2, fresh, now), 'verify-device');
|
||||
assert.equal(pickNudge(undefined, 2, fresh, now), 'stale-sessions');
|
||||
assert.equal(pickNudge(undefined, 0, fresh, now), undefined);
|
||||
const snoozed = dismissNudge('verify-device', fresh, now);
|
||||
assert.equal(pickNudge('verify-device', 2, snoozed, now), 'stale-sessions');
|
||||
const bothSnoozed = dismissNudge('stale-sessions', snoozed, now);
|
||||
assert.equal(pickNudge('verify-device', 2, bothSnoozed, now), undefined);
|
||||
// inside the 24 h grace period nothing shows
|
||||
assert.equal(pickNudge(undefined, 2, { firstSeen: now, dismissals: {} }, now), undefined);
|
||||
});
|
||||
|
||||
@@ -9,12 +9,15 @@
|
||||
* - setup-backup: no key backup at all (includes accounts that never set up
|
||||
* cross-signing: the setup flow does both).
|
||||
*
|
||||
* - stale-sessions (#113, lowest priority): sessions of this account not used
|
||||
* for 90+ days; Review opens Settings → Privacy & security 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',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user