Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
378b40fabe | ||
|
|
02c852e194 | ||
|
|
6e6213a43b | ||
|
|
5379eb046d | ||
|
|
17ebddb159 | ||
|
|
7bd1c14a3c | ||
|
|
a58684958c | ||
|
|
3c3bb2e41c | ||
|
|
88446e8953 | ||
|
|
f6bad9eb58 | ||
|
|
93c7dcd749 | ||
|
|
43e166b879 | ||
|
|
70206d6033 | ||
|
|
bfed37bf50 | ||
|
|
c2c43eda99 | ||
|
|
45c3b16694 |
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"matrix_rtc_session": {
|
||||
"delayed_leave": { "delay_ms": 60000, "restart_ms": 15000 }
|
||||
}
|
||||
}
|
||||
@@ -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 → Devices');
|
||||
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');
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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('General', { 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
+35
-42
@@ -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",
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.29",
|
||||
"@lotusguild/element-call-embedded": "0.28.0-lotus.2",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
@@ -2695,15 +2695,15 @@
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||
},
|
||||
"node_modules/@lotusguild/element-call-embedded": {
|
||||
"version": "0.25.0-lotus.29",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.29/element-call-embedded-0.25.0-lotus.29.tgz",
|
||||
"integrity": "sha512-IAIEyiOFfkfb1k2JfBNOkX4LOVaGF8LzeKVQYCqKoyKDVao6rYEYA71LON8ntTaxkredXOK4CeLFsnkCiJHuGg==",
|
||||
"version": "0.28.0-lotus.2",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.28.0-lotus.2/element-call-embedded-0.28.0-lotus.2.tgz",
|
||||
"integrity": "sha512-g3xaZ/i3RQ0mToWmjLi08HjSuKD3BpAllj9aEucPS8wh3Uwmz4HXa0tgF1hEN3dPrZR2BTWLQii5Mtfh4037vg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
"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",
|
||||
@@ -9152,15 +9152,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",
|
||||
@@ -10037,21 +10028,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 +10049,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 +10410,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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
@@ -108,7 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.29",
|
||||
"@lotusguild/element-call-embedded": "0.28.0-lotus.2",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
|
||||
@@ -23,7 +23,12 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
|
||||
|
||||
const [state, load] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const result = await Promise.allSettled([mx.loginFlows(), mx.registerRequest({})]);
|
||||
const result = await Promise.allSettled([
|
||||
mx.loginFlows(),
|
||||
mx.registerRequest({}),
|
||||
// Rejects when the server has no native OAuth: normal for most servers.
|
||||
mx.getAuthMetadata(),
|
||||
]);
|
||||
const loginFlows = promiseFulfilledResult(result[0]);
|
||||
const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined;
|
||||
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
|
||||
@@ -42,6 +47,7 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
|
||||
const authFlows: AuthFlows = {
|
||||
loginFlows,
|
||||
registerFlows,
|
||||
authMetadata: promiseFulfilledResult(result[2]),
|
||||
};
|
||||
|
||||
return authFlows;
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
useCallThemeSync,
|
||||
useCallStart,
|
||||
} from '../hooks/useCallEmbed';
|
||||
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||
import { callChatAtom, callEmbedAtom, immersiveCallAtom } from '../state/callEmbed';
|
||||
import { toastQueueAtom } from '../state/toast';
|
||||
import { CallEmbed, useCallControlState, useScreensharePromptOpen } from '../plugins/call';
|
||||
import { hangupCallAndWait } from '../plugins/call/hangup';
|
||||
@@ -52,6 +52,7 @@ import { useCallEndedToast } from '../hooks/useCallEndedToast';
|
||||
import { useCallRejoin } from '../hooks/useCallRejoin';
|
||||
import { usePttHaptics } from '../hooks/usePttHaptics';
|
||||
import { useScreenshareNotices } from '../hooks/useScreenshareNotices';
|
||||
import { ExpectedDevicesPrompt } from '../features/call/ExpectedDevicesPrompt';
|
||||
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
|
||||
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
|
||||
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
||||
@@ -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}
|
||||
|
||||
@@ -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]),
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
|
||||
import { CallMemberRenderer } from './CallMemberCard';
|
||||
import * as css from './styles.css';
|
||||
import { CallControls } from './CallControls';
|
||||
import { ImmersiveCallOverlay } from './ImmersiveCallOverlay';
|
||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { CallsUnavailableMessage } from './CallsUnavailable';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
@@ -198,10 +199,24 @@ function CallLoadErrorMessage() {
|
||||
type CallJoinedProps = {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
joined: boolean;
|
||||
/** [Gitea #126] Full screen: controls float over the call instead. */
|
||||
onExitImmersive?: () => void;
|
||||
};
|
||||
function CallJoined({ joined, containerRef }: CallJoinedProps) {
|
||||
function CallJoined({ joined, containerRef, onExitImmersive }: CallJoinedProps) {
|
||||
const callEmbed = useCallEmbed();
|
||||
|
||||
if (onExitImmersive) {
|
||||
return (
|
||||
<Box grow="Yes" direction="Column" style={{ position: 'relative' }}>
|
||||
<Box grow="Yes" ref={containerRef} />
|
||||
{callEmbed && joined && (
|
||||
<ImmersiveCallOverlay callEmbed={callEmbed} onExit={onExitImmersive} />
|
||||
)}
|
||||
{callEmbed && joined && <LotusDecorationPusher callEmbed={callEmbed} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Box grow="Yes" ref={containerRef} />
|
||||
@@ -212,7 +227,7 @@ function CallJoined({ joined, containerRef }: CallJoinedProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function CallView() {
|
||||
export function CallView({ onExitImmersive }: { onExitImmersive?: () => void } = {}) {
|
||||
const room = useRoom();
|
||||
const callContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||
useCallEmbedPlacementSync(callContainerRef);
|
||||
@@ -227,6 +242,26 @@ export function CallView() {
|
||||
// made it into the call (a hung iframe / blank spinner otherwise).
|
||||
const showLoadError = isCurrentRoom && !currentJoined && Boolean(loadError);
|
||||
|
||||
if (onExitImmersive && currentJoined) {
|
||||
// [Gitea #126] Cover the screen with a transparent layer above the call
|
||||
// frame: the frame follows the placeholder (useCallEmbedPlacementSync), so
|
||||
// it goes full screen too; the layer only takes touches on its own
|
||||
// tap-catcher / controls, so the call underneath stays usable.
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
paddingTop: 'env(safe-area-inset-top)',
|
||||
}}
|
||||
>
|
||||
<CallJoined joined containerRef={callContainerRef} onExitImmersive={onExitImmersive} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'Surface' })}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
Header,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { CallEmbed } from '../../plugins/call';
|
||||
import {
|
||||
MissingDevice,
|
||||
acceptForSession,
|
||||
describeLost,
|
||||
describeMissing,
|
||||
isAcceptedThisSession,
|
||||
parseDeviceLost,
|
||||
parseDeviceMissing,
|
||||
} from '../../plugins/call/expectedDevices';
|
||||
import { toastQueueAtom } from '../../state/toast';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
|
||||
/**
|
||||
* [Gitea #121] "Your usual headset isn't connected": the fork holds the join
|
||||
* until we answer. Join anyway (not asked again this session for the same
|
||||
* device) or Cancel (closes the call before it starts). A device lost mid-call
|
||||
* is only a toast.
|
||||
*/
|
||||
export function ExpectedDevicesPrompt({
|
||||
embed,
|
||||
onCancel,
|
||||
}: {
|
||||
embed: CallEmbed;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [missing, setMissing] = useState<MissingDevice[]>([]);
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
const modalStyle = useModalStyle(440);
|
||||
|
||||
useEffect(() => {
|
||||
const offMissing = embed.listenAction<{ data?: unknown }>('io.lotus.device_missing', (evt) => {
|
||||
const list = parseDeviceMissing(evt.detail?.data);
|
||||
if (list.length === 0) return;
|
||||
if (isAcceptedThisSession(list)) {
|
||||
embed.control.sendDeviceDecision(true);
|
||||
return;
|
||||
}
|
||||
setMissing(list);
|
||||
});
|
||||
const offLost = embed.listenAction<{ data?: unknown }>('io.lotus.device_lost', (evt) => {
|
||||
const lost = parseDeviceLost(evt.detail?.data);
|
||||
if (!lost) return;
|
||||
setToast({
|
||||
id: `device-lost-${lost.kind}-${Date.now()}`,
|
||||
iconSrc: lost.kind === 'audioinput' ? Icons.Mic : Icons.VolumeHigh,
|
||||
displayName: 'Audio device',
|
||||
body: describeLost(lost),
|
||||
roomName: embed.room.name ?? 'Voice call',
|
||||
roomId: embed.roomId,
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
offMissing();
|
||||
offLost();
|
||||
};
|
||||
}, [embed, setToast]);
|
||||
|
||||
if (missing.length === 0) return null;
|
||||
|
||||
const joinAnyway = () => {
|
||||
acceptForSession(missing);
|
||||
embed.control.sendDeviceDecision(true);
|
||||
setMissing([]);
|
||||
};
|
||||
const cancel = () => {
|
||||
embed.control.sendDeviceDecision(false);
|
||||
setMissing([]);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap focusTrapOptions={{ initialFocus: false, escapeDeactivates: false }}>
|
||||
<Dialog
|
||||
variant="Surface"
|
||||
role="alertdialog"
|
||||
aria-labelledby="expected-devices-title"
|
||||
style={modalStyle}
|
||||
>
|
||||
<Header
|
||||
variant="Surface"
|
||||
size="500"
|
||||
style={{
|
||||
padding: `0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
>
|
||||
<Text id="expected-devices-title" as="h2" size="H4">
|
||||
{missing.length === 1
|
||||
? describeMissing(missing[0]).title
|
||||
: "Your usual audio devices aren't connected"}
|
||||
</Text>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
{missing.map((d) => {
|
||||
const t = describeMissing(d);
|
||||
return (
|
||||
<Text key={d.kind} size="T300">
|
||||
<b>{t.expected}</b> isn't connected. Join with <b>{t.fallback}</b> instead?
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
<Text size="T200" priority="300">
|
||||
Plug it in and choose Cancel to start over with it.
|
||||
</Text>
|
||||
<Box direction="Column" gap="200">
|
||||
<Button variant="Primary" onClick={joinAnyway}>
|
||||
<Text size="B400">Join anyway</Text>
|
||||
</Button>
|
||||
<Button variant="Secondary" fill="Soft" onClick={cancel}>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
|
||||
import { CallEmbed } from '../../plugins/call';
|
||||
import { CallControls } from './CallControls';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { CONTROLS_AUTOHIDE_MS, wantsBrowserFullscreen } from './landscapeCall';
|
||||
|
||||
const isStandalone = (): boolean =>
|
||||
window.matchMedia?.('(display-mode: standalone)').matches ||
|
||||
(navigator as { standalone?: boolean }).standalone === true;
|
||||
|
||||
/**
|
||||
* [Gitea #126] Full-screen call controls. Taps inside the call frame never
|
||||
* reach us, so while the controls are hidden a transparent layer covers the
|
||||
* call: the first tap shows them (and is otherwise swallowed, as in native
|
||||
* video apps); with them up the layer is gone, so the call stays usable. They
|
||||
* hide again after a few seconds without a touch. The first tap in an Android
|
||||
* browser tab also enters the browser's own full screen (hides the address
|
||||
* bar); it is left again when this unmounts (rotate back, hang up, exit).
|
||||
*/
|
||||
export function ImmersiveCallOverlay({
|
||||
callEmbed,
|
||||
onExit,
|
||||
}: {
|
||||
callEmbed: CallEmbed;
|
||||
onExit: () => void;
|
||||
}) {
|
||||
const [shown, setShown] = useState(false);
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
const enteredFullscreen = useRef(false);
|
||||
const setChat = useSetAtom(callChatAtom);
|
||||
|
||||
const armHide = useCallback(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setShown(false), CONTROLS_AUTOHIDE_MS);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timer.current);
|
||||
if (enteredFullscreen.current && document.fullscreenElement === document.documentElement) {
|
||||
document.exitFullscreen().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reveal = () => {
|
||||
if (
|
||||
wantsBrowserFullscreen({
|
||||
userAgent: navigator.userAgent,
|
||||
standalone: isStandalone(),
|
||||
fullscreenEnabled: !!document.fullscreenEnabled,
|
||||
alreadyFullscreen: !!document.fullscreenElement,
|
||||
})
|
||||
) {
|
||||
document.documentElement
|
||||
.requestFullscreen({ navigationUI: 'hide' })
|
||||
.then(() => {
|
||||
enteredFullscreen.current = true;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
setShown(true);
|
||||
armHide();
|
||||
};
|
||||
|
||||
if (!shown) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Show call controls"
|
||||
onClick={reveal}
|
||||
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && reveal()}
|
||||
style={{ position: 'absolute', inset: 0, pointerEvents: 'auto' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
// Any touch on the controls keeps them up a little longer.
|
||||
onPointerDown={armHide}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: 'auto',
|
||||
paddingLeft: 'env(safe-area-inset-left)',
|
||||
paddingRight: 'env(safe-area-inset-right)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.7), rgba(0,0,0,0))',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
justifyContent="End"
|
||||
gap="200"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
||||
>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
before={<Icon size="100" src={Icons.Message} />}
|
||||
onClick={() => setChat(true)}
|
||||
>
|
||||
<Text size="B300">Chat</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
before={<Icon size="100" src={Icons.Cross} />}
|
||||
onClick={onExit}
|
||||
style={{ minWidth: toRem(40) }}
|
||||
>
|
||||
<Text size="B300">Exit full screen</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
<CallControls callEmbed={callEmbed} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { shouldGoImmersive, wantsBrowserFullscreen } from './landscapeCall';
|
||||
|
||||
const base = { landscapePhone: true, joinedHere: true, chatOpen: false, dismissed: false };
|
||||
|
||||
test('[#126] full screen only for a joined call on a sideways phone', () => {
|
||||
assert.equal(shouldGoImmersive(base), true);
|
||||
assert.equal(shouldGoImmersive({ ...base, landscapePhone: false }), false, 'upright / tablet');
|
||||
assert.equal(shouldGoImmersive({ ...base, joinedHere: false }), false, 'prescreen / other room');
|
||||
assert.equal(shouldGoImmersive({ ...base, chatOpen: true }), false, 'chat open');
|
||||
assert.equal(shouldGoImmersive({ ...base, dismissed: true }), false, 'user exited');
|
||||
});
|
||||
|
||||
test('[#126] browser full screen only in an Android browser tab', () => {
|
||||
const env = {
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) Chrome/140 Mobile',
|
||||
standalone: false,
|
||||
fullscreenEnabled: true,
|
||||
alreadyFullscreen: false,
|
||||
};
|
||||
assert.equal(wantsBrowserFullscreen(env), true);
|
||||
assert.equal(wantsBrowserFullscreen({ ...env, standalone: true }), false, 'installed app');
|
||||
assert.equal(
|
||||
wantsBrowserFullscreen({
|
||||
...env,
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari',
|
||||
}),
|
||||
false,
|
||||
'iPhone',
|
||||
);
|
||||
assert.equal(wantsBrowserFullscreen({ ...env, alreadyFullscreen: true }), false);
|
||||
assert.equal(wantsBrowserFullscreen({ ...env, fullscreenEnabled: false }), false);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* [Gitea #126] Phone turned sideways in a call: the call takes the whole
|
||||
* screen. A phone in landscape is ~840px wide (the "Tablet" size class), so
|
||||
* the trigger is the shape of the screen, not the size class: landscape, at
|
||||
* most 500px tall, touch input. Tablets and desktops never match.
|
||||
*/
|
||||
export const LANDSCAPE_PHONE_QUERY =
|
||||
'(orientation: landscape) and (max-height: 500px) and (pointer: coarse)';
|
||||
|
||||
/** Controls hide again after this long without a touch on them. */
|
||||
export const CONTROLS_AUTOHIDE_MS = 4000;
|
||||
|
||||
export type ImmersiveInputs = {
|
||||
landscapePhone: boolean;
|
||||
/** This room's call is the one we are in, and we have joined it. */
|
||||
joinedHere: boolean;
|
||||
/** The in-call chat is open (it replaces the call on small screens). */
|
||||
chatOpen: boolean;
|
||||
/** "Exit full screen" was pressed since the phone was last upright. */
|
||||
dismissed: boolean;
|
||||
};
|
||||
|
||||
/** Pure: whether the call should be full screen. */
|
||||
export const shouldGoImmersive = (i: ImmersiveInputs): boolean =>
|
||||
i.landscapePhone && i.joinedHere && !i.chatOpen && !i.dismissed;
|
||||
|
||||
/**
|
||||
* Pure: use the browser's own full-screen mode too? Only where an address bar
|
||||
* takes room: an Android browser tab (not the installed app / iOS, which have
|
||||
* none or don't support it), when the page isn't already full screen.
|
||||
*/
|
||||
export const wantsBrowserFullscreen = (env: {
|
||||
userAgent: string;
|
||||
standalone: boolean;
|
||||
fullscreenEnabled: boolean;
|
||||
alreadyFullscreen: boolean;
|
||||
}): boolean =>
|
||||
/Android/i.test(env.userAgent) &&
|
||||
!env.standalone &&
|
||||
env.fullscreenEnabled &&
|
||||
!env.alreadyFullscreen;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Line } from 'folds';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
@@ -11,7 +11,6 @@ import { WidgetsPanel } from './widgets/WidgetsPanel';
|
||||
import { widgetsPanelAtom } from '../../state/widgetsPanel';
|
||||
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { useMediaQuery } from '../../hooks/useMediaQuery';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
@@ -21,10 +20,12 @@ import { markAsRead } from '../../utils/notifications';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
import { CallView } from '../call/CallView';
|
||||
import { LANDSCAPE_PHONE_QUERY, shouldGoImmersive } from '../call/landscapeCall';
|
||||
import { useMediaQuery } from '../../hooks/useMediaQuery';
|
||||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { callChatAtom, immersiveCallAtom } from '../../state/callEmbed';
|
||||
import { CallChatView } from './CallChatView';
|
||||
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
||||
import { useCallEmbed, useCallJoined } from '../../hooks/useCallEmbed';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||
import { threadsListAtom } from '../../state/threadsList';
|
||||
@@ -57,6 +58,25 @@ export function Room() {
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const members = useRoomMembers(mx, room.roomId);
|
||||
const chat = useAtomValue(callChatAtom);
|
||||
// [Gitea #126] Phone turned sideways in this room's call: full screen.
|
||||
const landscapePhone = useMediaQuery(LANDSCAPE_PHONE_QUERY);
|
||||
const joinedAnywhere = useCallJoined(callEmbed);
|
||||
const [immersiveDismissed, setImmersiveDismissed] = useState(false);
|
||||
useEffect(() => {
|
||||
// "Exit full screen" lasts until the phone is upright again.
|
||||
if (!landscapePhone) setImmersiveDismissed(false);
|
||||
}, [landscapePhone]);
|
||||
const setImmersiveCall = useSetAtom(immersiveCallAtom);
|
||||
const immersive = shouldGoImmersive({
|
||||
landscapePhone,
|
||||
joinedHere: joinedAnywhere && callEmbed?.roomId === room.roomId,
|
||||
chatOpen: chat,
|
||||
dismissed: immersiveDismissed,
|
||||
});
|
||||
useEffect(() => {
|
||||
setImmersiveCall(immersive);
|
||||
return () => setImmersiveCall(false);
|
||||
}, [immersive, setImmersiveCall]);
|
||||
// Hoisted here (instead of inside RoomTimeline) so ThreadPanel/ThreadTimeline —
|
||||
// rendered as siblings of RoomView, not descendants — share the same computed
|
||||
// positions instead of falling back to the context default (Gitea #38).
|
||||
@@ -170,7 +190,8 @@ export function Room() {
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
<ReadPositionsContext.Provider value={readPositions}>
|
||||
<Box grow="Yes">
|
||||
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
|
||||
{immersive && <CallView onExitImmersive={() => setImmersiveDismissed(true)} />}
|
||||
{!immersive && callView && (screenSize === ScreenSize.Desktop || !chat) && (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomViewHeader callView />
|
||||
<Box grow="Yes">
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
} from '../../../utils/lotusDenoiseUtils';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { useSettingsSyncActions } from '../../../hooks/useSettingsSync';
|
||||
import { YourDataSection } from './YourData';
|
||||
import {
|
||||
CallAudioBitrate,
|
||||
ChatBackground,
|
||||
@@ -3007,6 +3008,7 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<Privacy />
|
||||
<StorageUsage />
|
||||
<SettingsSyncSection />
|
||||
<YourDataSection />
|
||||
<Calls />
|
||||
<DesktopSettings />
|
||||
<AppUpdates />
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Method } from 'matrix-js-sdk';
|
||||
import { Box, Button, 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>
|
||||
<input
|
||||
type="radio"
|
||||
name="your-data-conflicts"
|
||||
checked={!preferFile}
|
||||
onChange={() => setPreferFile(false)}
|
||||
/>{' '}
|
||||
<Text as="span" size="T200">
|
||||
Keep mine
|
||||
</Text>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="your-data-conflicts"
|
||||
checked={preferFile}
|
||||
onChange={() => 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { IAuthData, MatrixError } from 'matrix-js-sdk';
|
||||
import { IAuthData, MatrixError, ValidatedAuthMetadata } from 'matrix-js-sdk';
|
||||
import { ILoginFlowsResponse } from 'matrix-js-sdk/lib/@types/auth';
|
||||
|
||||
export enum RegisterFlowStatus {
|
||||
@@ -44,6 +44,13 @@ export const parseRegisterErrResp = (matrixError: MatrixError): RegisterFlowsRes
|
||||
export type AuthFlows = {
|
||||
loginFlows: ILoginFlowsResponse;
|
||||
registerFlows: RegisterFlowsResponse;
|
||||
/**
|
||||
* The homeserver's OAuth 2.0 metadata (`/_matrix/client/v1/auth_metadata`),
|
||||
* when it uses native OAuth (MSC3861, e.g. matrix.org via MAS). The same
|
||||
* source the SDK uses to refresh tokens, so sign-in is only offered when
|
||||
* refresh will work too.
|
||||
*/
|
||||
authMetadata?: ValidatedAuthMetadata;
|
||||
};
|
||||
|
||||
const AuthFlowsContext = createContext<AuthFlows | null>(null);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { immersiveCallAtom } from '../state/callEmbed';
|
||||
import { useCallEmbed } from '../hooks/useCallEmbed';
|
||||
import { CallStatus } from '../features/call-status';
|
||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||
@@ -9,8 +11,10 @@ export function CallStatusRenderer() {
|
||||
const selectedRoom = useSelectedRoom();
|
||||
|
||||
const screenSize = useScreenSizeContext();
|
||||
// [Gitea #126] The full-screen call has its own controls.
|
||||
const immersive = useAtomValue(immersiveCallAtom);
|
||||
|
||||
if (!callEmbed) return null;
|
||||
if (!callEmbed || immersive) return null;
|
||||
|
||||
if (screenSize === ScreenSize.Mobile && callEmbed.roomId === selectedRoom) return null;
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { SSOLogin } from '../SSOLogin';
|
||||
import { TokenLogin } from './TokenLogin';
|
||||
import { OidcLogin } from './OidcLogin';
|
||||
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||
import { getOidcIssuer } from '../../../cs-api';
|
||||
import { OrDivider } from '../OrDivider';
|
||||
import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils';
|
||||
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
||||
@@ -41,7 +40,7 @@ export function Login() {
|
||||
const server = useAuthServer();
|
||||
const discovery = useAutoDiscoveryInfo();
|
||||
const { hashRouter } = useClientConfig();
|
||||
const { loginFlows } = useAuthFlows();
|
||||
const { loginFlows, authMetadata } = useAuthFlows();
|
||||
const [searchParams] = useSearchParams();
|
||||
const loginSearchParams = useLoginSearchParams(searchParams);
|
||||
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
|
||||
@@ -58,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">
|
||||
|
||||
@@ -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,7 +55,6 @@ 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';
|
||||
@@ -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 → Devices), 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>
|
||||
);
|
||||
}
|
||||
@@ -261,6 +261,11 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
this.emit(CALL_ERROR_CHANGED);
|
||||
}
|
||||
|
||||
/** [Gitea #121] Answer the fork's expected-device check: join or not. */
|
||||
public sendDeviceDecision(join: boolean): void {
|
||||
this.call.transport.send('io.lotus.device_decision', { join }).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Press the fork's Reconnect / Try again. */
|
||||
public reconnect(): void {
|
||||
this.call.transport.send('io.lotus.reconnect', {}).catch(() => undefined);
|
||||
|
||||
@@ -257,6 +257,9 @@ export class CallEmbed {
|
||||
// sends io.lotus.inject_audio (only on an explicit user click), so
|
||||
// arming it for every call is safe.
|
||||
lotusAudioInject: 'true',
|
||||
// [Gitea #121] Before joining, the fork checks that the mic / output the
|
||||
// user last picked are plugged in, and asks us (ExpectedDevicesPrompt).
|
||||
lotusDeviceCheck: 'true',
|
||||
});
|
||||
|
||||
if (denoiseMode === 'ml') {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const store = new Map<string, string>();
|
||||
(globalThis as { sessionStorage?: unknown }).sessionStorage = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
store.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
store.delete(k);
|
||||
},
|
||||
};
|
||||
const m = await import('./expectedDevices');
|
||||
|
||||
test('[#121] parses the fork messages defensively', () => {
|
||||
assert.deepEqual(
|
||||
m.parseDeviceMissing({
|
||||
missing: [
|
||||
{ kind: 'audioinput', expected: 'Arctis 7', fallback: 'Laptop Mic' },
|
||||
{ kind: 'audiooutput', expected: 'Arctis 7', fallback: null },
|
||||
{ kind: 'videoinput', expected: 'Cam' },
|
||||
{ kind: 'audioinput' },
|
||||
],
|
||||
}),
|
||||
[
|
||||
{ kind: 'audioinput', expected: 'Arctis 7', fallback: 'Laptop Mic' },
|
||||
{ kind: 'audiooutput', expected: 'Arctis 7', fallback: null },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(m.parseDeviceMissing(undefined), []);
|
||||
assert.deepEqual(m.parseDeviceMissing({ missing: 'x' }), []);
|
||||
assert.equal(m.parseDeviceLost({ kind: 'nope' }), undefined);
|
||||
});
|
||||
|
||||
test('[#121] wording', () => {
|
||||
assert.deepEqual(
|
||||
m.describeMissing({ kind: 'audioinput', expected: 'Arctis 7', fallback: 'Laptop Mic' }),
|
||||
{
|
||||
title: "Your usual microphone isn't connected",
|
||||
expected: 'Arctis 7',
|
||||
fallback: 'Laptop Mic',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
m.describeLost({ kind: 'audiooutput', expected: 'Arctis 7', fallback: null }),
|
||||
'Arctis 7 disconnected — now using the default output.',
|
||||
);
|
||||
});
|
||||
|
||||
test('[#121] "Join anyway" is remembered for the session, per device set', () => {
|
||||
store.clear();
|
||||
const a = [{ kind: 'audioinput' as const, expected: 'Arctis 7', fallback: 'Laptop Mic' }];
|
||||
const b = [{ kind: 'audiooutput' as const, expected: 'Arctis 7', fallback: null }];
|
||||
assert.equal(m.isAcceptedThisSession(a), false);
|
||||
m.acceptForSession(a);
|
||||
assert.equal(m.isAcceptedThisSession(a), true);
|
||||
assert.equal(m.isAcceptedThisSession(b), false, 'a different device still asks');
|
||||
assert.equal(m.isAcceptedThisSession([...a, ...b]), false, 'more missing still asks');
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* [Gitea #121] The fork's expected-device check. Before joining, EC reports
|
||||
* the user's usual mic / output (the one they last picked) as missing; we ask
|
||||
* "Join anyway" or "Cancel". In a call, losing it is a toast. Pure helpers.
|
||||
*/
|
||||
|
||||
export type DeviceKind = 'audioinput' | 'audiooutput';
|
||||
export type MissingDevice = { kind: DeviceKind; expected: string; fallback: string | null };
|
||||
|
||||
const isKind = (k: unknown): k is DeviceKind => k === 'audioinput' || k === 'audiooutput';
|
||||
|
||||
const parseOne = (v: unknown): MissingDevice | undefined => {
|
||||
if (!v || typeof v !== 'object') return undefined;
|
||||
const { kind, expected, fallback } = v as Record<string, unknown>;
|
||||
if (!isKind(kind) || typeof expected !== 'string' || !expected) return undefined;
|
||||
return { kind, expected, fallback: typeof fallback === 'string' && fallback ? fallback : null };
|
||||
};
|
||||
|
||||
/** `io.lotus.device_missing` → the missing devices (empty if malformed). */
|
||||
export const parseDeviceMissing = (data: unknown): MissingDevice[] => {
|
||||
const list = (data as { missing?: unknown } | undefined)?.missing;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.map(parseOne).filter((d): d is MissingDevice => !!d);
|
||||
};
|
||||
|
||||
/** `io.lotus.device_lost` → the device that went away. */
|
||||
export const parseDeviceLost = (data: unknown): MissingDevice | undefined => parseOne(data);
|
||||
|
||||
const what = (kind: DeviceKind): string => (kind === 'audioinput' ? 'microphone' : 'audio output');
|
||||
|
||||
/** "Your usual microphone **Arctis 7** isn't connected." lines + the fallback. */
|
||||
export const describeMissing = (
|
||||
d: MissingDevice,
|
||||
): { title: string; expected: string; fallback: string } => ({
|
||||
title: `Your usual ${what(d.kind)} isn't connected`,
|
||||
expected: d.expected,
|
||||
fallback:
|
||||
d.fallback ?? (d.kind === 'audioinput' ? 'the default microphone' : 'the default output'),
|
||||
});
|
||||
|
||||
export const describeLost = (d: MissingDevice): string =>
|
||||
`${d.expected} disconnected — now using ${
|
||||
d.fallback ?? (d.kind === 'audioinput' ? 'the default microphone' : 'the default output')
|
||||
}.`;
|
||||
|
||||
// "Join anyway" holds for the rest of the session for the same device(s).
|
||||
const ACCEPTED_KEY = 'lotus-device-check-accepted';
|
||||
const keyOf = (missing: MissingDevice[]): string =>
|
||||
missing
|
||||
.map((d) => `${d.kind}:${d.expected}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
|
||||
const readAccepted = (): string[] => {
|
||||
try {
|
||||
const v: unknown = JSON.parse(sessionStorage.getItem(ACCEPTED_KEY) ?? '[]');
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const isAcceptedThisSession = (missing: MissingDevice[]): boolean =>
|
||||
readAccepted().includes(keyOf(missing));
|
||||
|
||||
export const acceptForSession = (missing: MissingDevice[]): void => {
|
||||
try {
|
||||
const list = readAccepted();
|
||||
const k = keyOf(missing);
|
||||
if (!list.includes(k)) sessionStorage.setItem(ACCEPTED_KEY, JSON.stringify([...list, k]));
|
||||
} catch {
|
||||
// storage unavailable: ask again next time
|
||||
}
|
||||
};
|
||||
@@ -18,3 +18,6 @@ export const callEmbedAtom = atom<CallEmbed | undefined, [CallEmbed | undefined]
|
||||
);
|
||||
|
||||
export const callChatAtom = atom<boolean>(false);
|
||||
|
||||
/** [Gitea #126] The call is full screen (phone turned sideways). */
|
||||
export const immersiveCallAtom = atom<boolean>(false);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
+21
-13
@@ -3,11 +3,11 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
|
||||
import { cryptoCallbacks } from './secretStorageKeys';
|
||||
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
|
||||
import { getFallbackSession, removeFallbackSession, Session } from '../app/state/sessions';
|
||||
import { LotusOidcTokenRefresher } from './oidcTokenRefresher';
|
||||
import { revokeOidcTokens } from './oidcLogout';
|
||||
import { installRefreshGuard, OidcIdentity, persistRefreshedTokens } from './oidcTokenRefresher';
|
||||
import { pushSessionToSW } from '../sw-session';
|
||||
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
|
||||
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
|
||||
import { watchKeyUploads } from '../app/utils/keyUploadWatch';
|
||||
import { clearSoundboardClipCache } from '../app/utils/soundboardClips';
|
||||
import { clearSharedPayload } from '../swShare';
|
||||
|
||||
@@ -61,12 +61,19 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
|
||||
|
||||
const legacyCryptoStore = new IndexedDBCryptoStore(globalThis.indexedDB, 'crypto-store');
|
||||
|
||||
// OIDC/next-gen-auth sessions carry a refresh token; wire automatic refresh
|
||||
// (the client calls this reactively on a 401) and persist rotated tokens.
|
||||
const oidcRefresher =
|
||||
// OIDC/next-gen-auth sessions carry a refresh token. Since SDK 43 the client
|
||||
// refreshes them itself; we persist the rotated tokens and serialise
|
||||
// refreshes across tabs (oidcTokenRefresher).
|
||||
const oidcIdentity: OidcIdentity | undefined =
|
||||
session.refreshToken && session.oidc
|
||||
? new LotusOidcTokenRefresher(session.oidc, session.deviceId, session.userId, session.baseUrl)
|
||||
? {
|
||||
userId: session.userId,
|
||||
deviceId: session.deviceId,
|
||||
baseUrl: session.baseUrl,
|
||||
oidc: session.oidc,
|
||||
}
|
||||
: undefined;
|
||||
if (oidcIdentity) installRefreshGuard(oidcIdentity);
|
||||
|
||||
const mx = createClient({
|
||||
baseUrl: session.baseUrl,
|
||||
@@ -76,12 +83,16 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
|
||||
store: indexedDBStore,
|
||||
cryptoStore: legacyCryptoStore,
|
||||
deviceId: session.deviceId,
|
||||
// [Gitea #201] Observe /keys/upload so a diverged OTK store is reported to
|
||||
// the user (KeyUploadBanner) instead of retrying silently forever.
|
||||
fetchFn: watchKeyUploads((input, init) => globalThis.fetch(input, init)),
|
||||
timelineSupport: true,
|
||||
cryptoCallbacks: cryptoCallbacks as any,
|
||||
// SAS (emoji) + QR-code verification (show/scan/reciprocate).
|
||||
verificationMethods: ['m.sas.v1', 'm.qr_code.show.v1', 'm.qr_code.scan.v1', 'm.reciprocate.v1'],
|
||||
tokenRefreshFunction: oidcRefresher
|
||||
? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken)
|
||||
oauthClientId: oidcIdentity?.oidc.clientId,
|
||||
onTokenRefresh: oidcIdentity
|
||||
? (tokens) => persistRefreshedTokens(oidcIdentity, tokens)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
@@ -126,11 +137,8 @@ export const clearCacheAndReload = async (mx: MatrixClient) => {
|
||||
export const logoutClient = async (mx: MatrixClient) => {
|
||||
pushSessionToSW();
|
||||
mx.stopClient();
|
||||
// For OIDC sessions, revoke the tokens at the issuer too (best-effort).
|
||||
const session = getFallbackSession();
|
||||
if (session?.oidc) {
|
||||
await revokeOidcTokens(session);
|
||||
}
|
||||
// For OIDC sessions the SDK revokes the tokens at the auth server instead of
|
||||
// calling /logout (matrix-js-sdk 43+); best-effort either way.
|
||||
try {
|
||||
await mx.logout();
|
||||
} catch {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { discoverAndValidateOIDCIssuerWellKnown } from 'matrix-js-sdk';
|
||||
import { Session } from '../app/state/sessions';
|
||||
|
||||
/**
|
||||
* Best-effort revoke the OIDC access + refresh tokens at the issuer's revocation
|
||||
* endpoint during logout. Tolerant of any failure — logout proceeds regardless
|
||||
* (the local session is cleared by the caller either way).
|
||||
*/
|
||||
export const revokeOidcTokens = async (session: Session): Promise<void> => {
|
||||
if (!session.oidc) return;
|
||||
try {
|
||||
const config = await discoverAndValidateOIDCIssuerWellKnown(session.oidc.issuer);
|
||||
const endpoint = config.revocation_endpoint;
|
||||
if (!endpoint) return;
|
||||
const { clientId } = session.oidc;
|
||||
const revoke = (token: string, hint: string): Promise<Response> =>
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ token, token_type_hint: hint, client_id: clientId }),
|
||||
});
|
||||
const requests: Promise<Response>[] = [];
|
||||
if (session.refreshToken) requests.push(revoke(session.refreshToken, 'refresh_token'));
|
||||
if (session.accessToken) requests.push(revoke(session.accessToken, 'access_token'));
|
||||
await Promise.allSettled(requests);
|
||||
} catch {
|
||||
/* issuer unreachable / no revocation endpoint — ignore */
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
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('../app/state/sessions');
|
||||
const { guardRefreshFetch, refreshGrantToken } = await import('./oidcTokenRefresher');
|
||||
|
||||
const id = {
|
||||
userId: '@a:hs.example',
|
||||
deviceId: 'DEV1',
|
||||
baseUrl: 'https://hs.example',
|
||||
oidc: { issuer: 'https://auth.example/', clientId: 'c1', redirectUri: 'https://app/cb' },
|
||||
};
|
||||
const grant = (refresh: string): RequestInit => {
|
||||
const body = new URLSearchParams();
|
||||
body.append('grant_type', 'refresh_token');
|
||||
body.append('client_id', 'c1');
|
||||
body.append('refresh_token', refresh);
|
||||
return { method: 'POST', body };
|
||||
};
|
||||
const persist = (access: string, refresh: string) =>
|
||||
sessions.setFallbackSession(access, id.deviceId, id.userId, id.baseUrl, {
|
||||
refreshToken: refresh,
|
||||
oidc: id.oidc,
|
||||
expiresInMs: 300_000,
|
||||
});
|
||||
|
||||
test('[#16] recognises only refresh-token grants', () => {
|
||||
assert.equal(refreshGrantToken(grant('r1')), 'r1');
|
||||
assert.equal(
|
||||
refreshGrantToken({ method: 'POST', body: 'grant_type=refresh_token&refresh_token=r2' }),
|
||||
'r2',
|
||||
);
|
||||
assert.equal(
|
||||
refreshGrantToken({ method: 'POST', body: 'grant_type=authorization_code&code=x' }),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(refreshGrantToken({ method: 'GET' }), undefined);
|
||||
assert.equal(refreshGrantToken(undefined), undefined);
|
||||
});
|
||||
|
||||
test('[#16] a real refresh is persisted before the lock is released', async () => {
|
||||
store.clear();
|
||||
persist('at1', 'r1');
|
||||
let calls = 0;
|
||||
const inner: typeof fetch = async () => {
|
||||
calls += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
token_type: 'Bearer',
|
||||
access_token: 'at2',
|
||||
refresh_token: 'r2',
|
||||
expires_in: 300,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
};
|
||||
const res = await guardRefreshFetch(inner, id)('https://auth.example/token', grant('r1'));
|
||||
assert.equal(calls, 1);
|
||||
assert.equal((await res.json()).access_token, 'at2', 'the SDK still reads the body');
|
||||
const s = sessions.getFallbackSession();
|
||||
assert.equal(s?.accessToken, 'at2');
|
||||
assert.equal(s?.refreshToken, 'r2');
|
||||
assert.ok((s?.expiresInMs ?? 0) > 290_000);
|
||||
});
|
||||
|
||||
test('[#16] the losing tab adopts the winner instead of spending a used token', async () => {
|
||||
store.clear();
|
||||
persist('at-winner', 'r-winner'); // a sibling tab already rotated r-old
|
||||
let calls = 0;
|
||||
const inner: typeof fetch = async () => {
|
||||
calls += 1;
|
||||
return new Response('{"error":"invalid_grant"}', { status: 400 });
|
||||
};
|
||||
const res = await guardRefreshFetch(inner, id)('https://auth.example/token', grant('r-old'));
|
||||
assert.equal(calls, 0, 'never hits the issuer');
|
||||
const body = await res.json();
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(body.token_type, 'Bearer');
|
||||
assert.equal(body.access_token, 'at-winner');
|
||||
assert.equal(body.refresh_token, 'r-winner');
|
||||
});
|
||||
|
||||
test('[#16] another account in storage is never adopted', async () => {
|
||||
store.clear();
|
||||
sessions.setFallbackSession('at-other', 'DEV9', '@b:hs.example', id.baseUrl, {
|
||||
refreshToken: 'r-other',
|
||||
});
|
||||
let calls = 0;
|
||||
const inner: typeof fetch = async () => {
|
||||
calls += 1;
|
||||
return new Response(JSON.stringify({ token_type: 'Bearer', access_token: 'at2' }), {
|
||||
status: 200,
|
||||
});
|
||||
};
|
||||
await guardRefreshFetch(inner, id)('https://auth.example/token', grant('r1'));
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('[#16] other requests pass straight through', async () => {
|
||||
let seen = '';
|
||||
const inner: typeof fetch = async (input) => {
|
||||
seen = String(input);
|
||||
return new Response('{}');
|
||||
};
|
||||
await guardRefreshFetch(inner, id)('https://hs.example/_matrix/client/v3/sync');
|
||||
assert.equal(seen, 'https://hs.example/_matrix/client/v3/sync');
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { AccessTokens, OidcTokenRefresher } from 'matrix-js-sdk';
|
||||
import type { IdTokenClaims } from 'oidc-client-ts';
|
||||
import type { AccessTokens } from 'matrix-js-sdk';
|
||||
import { getFallbackSession, OidcSessionMeta, setFallbackSession } from '../app/state/sessions';
|
||||
|
||||
// Since matrix-js-sdk 43 the SDK refreshes OAuth tokens itself (MatrixClient
|
||||
// `oauthClientId` + `onTokenRefresh`). This module keeps the two things Lotus
|
||||
// added on top: rotated tokens are persisted (F5), and refreshes are
|
||||
// serialised across tabs (Gitea #16).
|
||||
|
||||
// Web Lock name serialising OIDC refreshes across tabs (Gitea #16). Every tab
|
||||
// runs its own refresher against the SAME stored refresh token; with rotating
|
||||
// runs its own client against the SAME stored refresh token; with rotating
|
||||
// refresh tokens the second tab to hit the issuer gets `invalid_grant` and is
|
||||
// signed out. Holding the lock while refreshing (and re-reading storage once
|
||||
// inside it) makes the loser adopt the winner's tokens instead.
|
||||
@@ -19,82 +23,106 @@ export const withRefreshLock = <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
return locks.request(REFRESH_LOCK_NAME, fn);
|
||||
};
|
||||
|
||||
export type OidcIdentity = {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
baseUrl: string;
|
||||
oidc: OidcSessionMeta;
|
||||
};
|
||||
|
||||
/** F5 — keep the stored session (and its expiry) in step with every refresh. */
|
||||
export const persistRefreshedTokens = (id: OidcIdentity, tokens: AccessTokens): void => {
|
||||
const expiresInMs =
|
||||
tokens.expiry instanceof Date ? Math.max(0, tokens.expiry.getTime() - Date.now()) : undefined;
|
||||
setFallbackSession(tokens.accessToken, id.deviceId, id.userId, id.baseUrl, {
|
||||
refreshToken: tokens.refreshToken,
|
||||
oidc: id.oidc,
|
||||
expiresInMs,
|
||||
});
|
||||
};
|
||||
|
||||
/** The refresh token a request is spending, if it is an OAuth refresh grant. */
|
||||
export const refreshGrantToken = (init?: RequestInit): string | undefined => {
|
||||
if (!init || (init.method ?? 'GET').toUpperCase() !== 'POST') return undefined;
|
||||
const { body } = init;
|
||||
let params: URLSearchParams | undefined;
|
||||
if (body instanceof URLSearchParams) params = body;
|
||||
else if (typeof body === 'string') params = new URLSearchParams(body);
|
||||
if (params?.get('grant_type') !== 'refresh_token') return undefined;
|
||||
return params.get('refresh_token') ?? undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* OidcTokenRefresher that persists rotated tokens back to the fallback session,
|
||||
* so a page reload keeps the freshest access/refresh token. The matrix client
|
||||
* calls this automatically (reactively on a 401) when a refresh token is set.
|
||||
* #16 — wrap `fetch` so OAuth refresh grants run one at a time across tabs.
|
||||
* Inside the lock: if storage already holds a DIFFERENT refresh token for this
|
||||
* user + device, a sibling tab won the race, so answer with its tokens (a
|
||||
* well-formed token response) instead of spending a consumed refresh token.
|
||||
* Comparing refresh tokens (not access tokens) can never adopt the token that
|
||||
* just failed, so this cannot loop. A real refresh is persisted BEFORE the lock
|
||||
* is released, so the next tab in line sees it. Every other request passes
|
||||
* straight through.
|
||||
*/
|
||||
export class LotusOidcTokenRefresher extends OidcTokenRefresher {
|
||||
private readonly deviceIdRef: string;
|
||||
|
||||
private readonly userIdRef: string;
|
||||
|
||||
private readonly baseUrlRef: string;
|
||||
|
||||
private readonly oidcRef: OidcSessionMeta;
|
||||
|
||||
constructor(oidc: OidcSessionMeta, deviceId: string, userId: string, baseUrl: string) {
|
||||
super(
|
||||
oidc.issuer,
|
||||
oidc.clientId,
|
||||
oidc.redirectUri,
|
||||
deviceId,
|
||||
(oidc.idTokenClaims ?? {}) as unknown as IdTokenClaims,
|
||||
);
|
||||
this.deviceIdRef = deviceId;
|
||||
this.userIdRef = userId;
|
||||
this.baseUrlRef = baseUrl;
|
||||
this.oidcRef = oidc;
|
||||
}
|
||||
|
||||
// #16 — before touching the issuer, check whether another tab already rotated
|
||||
// the tokens. `refreshToken` is exactly what the SDK currently holds, so a
|
||||
// DIFFERENT stored refresh token (same user + device) means a sibling tab won
|
||||
// the race: adopt its tokens instead of burning a possibly-consumed refresh
|
||||
// token. Comparing refresh tokens (not access tokens) can never adopt the very
|
||||
// token that just 401'd, so this cannot loop. The whole thing runs under a
|
||||
// cross-tab Web Lock so concurrent refreshes serialise and the waiter sees
|
||||
// the winner's write.
|
||||
public doRefreshAccessToken(refreshToken: string): Promise<AccessTokens> {
|
||||
export const guardRefreshFetch =
|
||||
(inner: typeof fetch, id: OidcIdentity): typeof fetch =>
|
||||
(input, init) => {
|
||||
const spending = refreshGrantToken(init);
|
||||
if (!spending) return inner(input, init);
|
||||
return withRefreshLock(async () => {
|
||||
const stored = getFallbackSession();
|
||||
if (
|
||||
stored &&
|
||||
stored.userId === this.userIdRef &&
|
||||
stored.deviceId === this.deviceIdRef &&
|
||||
stored.userId === id.userId &&
|
||||
stored.deviceId === id.deviceId &&
|
||||
stored.refreshToken &&
|
||||
stored.refreshToken !== refreshToken
|
||||
stored.refreshToken !== spending
|
||||
) {
|
||||
return {
|
||||
accessToken: stored.accessToken,
|
||||
refreshToken: stored.refreshToken,
|
||||
expiry:
|
||||
typeof stored.expiresInMs === 'number'
|
||||
? new Date(Date.now() + stored.expiresInMs)
|
||||
: undefined,
|
||||
};
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
token_type: 'Bearer',
|
||||
access_token: stored.accessToken,
|
||||
refresh_token: stored.refreshToken,
|
||||
...(typeof stored.expiresInMs === 'number'
|
||||
? { expires_in: Math.floor(stored.expiresInMs / 1000) }
|
||||
: {}),
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
return super.doRefreshAccessToken(refreshToken);
|
||||
const started = Date.now();
|
||||
const res = await inner(input, init);
|
||||
if (res.ok) {
|
||||
try {
|
||||
const body = (await res.clone().json()) as {
|
||||
access_token?: unknown;
|
||||
refresh_token?: unknown;
|
||||
expires_in?: unknown;
|
||||
};
|
||||
if (typeof body.access_token === 'string') {
|
||||
persistRefreshedTokens(id, {
|
||||
accessToken: body.access_token,
|
||||
refreshToken: typeof body.refresh_token === 'string' ? body.refresh_token : undefined,
|
||||
expiry:
|
||||
typeof body.expires_in === 'number'
|
||||
? new Date(started + body.expires_in * 1000)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// unreadable body: the SDK will report the failure itself
|
||||
}
|
||||
}
|
||||
return res;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// F5 — persist the new expiry so the stored `expiresAt` stays fresh across
|
||||
// reloads instead of going stale. The SDK invokes persistTokens synchronously
|
||||
// inside the refresh and passes the freshly-refreshed `expiry` (a Date) on the
|
||||
// tokens object at runtime, even though its published type omits it — so read
|
||||
// it here directly (a doRefreshAccessToken override would run too late, since
|
||||
// persistTokens is called before that method returns).
|
||||
protected async persistTokens(tokens: {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiry?: Date;
|
||||
}): Promise<void> {
|
||||
const expiresInMs =
|
||||
tokens.expiry instanceof Date ? Math.max(0, tokens.expiry.getTime() - Date.now()) : undefined;
|
||||
setFallbackSession(tokens.accessToken, this.deviceIdRef, this.userIdRef, this.baseUrlRef, {
|
||||
refreshToken: tokens.refreshToken,
|
||||
oidc: this.oidcRef,
|
||||
expiresInMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
let unguardedFetch: typeof fetch | undefined;
|
||||
|
||||
/**
|
||||
* Install the #16 guard on `globalThis.fetch`. The SDK's OAuth calls use the
|
||||
* global fetch directly (not the client's `fetchFn`), so this is the only
|
||||
* place to catch them. Idempotent: re-installing replaces the guard.
|
||||
*/
|
||||
export const installRefreshGuard = (id: OidcIdentity): void => {
|
||||
unguardedFetch ??= globalThis.fetch.bind(globalThis);
|
||||
globalThis.fetch = guardRefreshFetch(unguardedFetch, id);
|
||||
};
|
||||
|
||||
+15
-3
@@ -29,6 +29,17 @@ const copyFiles = {
|
||||
dest: 'public/element-call',
|
||||
rename: { stripBase: 4 },
|
||||
},
|
||||
{
|
||||
// [Gitea #201] Element Call's config. Since v0.28 the embedded package
|
||||
// ships none (its defaults are an 18 s delayed leave), so ship the Lotus
|
||||
// values: keep the call membership 60 s through a homeserver stall
|
||||
// (element-call lotus #46). On the web the deploy never overwrites the
|
||||
// server's own public/element-call/config.json (rsync excludes it); this
|
||||
// copy is what the desktop app and fresh installs get.
|
||||
src: 'build/element-call/config.json',
|
||||
dest: 'public/element-call',
|
||||
rename: { stripBase: 2 },
|
||||
},
|
||||
{
|
||||
src: 'config.json',
|
||||
dest: '',
|
||||
@@ -327,9 +338,10 @@ export default defineConfig({
|
||||
// Assets are content-hashed, so the filename is the cache key — don't
|
||||
// append a revision cache-busting param.
|
||||
dontCacheBustURLsMatching: /assets\//,
|
||||
// Raised above the 2 MB default so the ~5.5 MB matrix-sdk crypto wasm
|
||||
// (hash-busted and hot on every session) is precached deliberately.
|
||||
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
|
||||
// Raised above the 2 MB default so the matrix-sdk crypto wasm (~7.8 MB
|
||||
// since crypto-wasm 18.9 / matrix-js-sdk 44; hash-busted and hot on every
|
||||
// session) is precached deliberately.
|
||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||
// codeSplitting: false is not yet supported by vite-plugin-pwa 1.3.0;
|
||||
// the inlineDynamicImports deprecation warning from Vite is from pwa internal build
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user