Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99b0cf9c47 | ||
|
|
1b561ca1ca | ||
|
|
53cceaa66e | ||
|
|
8794465034 | ||
|
|
ace084c91c | ||
|
|
c73a3f8b31 | ||
|
|
504899aa14 | ||
|
|
7d31b2b2f7 | ||
|
|
61d2433721 | ||
|
|
78b19f1ced | ||
|
|
2f5bcf2b91 | ||
|
|
d1a9625b1c | ||
|
|
9ab01ff6f6 | ||
|
|
d2c3c56b18 | ||
|
|
e2ce177930 | ||
|
|
1ebc42863e | ||
|
|
58f7b18746 | ||
|
|
43445db5cc | ||
|
|
3c33169297 | ||
|
|
3163829c57 | ||
|
|
2264c79833 | ||
|
|
2612f71c57 | ||
|
|
e8a04fee20 | ||
|
|
843a48dd10 | ||
|
|
6d86d25b7f | ||
|
|
56af9520bd | ||
|
|
c247024bcd | ||
|
|
29ece6a0c8 | ||
|
|
f7f6059b33 | ||
|
|
71b7619082 | ||
|
|
e4591f669b | ||
|
|
185970bc5a | ||
|
|
d5b0b40b17 | ||
|
|
8cdf192b56 | ||
|
|
222927b558 | ||
|
|
b944456ab5 | ||
|
|
02bd356a89 | ||
|
|
3b3bf8abac | ||
|
|
22d5aea5e6 | ||
|
|
826fc46229 | ||
|
|
b93e4bcef9 | ||
|
|
1af491cabb | ||
|
|
449d0c740e | ||
|
|
a7495fc509 | ||
|
|
299da879fb | ||
|
|
f8fa1ca5ed | ||
|
|
5d15013a27 | ||
|
|
378b40fabe | ||
|
|
0d4f2e4a87 | ||
|
|
c907b0ba99 | ||
|
|
02c852e194 | ||
|
|
6e6213a43b | ||
|
|
5379eb046d | ||
|
|
17ebddb159 | ||
|
|
7bd1c14a3c | ||
|
|
a58684958c | ||
|
|
3c3bb2e41c | ||
|
|
88446e8953 | ||
|
|
f6bad9eb58 | ||
|
|
93c7dcd749 | ||
|
|
43e166b879 | ||
|
|
70206d6033 | ||
|
|
bfed37bf50 | ||
|
|
c2c43eda99 | ||
|
|
45c3b16694 | ||
|
|
5f3c5d370a | ||
|
|
4120ccdbcc | ||
|
|
ed5bf997b4 | ||
|
|
878a34712a | ||
|
|
62672787a0 | ||
|
|
0746e80941 | ||
|
|
6300e4170e | ||
|
|
7250322977 | ||
|
|
ac1a1c9a48 | ||
|
|
8645a55769 | ||
|
|
8784a8158c | ||
|
|
f920fd82fb | ||
|
|
42040b6412 |
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"matrix_rtc_session": {
|
||||||
|
"delayed_leave": { "delay_ms": 60000, "restart_ms": 15000 }
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -16,7 +16,8 @@
|
|||||||
"statusPages": {
|
"statusPages": {
|
||||||
"matrix.lotusguild.org": {
|
"matrix.lotusguild.org": {
|
||||||
"url": "https://isitup.lotusguild.org",
|
"url": "https://isitup.lotusguild.org",
|
||||||
"slug": "matrix",
|
"slug": "public",
|
||||||
|
"noticePrefix": "Matrix:",
|
||||||
"groups": {
|
"groups": {
|
||||||
"homeserver": "Homeserver",
|
"homeserver": "Homeserver",
|
||||||
"calls": "Voice calls",
|
"calls": "Voice calls",
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
- navigation "Settings sections":
|
- navigation "Settings sections":
|
||||||
- button "General" [pressed]:
|
- button "Account" [pressed]:
|
||||||
- img
|
|
||||||
- paragraph: General
|
|
||||||
- button "Account":
|
|
||||||
- img
|
- img
|
||||||
- paragraph: Account
|
- paragraph: Account
|
||||||
|
- button "Appearance":
|
||||||
|
- img
|
||||||
|
- paragraph: Appearance
|
||||||
|
- button "Messages":
|
||||||
|
- img
|
||||||
|
- paragraph: Messages
|
||||||
- button "Notifications":
|
- button "Notifications":
|
||||||
- img
|
- img
|
||||||
- paragraph: Notifications
|
- paragraph: Notifications
|
||||||
- button "Devices":
|
- button "Calls & audio":
|
||||||
- img
|
- img
|
||||||
- paragraph: Devices
|
- paragraph: Calls & audio
|
||||||
- button "Emojis & Stickers":
|
- button "Privacy & security":
|
||||||
- img
|
- img
|
||||||
- paragraph: Emojis & Stickers
|
- paragraph: Privacy & security
|
||||||
- button "Developer Tools":
|
- button "Data & storage":
|
||||||
- img
|
- img
|
||||||
- paragraph: Developer Tools
|
- paragraph: Data & storage
|
||||||
|
- button "Emojis & stickers":
|
||||||
|
- img
|
||||||
|
- paragraph: Emojis & stickers
|
||||||
|
- button "Advanced":
|
||||||
|
- img
|
||||||
|
- paragraph: Advanced
|
||||||
- button "About":
|
- button "About":
|
||||||
- img
|
- img
|
||||||
- paragraph: About
|
- paragraph: About
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { ensureUser, hsReachable, loginUI, uniq } from './localHs';
|
||||||
|
|
||||||
|
// [Gitea #201 KE-1] When the server keeps rejecting this device's key uploads
|
||||||
|
// with "One time key … already exists", the app says so and offers the fix.
|
||||||
|
const CONFLICT = {
|
||||||
|
errcode: 'M_UNKNOWN',
|
||||||
|
error: 'One time key signed_curve25519:AAAAAAAAAA0 already exists. Old key: {}; new key: {}',
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('key upload recovery (#201)', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a diverged device sees the banner, and Fix opens the logout dialog', async ({ page }) => {
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
const user = await ensureUser(uniq('kup'));
|
||||||
|
let rejected = 0;
|
||||||
|
await page.route(/\/_matrix\/client\/v3\/keys\/upload/, async (route) => {
|
||||||
|
rejected += 1;
|
||||||
|
await route.fulfill({ status: 400, json: CONFLICT });
|
||||||
|
});
|
||||||
|
await loginUI(page, user);
|
||||||
|
|
||||||
|
const banner = page
|
||||||
|
.getByRole('alert')
|
||||||
|
.filter({ hasText: /Encryption on this device is out of sync/ });
|
||||||
|
await expect(banner).toBeVisible({ timeout: 200_000 });
|
||||||
|
console.log(`key uploads rejected: ${rejected}`);
|
||||||
|
expect(rejected).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
|
await banner.getByRole('button', { name: 'Details' }).click();
|
||||||
|
await expect(banner).toContainText('Settings → Privacy & security');
|
||||||
|
await expect(banner).toContainText('already exists');
|
||||||
|
|
||||||
|
await banner.getByRole('button', { name: /Fix: sign out/ }).click();
|
||||||
|
await expect(page.getByRole('dialog').getByText('Logout').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a healthy device never sees it', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const user = await ensureUser(uniq('kok'));
|
||||||
|
await loginUI(page, user);
|
||||||
|
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
await expect(page.getByText(/Encryption on this device is out of sync/)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -109,9 +109,17 @@ test.describe('local homeserver regression', () => {
|
|||||||
);
|
);
|
||||||
await loginUI(page, alice);
|
await loginUI(page, alice);
|
||||||
await openRoom(page, room);
|
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.move(600, 350);
|
||||||
|
await expect(async () => {
|
||||||
await page.mouse.wheel(0, -600);
|
await page.mouse.wheel(0, -600);
|
||||||
await expect(page.getByRole('button', { name: /Jump to Latest/ })).toBeVisible();
|
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.locator('[data-slate-editor]').first().click();
|
||||||
await page.keyboard.type('sent while scrolled up');
|
await page.keyboard.type('sent while scrolled up');
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
@@ -407,6 +415,95 @@ test.describe('local homeserver regression', () => {
|
|||||||
.toBe(status);
|
.toBe(status);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('status: applies at once, quick changes fold into one write (slow-save report)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let lastOk = 0;
|
||||||
|
const sent: string[] = [];
|
||||||
|
await page.route(/\/presence\/[^/]+\/status/, (route) => {
|
||||||
|
if (route.request().method() !== 'PUT') return route.continue();
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastOk < 10_000) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 429,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
errcode: 'M_LIMIT_EXCEEDED',
|
||||||
|
retry_after_ms: 10_000 - (now - lastOk),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lastOk = now;
|
||||||
|
sent.push(JSON.parse(route.request().postData() ?? '{}').status_msg ?? '');
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
await loginUI(page, alice);
|
||||||
|
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||||
|
await page.getByText('Account', { exact: true }).first().click();
|
||||||
|
const input = page.getByLabel('Status message');
|
||||||
|
const save = input.locator('xpath=ancestor::form[1]').getByRole('button', { name: 'Save' });
|
||||||
|
|
||||||
|
// Use up the 10 s budget, then pick three quick statuses in a row.
|
||||||
|
await input.fill(uniq('first '));
|
||||||
|
await save.click();
|
||||||
|
await expect(save).toBeDisabled(); // applied: nothing left to save, no spinner wait
|
||||||
|
const quick = page.getByRole('group', { name: 'Quick statuses' });
|
||||||
|
await quick.getByRole('button', { name: /Lunch/ }).click();
|
||||||
|
await quick.getByRole('button', { name: /Watching/ }).click();
|
||||||
|
await quick.getByRole('button', { name: /AFK/ }).click();
|
||||||
|
await expect(input).toHaveValue('😴 AFK');
|
||||||
|
await expect(save).toBeDisabled();
|
||||||
|
await expect(page.getByText(/Status set\. Sharing it with others/)).toBeVisible();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(
|
||||||
|
await api<{ status_msg?: string }>(
|
||||||
|
'GET',
|
||||||
|
`/_matrix/client/v3/presence/${enc(alice.userId)}/status`,
|
||||||
|
alice.token,
|
||||||
|
)
|
||||||
|
).status_msg,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe('😴 AFK');
|
||||||
|
// The middle choices were superseded while waiting, never sent.
|
||||||
|
expect(sent.filter((m) => /Lunch|Watching/.test(m))).toEqual([]);
|
||||||
|
await expect(page.getByText(/Status set\. Sharing it with others/)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('own status shows in the members drawer without switching rooms, and clears', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// The SDK creates our own User with `new User()` (no event re-emit), so the
|
||||||
|
// shared presence store missed our own presence until a remount. Reported:
|
||||||
|
// a new status only appeared after switching rooms.
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
const me = await ensureUser(uniq('own'));
|
||||||
|
const room = await createRoom(me, 'Own Status Room');
|
||||||
|
await sendText(me, room, 'hello');
|
||||||
|
await loginUI(page, me);
|
||||||
|
await openRoom(page, room);
|
||||||
|
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||||
|
await page.getByRole('dialog').first().getByText('Account', { exact: true }).first().click();
|
||||||
|
await page
|
||||||
|
.getByRole('group', { name: 'Quick statuses' })
|
||||||
|
.getByRole('button', { name: /Gaming/ })
|
||||||
|
.click();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.getByText('🎮 Gaming')).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||||
|
await page.getByRole('dialog').first().getByText('Account', { exact: true }).first().click();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /^Clear/ })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.getByText('🎮 Gaming')).toHaveCount(0, { timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
test('touch: long-press opens the message action sheet (#166)', async ({ browser }) => {
|
test('touch: long-press opens the message action sheet (#166)', async ({ browser }) => {
|
||||||
const ctx = await browser.newContext({ ...devices['Pixel 7'] });
|
const ctx = await browser.newContext({ ...devices['Pixel 7'] });
|
||||||
const page = await ctx.newPage();
|
const page = await ctx.newPage();
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { test, expect, Page, Route, BrowserContext } from '@playwright/test';
|
||||||
|
import { HS, PASSWORD, api, enc, ensureUser, hsReachable, uniq } from './localHs';
|
||||||
|
|
||||||
|
// Native OAuth sign-in on matrix-js-sdk 44 (Gitea #201 SDK upgrade): login,
|
||||||
|
// refresh on an expired token, the #16 two-tab refresh race, and logout
|
||||||
|
// revocation. The homeserver is the real local Synapse; the auth server is
|
||||||
|
// faked here and hands out REAL Synapse tokens for the device the client asked
|
||||||
|
// for, so sync works after every step. "Expiry" is simulated by answering
|
||||||
|
// M_UNKNOWN_TOKEN for a token we mark expired.
|
||||||
|
const AUTH = 'https://auth.fake.test';
|
||||||
|
|
||||||
|
type Fake = {
|
||||||
|
prompts: (string | null)[];
|
||||||
|
registered: number;
|
||||||
|
tokenCalls: { grant: string; refresh?: string }[];
|
||||||
|
revoked: string[];
|
||||||
|
expired: Set<string>;
|
||||||
|
issuedAccess: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const b64url = (buf: Buffer) =>
|
||||||
|
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
|
||||||
|
async function synapseToken(localpart: string, deviceId: string): Promise<string> {
|
||||||
|
const res = await api<{ access_token: string }>('POST', '/_matrix/client/v3/login', undefined, {
|
||||||
|
type: 'm.login.password',
|
||||||
|
identifier: { type: 'm.id.user', user: localpart },
|
||||||
|
password: PASSWORD,
|
||||||
|
device_id: deviceId,
|
||||||
|
});
|
||||||
|
return res.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fakeOAuth(context: BrowserContext, localpart: string): Promise<Fake> {
|
||||||
|
const fake: Fake = {
|
||||||
|
prompts: [],
|
||||||
|
registered: 0,
|
||||||
|
tokenCalls: [],
|
||||||
|
revoked: [],
|
||||||
|
expired: new Set(),
|
||||||
|
issuedAccess: [],
|
||||||
|
};
|
||||||
|
const codes = new Map<string, { challenge: string; deviceId: string }>();
|
||||||
|
const refreshes = new Map<string, string>(); // live refresh token -> deviceId (rotating)
|
||||||
|
let n = 0;
|
||||||
|
const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' };
|
||||||
|
|
||||||
|
const issue = async (deviceId: string) => {
|
||||||
|
n += 1;
|
||||||
|
const access = await synapseToken(localpart, deviceId);
|
||||||
|
const refresh = `r${n}`;
|
||||||
|
refreshes.set(refresh, deviceId);
|
||||||
|
fake.issuedAccess.push(access);
|
||||||
|
return { token_type: 'Bearer', access_token: access, refresh_token: refresh, expires_in: 300 };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Homeserver: advertise the issuer, serve auth metadata, and "expire" tokens.
|
||||||
|
await context.route(`${HS}/.well-known/matrix/client`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
headers: cors,
|
||||||
|
json: { 'm.homeserver': { base_url: HS }, 'm.authentication': { issuer: `${AUTH}/` } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await context.route(`${HS}/_matrix/client/v1/auth_metadata`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
headers: cors,
|
||||||
|
json: {
|
||||||
|
issuer: `${AUTH}/`,
|
||||||
|
authorization_endpoint: `${AUTH}/authorize`,
|
||||||
|
token_endpoint: `${AUTH}/token`,
|
||||||
|
registration_endpoint: `${AUTH}/register`,
|
||||||
|
revocation_endpoint: `${AUTH}/revoke`,
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
response_modes_supported: ['query', 'fragment'],
|
||||||
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||||
|
code_challenge_methods_supported: ['S256'],
|
||||||
|
prompt_values_supported: ['create'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await context.route(`${HS}/_matrix/**`, async (route: Route) => {
|
||||||
|
const auth = route.request().headers().authorization ?? '';
|
||||||
|
const token = auth.replace(/^Bearer /, '');
|
||||||
|
if (token && fake.expired.has(token)) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
headers: cors,
|
||||||
|
json: { errcode: 'M_UNKNOWN_TOKEN', error: 'Token expired', soft_logout: true },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth server.
|
||||||
|
await context.route(`${AUTH}/**`, async (route: Route) => {
|
||||||
|
const req = route.request();
|
||||||
|
const url = new URL(req.url());
|
||||||
|
if (req.method() === 'OPTIONS') {
|
||||||
|
await route.fulfill({ status: 204, headers: cors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/register') {
|
||||||
|
fake.registered += 1;
|
||||||
|
await route.fulfill({ headers: cors, json: { client_id: 'fake-client' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/authorize') {
|
||||||
|
const deviceId = /urn:matrix:client:device:(\S+)/.exec(
|
||||||
|
url.searchParams.get('scope') ?? '',
|
||||||
|
)?.[1];
|
||||||
|
fake.prompts.push(url.searchParams.get('prompt'));
|
||||||
|
expect(url.searchParams.get('response_mode')).toBe('query');
|
||||||
|
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
||||||
|
const code = `code-${Date.now()}`;
|
||||||
|
codes.set(code, { challenge: url.searchParams.get('code_challenge')!, deviceId: deviceId! });
|
||||||
|
const back = new URL(url.searchParams.get('redirect_uri')!);
|
||||||
|
back.searchParams.set('code', code);
|
||||||
|
back.searchParams.set('state', url.searchParams.get('state')!);
|
||||||
|
await route.fulfill({ status: 302, headers: { location: back.href } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = new URLSearchParams(req.postData() ?? '');
|
||||||
|
if (url.pathname === '/token') {
|
||||||
|
const grant = form.get('grant_type') ?? '';
|
||||||
|
fake.tokenCalls.push({ grant, refresh: form.get('refresh_token') ?? undefined });
|
||||||
|
if (grant === 'authorization_code') {
|
||||||
|
const pending = codes.get(form.get('code') ?? '');
|
||||||
|
const verifier = form.get('code_verifier') ?? '';
|
||||||
|
if (
|
||||||
|
!pending ||
|
||||||
|
b64url(createHash('sha256').update(verifier).digest()) !== pending.challenge
|
||||||
|
) {
|
||||||
|
await route.fulfill({ status: 400, headers: cors, json: { error: 'invalid_grant' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
codes.delete(form.get('code')!);
|
||||||
|
await route.fulfill({ headers: cors, json: await issue(pending.deviceId) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (grant === 'refresh_token') {
|
||||||
|
const deviceId = refreshes.get(form.get('refresh_token') ?? '');
|
||||||
|
if (!deviceId) {
|
||||||
|
// rotating refresh tokens: a used one is dead
|
||||||
|
await route.fulfill({ status: 400, headers: cors, json: { error: 'invalid_grant' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refreshes.delete(form.get('refresh_token')!);
|
||||||
|
await route.fulfill({ headers: cors, json: await issue(deviceId) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url.pathname === '/revoke') {
|
||||||
|
fake.revoked.push(form.get('token') ?? '');
|
||||||
|
await route.fulfill({ status: 200, headers: cors, body: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({ status: 404, headers: cors, body: '' });
|
||||||
|
});
|
||||||
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedSession = (page: Page) =>
|
||||||
|
page.evaluate(() => JSON.parse(localStorage.getItem('cinny_session_v1') ?? 'null'));
|
||||||
|
|
||||||
|
async function oauthLogin(page: Page) {
|
||||||
|
await page.goto(`/login/${enc(HS)}/`);
|
||||||
|
await page.getByRole('button', { name: /Continue with/ }).click();
|
||||||
|
await page.waitForURL(/\/home/, { timeout: 60_000 });
|
||||||
|
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('OAuth sign-in on SDK 44', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login, refresh on expiry, two-tab race, logout revokes', async ({ context }) => {
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
const user = await ensureUser(uniq('oa'));
|
||||||
|
const fake = await fakeOAuth(context, user.localpart);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
// 1. Login: registration, PKCE-checked code exchange, session persisted.
|
||||||
|
await oauthLogin(page);
|
||||||
|
expect(fake.registered).toBe(1);
|
||||||
|
expect(fake.tokenCalls.map((c) => c.grant)).toEqual(['authorization_code']);
|
||||||
|
let s = await storedSession(page);
|
||||||
|
expect(s.userId).toBe(user.userId);
|
||||||
|
expect(s.refreshToken).toBe('r1');
|
||||||
|
expect(s.oidc.clientId).toBe('fake-client');
|
||||||
|
const t1 = s.accessToken;
|
||||||
|
|
||||||
|
// 2. Second tab on the same session.
|
||||||
|
const page2 = await context.newPage();
|
||||||
|
await page2.goto('/home/');
|
||||||
|
await page2.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
|
||||||
|
|
||||||
|
// 3. Token "expires": both tabs get M_UNKNOWN_TOKEN on their next request
|
||||||
|
// and refresh at once. Exactly one refresh may reach the auth server
|
||||||
|
// (r1 is single-use); the other tab must adopt its tokens.
|
||||||
|
// Sync long-polls continuously, so both tabs hit it within ~30 s.
|
||||||
|
fake.expired.add(t1);
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await storedSession(page)).accessToken, { timeout: 90_000 })
|
||||||
|
.not.toBe(t1);
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
const refreshCalls = fake.tokenCalls.filter((c) => c.grant === 'refresh_token');
|
||||||
|
expect(refreshCalls.map((c) => c.refresh)).toEqual(['r1']);
|
||||||
|
s = await storedSession(page);
|
||||||
|
expect(s.refreshToken).toBe('r2');
|
||||||
|
await Promise.all(
|
||||||
|
[page, page2].map((p) =>
|
||||||
|
expect(p.getByText(/Username or email|Sign-in failed|logged out/i)).toHaveCount(0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Logout through the UI revokes both tokens at the auth server
|
||||||
|
// (the SDK does it for OAuth sessions instead of calling /logout).
|
||||||
|
await page2.close();
|
||||||
|
const latest = await storedSession(page);
|
||||||
|
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Logout' }).first().click();
|
||||||
|
await page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByRole('button', { name: 'Logout', exact: true })
|
||||||
|
.last()
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.getByLabel('Username or email').or(page.getByRole('button', { name: /Continue with/ })),
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
expect(fake.revoked.sort()).toEqual([latest.accessToken, latest.refreshToken].sort());
|
||||||
|
expect(await storedSession(page)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Register opens the auth server sign-up page (prompt=create)', async ({ context }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const user = await ensureUser(uniq('or'));
|
||||||
|
const fake = await fakeOAuth(context, user.localpart);
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`/register/${enc(HS)}/`);
|
||||||
|
// Native-OAuth server: no legacy registration form, just the auth server.
|
||||||
|
await expect(page.getByLabel('Username', { exact: true })).toHaveCount(0);
|
||||||
|
await page.getByRole('button', { name: /Continue with/ }).click();
|
||||||
|
await page.waitForURL(/\/home/, { timeout: 60_000 });
|
||||||
|
expect(fake.prompts).toEqual(['create']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Login sends no prompt', async ({ context }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const user = await ensureUser(uniq('ol'));
|
||||||
|
const fake = await fakeOAuth(context, user.localpart);
|
||||||
|
const page = await context.newPage();
|
||||||
|
await oauthLogin(page);
|
||||||
|
expect(fake.prompts).toEqual([null]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
createRoom,
|
||||||
|
enc,
|
||||||
|
ensureUser,
|
||||||
|
hsReachable,
|
||||||
|
loginUI,
|
||||||
|
openRoom,
|
||||||
|
sendText,
|
||||||
|
uniq,
|
||||||
|
} from './localHs';
|
||||||
|
|
||||||
|
// Offline outbox (#112): messages sent while offline are queued, then
|
||||||
|
// delivered once each, in order, when the connection returns. Checked on the
|
||||||
|
// server, so duplicates or reordering would show.
|
||||||
|
test.describe('offline outbox', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queued while offline, delivered once each and in order when back', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const me = await ensureUser(uniq('outbox'));
|
||||||
|
const room = await createRoom(me, 'Outbox Room');
|
||||||
|
await sendText(me, room, 'before going offline');
|
||||||
|
await loginUI(page, me);
|
||||||
|
await openRoom(page, room);
|
||||||
|
await expect(page.getByText('before going offline')).toBeVisible();
|
||||||
|
|
||||||
|
await context.setOffline(true);
|
||||||
|
const composer = page.getByRole('textbox', { name: /message/i }).last();
|
||||||
|
const texts = [uniq('one '), uniq('two '), uniq('three ')];
|
||||||
|
for (const text of texts) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await composer.fill(text);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await composer.press('Enter');
|
||||||
|
}
|
||||||
|
for (const text of texts) {
|
||||||
|
const item = page.locator('[data-message-item]', { hasText: text });
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await expect(item).toHaveCount(1);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await expect(item.getByLabel(/^Queued\. Will send when you're back online/)).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.setOffline(false);
|
||||||
|
const bodiesOnServer = async () => {
|
||||||
|
const res = await api<{ chunk: { type: string; content?: { body?: string } }[] }>(
|
||||||
|
'GET',
|
||||||
|
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=f&limit=100`,
|
||||||
|
me.token,
|
||||||
|
);
|
||||||
|
return res.chunk
|
||||||
|
.filter((e) => e.type === 'm.room.message' && texts.includes(e.content?.body ?? ''))
|
||||||
|
.map((e) => e.content?.body);
|
||||||
|
};
|
||||||
|
await expect.poll(bodiesOnServer, { timeout: 60_000, intervals: [1000] }).toEqual(texts);
|
||||||
|
for (const text of texts) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-message-item]', { hasText: text }).getByLabel(/^Queued/),
|
||||||
|
).toHaveCount(0, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
createRoom,
|
||||||
|
enc,
|
||||||
|
ensureUser,
|
||||||
|
hsReachable,
|
||||||
|
loginUI,
|
||||||
|
openRoom,
|
||||||
|
sendText,
|
||||||
|
uniq,
|
||||||
|
} from './localHs';
|
||||||
|
|
||||||
|
// Scheduled messages (MSC4140 delayed events). Each step is checked on the
|
||||||
|
// server too, so this proves the delayed event really exists, fires and is
|
||||||
|
// cancelled, not just that the tray looks right.
|
||||||
|
type Delayed = { delayed_events?: { delay_id: string; room_id: string }[] };
|
||||||
|
const delayedOnServer = (token: string) =>
|
||||||
|
api<Delayed>('GET', '/_matrix/client/unstable/org.matrix.msc4140/delayed_events', token).then(
|
||||||
|
(r) => r.delayed_events ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The tray remembers whether it is open; open it only if its rows are hidden.
|
||||||
|
const openTray = async (page: Page) => {
|
||||||
|
const rowButton = page.getByRole('button', { name: /^Cancel scheduled message: / });
|
||||||
|
if (await rowButton.count()) return;
|
||||||
|
await page.getByRole('button', { name: /^\d+ scheduled messages?$/ }).click();
|
||||||
|
await expect(rowButton.first()).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule = async (page: Page, text: string) => {
|
||||||
|
await page.getByRole('button', { name: 'Schedule message' }).click();
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'Schedule message' });
|
||||||
|
await dialog.locator('#schedule-message-body').fill(text);
|
||||||
|
// Two hours ahead, in the browser's own time zone.
|
||||||
|
const { date, time } = await page.evaluate(() => {
|
||||||
|
const d = new Date(Date.now() + 2 * 60 * 60 * 1000);
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return {
|
||||||
|
date: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`,
|
||||||
|
time: `${p(d.getHours())}:${p(d.getMinutes())}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await dialog.locator('#schedule-date').fill(date);
|
||||||
|
await dialog.locator('#schedule-time').fill(time);
|
||||||
|
await dialog.getByRole('button', { name: 'Schedule', exact: true }).click();
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('scheduled messages', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('schedule, send now, and cancel — the server agrees at each step', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const me = await ensureUser(uniq('sched'));
|
||||||
|
const room = await createRoom(me, 'Schedule Room');
|
||||||
|
await sendText(me, room, 'room is ready');
|
||||||
|
await loginUI(page, me);
|
||||||
|
await openRoom(page, room);
|
||||||
|
|
||||||
|
// 1. Schedule: tray shows it, the server holds one delayed event.
|
||||||
|
const first = uniq('later ');
|
||||||
|
await schedule(page, first);
|
||||||
|
await expect(page.getByRole('button', { name: '1 scheduled message' })).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await delayedOnServer(me.token)).length, { intervals: [1000] })
|
||||||
|
.toBe(1);
|
||||||
|
expect((await delayedOnServer(me.token))[0].room_id).toBe(room);
|
||||||
|
await expect(page.locator('[data-message-item]', { hasText: first })).toHaveCount(0);
|
||||||
|
|
||||||
|
// 2. Send now: it arrives once, the delayed event is gone.
|
||||||
|
await openTray(page);
|
||||||
|
await page.getByRole('button', { name: /^Send scheduled message now: / }).click();
|
||||||
|
await expect(page.locator('[data-message-item]', { hasText: first })).toHaveCount(1, {
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await delayedOnServer(me.token)).length, { intervals: [1000] })
|
||||||
|
.toBe(0);
|
||||||
|
|
||||||
|
// 3. Cancel: removed from the server and never sent.
|
||||||
|
const second = uniq('never ');
|
||||||
|
await schedule(page, second);
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await delayedOnServer(me.token)).length, { intervals: [1000] })
|
||||||
|
.toBe(1);
|
||||||
|
await openTray(page);
|
||||||
|
await page.getByRole('button', { name: /^Cancel scheduled message: / }).click();
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await delayedOnServer(me.token)).length, { intervals: [1000] })
|
||||||
|
.toBe(0);
|
||||||
|
await expect(page.getByRole('button', { name: /scheduled message/ })).toHaveCount(0);
|
||||||
|
const msgs = await api<{ chunk: { content?: { body?: string } }[] }>(
|
||||||
|
'GET',
|
||||||
|
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=50`,
|
||||||
|
me.token,
|
||||||
|
);
|
||||||
|
expect(msgs.chunk.some((e) => e.content?.body === second)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -75,7 +75,9 @@ test.describe('security nudge (#110, #123)', () => {
|
|||||||
await expect(strip(page)).toHaveCount(0);
|
await expect(strip(page)).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('no key backup: "Set up key backup" opens Settings → Devices', async ({ page }) => {
|
test('no key backup: "Set up key backup" opens Settings → Privacy & security', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
test.setTimeout(150_000);
|
test.setTimeout(150_000);
|
||||||
await loginUI(page, user);
|
await loginUI(page, user);
|
||||||
await pastGrace(page);
|
await pastGrace(page);
|
||||||
@@ -83,7 +85,7 @@ test.describe('security nudge (#110, #123)', () => {
|
|||||||
const nudge = strip(page);
|
const nudge = strip(page);
|
||||||
await expect(nudge).toContainText("Your encryption keys aren't backed up");
|
await expect(nudge).toContainText("Your encryption keys aren't backed up");
|
||||||
await nudge.getByRole('button', { name: 'Set up' }).click();
|
await nudge.getByRole('button', { name: 'Set up' }).click();
|
||||||
await expect(page.getByText('Device Verification').first()).toBeVisible();
|
await expect(page.getByText('Device verification').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => {
|
test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { ensureUser, hsReachable, loginUI, uniq } from './localHs';
|
||||||
|
|
||||||
|
// [Gitea #463] Search across all settings.
|
||||||
|
test.describe('settings search (#463)', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds a setting, opens its page and shows it', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const user = await ensureUser(uniq('ss'));
|
||||||
|
await loginUI(page, user);
|
||||||
|
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||||
|
const dialog = page.getByRole('dialog').first();
|
||||||
|
const box = dialog.getByRole('textbox', { name: 'Search settings' });
|
||||||
|
|
||||||
|
// Ctrl+F focuses the box while settings are open (after the dialog has
|
||||||
|
// placed its own initial focus).
|
||||||
|
await expect(box).toBeVisible();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.keyboard.press('ControlOrMeta+f');
|
||||||
|
await expect(box).toBeFocused();
|
||||||
|
|
||||||
|
await box.fill('deafen');
|
||||||
|
const results = dialog.getByRole('list', { name: 'Matching settings' });
|
||||||
|
const first = results.getByRole('listitem').first();
|
||||||
|
await expect(first).toContainText('Push to deafen');
|
||||||
|
await expect(first).toContainText('Calls & audio');
|
||||||
|
await expect(dialog.getByRole('navigation', { name: 'Settings sections' })).toBeHidden();
|
||||||
|
await first.click();
|
||||||
|
await expect(dialog.getByText('Calls & audio', { exact: true }).last()).toBeVisible();
|
||||||
|
await expect(dialog.getByText('Push to deafen', { exact: true })).toBeInViewport();
|
||||||
|
await expect(dialog.getByRole('navigation', { name: 'Settings sections' })).toBeVisible();
|
||||||
|
|
||||||
|
// A description word finds it too; Enter picks the top result.
|
||||||
|
await box.fill('utm');
|
||||||
|
await expect(results.getByRole('listitem').first()).toContainText(
|
||||||
|
'Strip tracking parameters from links',
|
||||||
|
);
|
||||||
|
await box.press('Enter');
|
||||||
|
await expect(dialog.getByText('Strip tracking parameters from links')).toBeInViewport();
|
||||||
|
|
||||||
|
// No match, then Esc clears.
|
||||||
|
await box.fill('zzzz-nothing');
|
||||||
|
await expect(dialog.getByText(/No settings match/)).toBeVisible();
|
||||||
|
await box.press('Escape');
|
||||||
|
await expect(box).toHaveValue('');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import { api, ensureUser, hsReachable, loginUI, PASSWORD } from './localHs';
|
||||||
|
|
||||||
|
// Signing out other devices with SSO confirmation. The desktop app opens the SSO
|
||||||
|
// page in the system browser (window.open gives no window back), so the dialog
|
||||||
|
// must still offer "Continue" — before the fix the only way on was starting
|
||||||
|
// over, in a loop (reported by a desktop SSO user). The server is mocked: the
|
||||||
|
// retry succeeds only if it carries the challenge's session.
|
||||||
|
const openDevicesAndSignOutOther = async (page: Page, flows: string[][]) => {
|
||||||
|
const sent: unknown[] = [];
|
||||||
|
await page.route('**/delete_devices', async (route) => {
|
||||||
|
const body = JSON.parse(route.request().postData() ?? '{}');
|
||||||
|
sent.push(body.auth ?? null);
|
||||||
|
if (body.auth?.session === 'S1') {
|
||||||
|
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
|
||||||
|
}
|
||||||
|
return route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
session: 'S1',
|
||||||
|
flows: flows.map((stages) => ({ stages })),
|
||||||
|
params: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||||
|
const dialog = page.getByRole('dialog').first();
|
||||||
|
await dialog.getByText('Privacy & security', { exact: true }).first().click();
|
||||||
|
const card = dialog
|
||||||
|
.getByText('Old laptop', { exact: true })
|
||||||
|
.locator('xpath=ancestor::*[.//button][1]');
|
||||||
|
await card.getByRole('button').nth(1).click(); // the delete chip
|
||||||
|
const bar = dialog.getByText(/Logout from selected devices/);
|
||||||
|
await bar
|
||||||
|
.locator('xpath=ancestor::*[.//button[normalize-space()="Logout"]][1]')
|
||||||
|
.getByRole('button', { name: 'Logout', exact: true })
|
||||||
|
.click();
|
||||||
|
return { sent, bar };
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('SSO confirmation for signing out devices', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop: no popup window, Continue still completes it', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.open = () => null; // the desktop app hands the page to the system browser
|
||||||
|
});
|
||||||
|
const user = await ensureUser(`ssod${Date.now().toString(36)}`);
|
||||||
|
await api('POST', '/_matrix/client/v3/login', undefined, {
|
||||||
|
type: 'm.login.password',
|
||||||
|
identifier: { type: 'm.id.user', user: user.localpart },
|
||||||
|
password: PASSWORD,
|
||||||
|
initial_device_display_name: 'Old laptop',
|
||||||
|
});
|
||||||
|
await loginUI(page, user);
|
||||||
|
const { sent, bar } = await openDevicesAndSignOutOther(page, [['m.login.sso']]);
|
||||||
|
await page.getByRole('button', { name: 'Continue with SSO' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Continue', exact: true }).click();
|
||||||
|
await expect(bar).toHaveCount(0);
|
||||||
|
expect(sent).toEqual([null, { session: 'S1' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password offered first: SSO is still one click away', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const user = await ensureUser(`ssop${Date.now().toString(36)}`);
|
||||||
|
await api('POST', '/_matrix/client/v3/login', undefined, {
|
||||||
|
type: 'm.login.password',
|
||||||
|
identifier: { type: 'm.id.user', user: user.localpart },
|
||||||
|
password: PASSWORD,
|
||||||
|
initial_device_display_name: 'Old laptop',
|
||||||
|
});
|
||||||
|
await loginUI(page, user);
|
||||||
|
await openDevicesAndSignOutOther(page, [['m.login.password'], ['m.login.sso']]);
|
||||||
|
await expect(page.getByText('Account Password').first()).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Use SSO instead' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Continue with SSO' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Use your password instead' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import { HS, PASSWORD, api, ensureUser, hsReachable, loginUI, uniq } from './localHs';
|
||||||
|
|
||||||
|
// [Gitea #113] Sessions unused for 90+ days: a nudge (after the device-security
|
||||||
|
// one), and Review opens Settings → Devices with them ticked for sign-out.
|
||||||
|
// Synapse won't backdate last-seen, so the session list is served with one
|
||||||
|
// real extra session made 120 days old.
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
|
||||||
|
const strip = (page: Page, text: RegExp) => page.getByRole('status').filter({ hasText: text });
|
||||||
|
|
||||||
|
async function pastGrace(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const { deviceId } = JSON.parse(localStorage.getItem('cinny_session_v1') ?? '{}');
|
||||||
|
localStorage.setItem(
|
||||||
|
`lotus-security-nudge-${deviceId}`,
|
||||||
|
JSON.stringify({ firstSeen: Date.now() - 2 * 86_400_000, dismissals: {} }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settled(page: Page) {
|
||||||
|
await page
|
||||||
|
.getByText('Connecting...')
|
||||||
|
.first()
|
||||||
|
.waitFor({ timeout: 30_000 })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('stale sessions (#113)', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nudge after the security one, Review ticks the old session, sign-out works', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
const user = await ensureUser(uniq('ss'));
|
||||||
|
// Two extra sessions: one we'll make look 4 months old, one recent.
|
||||||
|
const login = (device: string) =>
|
||||||
|
api<{ device_id: string }>('POST', '/_matrix/client/v3/login', undefined, {
|
||||||
|
type: 'm.login.password',
|
||||||
|
identifier: { type: 'm.id.user', user: user.localpart },
|
||||||
|
password: PASSWORD,
|
||||||
|
device_id: device,
|
||||||
|
initial_device_display_name: device,
|
||||||
|
});
|
||||||
|
await login('OLDLAPTOP');
|
||||||
|
await login('RECENTPHONE');
|
||||||
|
await page.route(/\/_matrix\/client\/v3\/devices(\?.*)?$/, async (route) => {
|
||||||
|
const res = await route.fetch();
|
||||||
|
const body = await res.json();
|
||||||
|
for (const d of body.devices ?? []) {
|
||||||
|
if (d.device_id === 'OLDLAPTOP') d.last_seen_ts = Date.now() - 120 * DAY;
|
||||||
|
else d.last_seen_ts = Date.now() - DAY;
|
||||||
|
}
|
||||||
|
await route.fulfill({ response: res, json: body });
|
||||||
|
});
|
||||||
|
|
||||||
|
await loginUI(page, user);
|
||||||
|
await pastGrace(page);
|
||||||
|
await settled(page);
|
||||||
|
|
||||||
|
// A fresh account has no key backup: that nudge comes first.
|
||||||
|
const security = strip(page, /encryption keys/);
|
||||||
|
await expect(security).toBeVisible({ timeout: 30_000 });
|
||||||
|
await security.getByRole('button', { name: 'Not now' }).click();
|
||||||
|
|
||||||
|
const stale = strip(page, /a session you haven't used in 3 months/);
|
||||||
|
await expect(stale).toBeVisible({ timeout: 30_000 });
|
||||||
|
await stale.getByRole('button', { name: 'Review' }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog').first();
|
||||||
|
await expect(dialog.getByText('Inactive for 3+ months')).toHaveCount(1);
|
||||||
|
await expect(dialog.getByText(/Logout from selected devices\. \(1 selected\)/)).toBeVisible();
|
||||||
|
|
||||||
|
// The sticky "Logout from selected devices" bar comes last.
|
||||||
|
await dialog.getByRole('button', { name: 'Logout', exact: true }).last().click();
|
||||||
|
await page.locator('input[name="passwordInput"]').fill(PASSWORD);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(dialog.getByText(/Logout from selected devices/)).toHaveCount(0, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { devices } = await api<{ devices: { device_id: string }[] }>(
|
||||||
|
'GET',
|
||||||
|
'/_matrix/client/v3/devices',
|
||||||
|
user.token,
|
||||||
|
);
|
||||||
|
const ids = devices.map((d) => d.device_id);
|
||||||
|
expect(ids).not.toContain('OLDLAPTOP');
|
||||||
|
expect(ids).toContain('RECENTPHONE');
|
||||||
|
expect(HS).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { api, ensureUser, hsReachable, loginUI, uniq } from './localHs';
|
||||||
|
|
||||||
|
// [Gitea #114] Export / import "your data": a round trip through the file, the
|
||||||
|
// merge summary, and the keep-mine / use-the-file's choice.
|
||||||
|
const enc = encodeURIComponent;
|
||||||
|
|
||||||
|
test.describe('your data (#114)', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('export, change things, import merges with the chosen conflict rule', async ({ page }) => {
|
||||||
|
test.setTimeout(150_000);
|
||||||
|
const user = await ensureUser(uniq('yd'));
|
||||||
|
const put = (type: string, content: unknown) =>
|
||||||
|
api(
|
||||||
|
'PUT',
|
||||||
|
`/_matrix/client/v3/user/${enc(user.userId)}/account_data/${type}`,
|
||||||
|
user.token,
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
const get = (type: string) =>
|
||||||
|
api<Record<string, unknown>>(
|
||||||
|
'GET',
|
||||||
|
`/_matrix/client/v3/user/${enc(user.userId)}/account_data/${type}`,
|
||||||
|
user.token,
|
||||||
|
);
|
||||||
|
const bm = (eventId: string) => ({ roomId: '!r:localhost', eventId, savedAt: 1 });
|
||||||
|
await put('io.lotus.bookmarks', { bookmarks: [bm('$one'), bm('$two')] });
|
||||||
|
await put('io.lotus.user_notes', { '@friend:localhost': 'met at the LAN party' });
|
||||||
|
|
||||||
|
await loginUI(page, user);
|
||||||
|
await page
|
||||||
|
.getByText('Connecting...')
|
||||||
|
.first()
|
||||||
|
.waitFor({ state: 'detached', timeout: 90_000 })
|
||||||
|
.catch(() => undefined);
|
||||||
|
const openGeneral = async () => {
|
||||||
|
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||||
|
await page.getByRole('dialog').getByText('Data & storage', { exact: true }).first().click();
|
||||||
|
await page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByText('Your data', { exact: true })
|
||||||
|
.scrollIntoViewIfNeeded();
|
||||||
|
};
|
||||||
|
await openGeneral();
|
||||||
|
|
||||||
|
// Export.
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent('download'),
|
||||||
|
page.getByRole('dialog').getByRole('button', { name: 'Export', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
const path = await download.path();
|
||||||
|
const file = JSON.parse(fs.readFileSync(path!, 'utf8'));
|
||||||
|
expect(file.format).toBe('lotus-chat-data');
|
||||||
|
expect(file.accountData['io.lotus.bookmarks'].bookmarks).toHaveLength(2);
|
||||||
|
expect(file.local.settings).toBeTruthy();
|
||||||
|
|
||||||
|
// Lose a bookmark, change the note.
|
||||||
|
await put('io.lotus.bookmarks', { bookmarks: [bm('$one')] });
|
||||||
|
await put('io.lotus.user_notes', { '@friend:localhost': 'changed my mind' });
|
||||||
|
await page.waitForTimeout(3000); // let sync deliver the new account data
|
||||||
|
|
||||||
|
const importFile = async () => {
|
||||||
|
await page
|
||||||
|
.locator('input[type="file"][aria-label="Lotus Chat data file"]')
|
||||||
|
.setInputFiles(path!);
|
||||||
|
await expect(page.getByRole('region', { name: 'Import summary' })).toBeVisible();
|
||||||
|
};
|
||||||
|
await importFile();
|
||||||
|
const summary = page.getByRole('region', { name: 'Import summary' });
|
||||||
|
await expect(summary).toContainText('Bookmarks: 1 new');
|
||||||
|
await expect(summary).toContainText('Private notes about people: 1 differ');
|
||||||
|
|
||||||
|
// Keep mine: the bookmark comes back, the note stays as it is now.
|
||||||
|
await summary.getByRole('button', { name: 'Import', exact: true }).click();
|
||||||
|
await expect(page.getByText('Imported.')).toBeVisible({ timeout: 20_000 });
|
||||||
|
expect(((await get('io.lotus.bookmarks')).bookmarks as unknown[]).length).toBe(2);
|
||||||
|
expect((await get('io.lotus.user_notes'))['@friend:localhost']).toBe('changed my mind');
|
||||||
|
|
||||||
|
// Again, using the file's: the note goes back.
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await importFile();
|
||||||
|
await summary.getByText("Use the file's").click();
|
||||||
|
await summary.getByRole('button', { name: 'Import', exact: true }).click();
|
||||||
|
await expect(page.getByText('Imported.')).toBeVisible({ timeout: 20_000 });
|
||||||
|
expect((await get('io.lotus.user_notes'))['@friend:localhost']).toBe('met at the LAN party');
|
||||||
|
|
||||||
|
// Wrong files are refused with a plain message.
|
||||||
|
fs.writeFileSync(`${path}.bad`, '{"format":"something-else"}');
|
||||||
|
await page
|
||||||
|
.locator('input[type="file"][aria-label="Lotus Chat data file"]')
|
||||||
|
.setInputFiles(`${path}.bad`);
|
||||||
|
await expect(page.getByText("This isn't a Lotus Chat data file.")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+61
-61
@@ -45,7 +45,7 @@
|
|||||||
"html-react-parser": "6.1.2",
|
"html-react-parser": "6.1.2",
|
||||||
"i18next": "26.2.0",
|
"i18next": "26.2.0",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-http-backend": "4.0.2",
|
"i18next-http-backend": "4.0.3",
|
||||||
"immer": "11.1.8",
|
"immer": "11.1.8",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
"katex": "0.18.11",
|
"katex": "0.18.11",
|
||||||
"linkify-react": "4.3.3",
|
"linkify-react": "4.3.3",
|
||||||
"linkifyjs": "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",
|
"matrix-widget-api": "1.18.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "6.3.289",
|
"pdfjs-dist": "6.3.289",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-range": "1.10.0",
|
"react-range": "1.10.0",
|
||||||
"react-router-dom": "7.18.3",
|
"react-router-dom": "7.18.3",
|
||||||
"sanitize-html": "2.17.7",
|
"sanitize-html": "2.18.1",
|
||||||
"slate": "0.124.1",
|
"slate": "0.124.1",
|
||||||
"slate-dom": "0.124.1",
|
"slate-dom": "0.124.1",
|
||||||
"slate-history": "0.113.1",
|
"slate-history": "0.113.1",
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.28",
|
"@lotusguild/element-call-embedded": "0.28.0-lotus.5",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
@@ -2695,15 +2695,15 @@
|
|||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||||
},
|
},
|
||||||
"node_modules/@lotusguild/element-call-embedded": {
|
"node_modules/@lotusguild/element-call-embedded": {
|
||||||
"version": "0.25.0-lotus.28",
|
"version": "0.28.0-lotus.5",
|
||||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.28/element-call-embedded-0.25.0-lotus.28.tgz",
|
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.28.0-lotus.5/element-call-embedded-0.28.0-lotus.5.tgz",
|
||||||
"integrity": "sha512-IFBHHSVw0CzrrLgx3oIIcJmBB4YMb8at4oVuSKXifZ7MmyHydxg9U3sTlZGgLUjMVrrAXj4gFzCksGHet4LSQw==",
|
"integrity": "sha512-yW95xjtlUv65B7JCd0dxOv2M8qdjXvMvlk4btDmobr4D+kIFWtfyOCranmzHz9KbaS3vwy3WgTgp73G8mAAO/Q==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
"version": "18.3.1",
|
"version": "18.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.9.0.tgz",
|
||||||
"integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==",
|
"integrity": "sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
@@ -5580,12 +5580,12 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/content-type": {
|
"node_modules/content-type": {
|
||||||
"version": "2.0.0",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-3.1.1.tgz",
|
||||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
"integrity": "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -6057,6 +6057,7 @@
|
|||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||||
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -8168,9 +8169,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next-http-backend": {
|
"node_modules/i18next-http-backend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.3.tgz",
|
||||||
"integrity": "sha512-oay62dIB2kL7+WHzoUXBjWfL3+mwijD3pQkQkIEaRLhy6kjXxUhrRenV0gInwlCASAaJaDy94+XvYizK+cAGdA==",
|
"integrity": "sha512-B5GJysCsJwqiDQRpUcikWsut82Vmekix58WIyiCpYtxaZ/wY8kwJCicMRvVcMoIa1x84siiRl5vBt6iCBVO6zg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -8704,9 +8705,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-plain-object": {
|
"node_modules/is-plain-object": {
|
||||||
"version": "5.0.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
|
||||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -9152,15 +9154,6 @@
|
|||||||
"node": ">=4.0"
|
"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": {
|
"node_modules/katex": {
|
||||||
"version": "0.18.11",
|
"version": "0.18.11",
|
||||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.11.tgz",
|
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.11.tgz",
|
||||||
@@ -9216,14 +9209,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/launder": {
|
"node_modules/launder": {
|
||||||
"version": "1.7.1",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.2.tgz",
|
||||||
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
|
"integrity": "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dayjs": "^1.11.7"
|
"dayjs": "^1.11.23"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/launder/node_modules/dayjs": {
|
||||||
|
"version": "1.11.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
|
||||||
|
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/leven": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||||
@@ -10037,21 +10036,19 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/matrix-js-sdk": {
|
"node_modules/matrix-js-sdk": {
|
||||||
"version": "41.7.0",
|
"version": "44.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-44.0.0.tgz",
|
||||||
"integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==",
|
"integrity": "sha512-5wjcYMIX1aqhJC9hSUGjfSW+7SWEaLC8NUaek0xH5KGXqAO11M4Z5Mk7Voodsk+16Jm9PYB8/wMtyqIKFm0VAQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^8.0.0",
|
||||||
"@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1",
|
"@matrix-org/matrix-sdk-crypto-wasm": "^18.9.0",
|
||||||
"another-json": "^0.2.0",
|
"another-json": "^0.2.0",
|
||||||
"bs58": "^6.0.0",
|
"bs58": "^6.0.0",
|
||||||
"content-type": "^2.0.0",
|
"content-type": "^3.0.0",
|
||||||
"jwt-decode": "^4.0.0",
|
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
"matrix-events-sdk": "0.0.1",
|
"matrix-events-sdk": "0.0.1",
|
||||||
"matrix-widget-api": "^1.16.1",
|
"matrix-widget-api": "^1.19.0",
|
||||||
"oidc-client-ts": "^3.0.1",
|
|
||||||
"p-retry": "8",
|
"p-retry": "8",
|
||||||
"sdp-transform": "^3.0.0",
|
"sdp-transform": "^3.0.0",
|
||||||
"unhomoglyph": "^1.0.6"
|
"unhomoglyph": "^1.0.6"
|
||||||
@@ -10060,6 +10057,22 @@
|
|||||||
"node": ">=22.0.0"
|
"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": {
|
"node_modules/matrix-widget-api": {
|
||||||
"version": "1.18.0",
|
"version": "1.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.18.0.tgz",
|
||||||
@@ -10405,18 +10418,6 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -11713,18 +11714,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/sanitize-html": {
|
"node_modules/sanitize-html": {
|
||||||
"version": "2.17.7",
|
"version": "2.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz",
|
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz",
|
||||||
"integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",
|
"integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"deepmerge": "^4.2.2",
|
|
||||||
"escape-string-regexp": "^4.0.0",
|
"escape-string-regexp": "^4.0.0",
|
||||||
"htmlparser2": "^12.0.0",
|
"htmlparser2": "^12.0.0",
|
||||||
"is-plain-object": "^5.0.0",
|
"is-plain-object": "^5.1.0",
|
||||||
"launder": "^1.7.1",
|
"launder": "^1.7.2",
|
||||||
"parse-srcset": "^1.0.2",
|
"parse-srcset": "^1.0.2",
|
||||||
"postcss": "^8.3.11"
|
"postcss": "^8.5.27"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.12.0"
|
"node": ">=22.12.0"
|
||||||
|
|||||||
+5
-5
@@ -12,7 +12,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "npm run check:eslint && npm run check:prettier",
|
"lint": "npm run check:eslint && npm run check:prettier",
|
||||||
"check:eslint": "eslint src/* --max-warnings 36",
|
"check:eslint": "eslint src/* --max-warnings 29",
|
||||||
"check:prettier": "prettier --check .",
|
"check:prettier": "prettier --check .",
|
||||||
"fix:prettier": "prettier --write .",
|
"fix:prettier": "prettier --write .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"html-react-parser": "6.1.2",
|
"html-react-parser": "6.1.2",
|
||||||
"i18next": "26.2.0",
|
"i18next": "26.2.0",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-http-backend": "4.0.2",
|
"i18next-http-backend": "4.0.3",
|
||||||
"immer": "11.1.8",
|
"immer": "11.1.8",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"katex": "0.18.11",
|
"katex": "0.18.11",
|
||||||
"linkify-react": "4.3.3",
|
"linkify-react": "4.3.3",
|
||||||
"linkifyjs": "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",
|
"matrix-widget-api": "1.18.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "6.3.289",
|
"pdfjs-dist": "6.3.289",
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-range": "1.10.0",
|
"react-range": "1.10.0",
|
||||||
"react-router-dom": "7.18.3",
|
"react-router-dom": "7.18.3",
|
||||||
"sanitize-html": "2.17.7",
|
"sanitize-html": "2.18.1",
|
||||||
"slate": "0.124.1",
|
"slate": "0.124.1",
|
||||||
"slate-dom": "0.124.1",
|
"slate-dom": "0.124.1",
|
||||||
"slate-history": "0.113.1",
|
"slate-history": "0.113.1",
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.28",
|
"@lotusguild/element-call-embedded": "0.28.0-lotus.5",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode, useState } from 'react';
|
||||||
|
import { Box, Button, Text } from 'folds';
|
||||||
import { AuthDict, AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
|
import { AuthDict, AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
|
||||||
import { getUIAFlowForStages } from '../utils/matrix-uia';
|
import { getUIAFlowForStages } from '../utils/matrix-uia';
|
||||||
import { useSupportedUIAFlows, useUIACompleted, useUIAFlow } from '../hooks/useUIAFlows';
|
import { useSupportedUIAFlows, useUIACompleted, useUIAFlow } from '../hooks/useUIAFlows';
|
||||||
@@ -23,7 +24,18 @@ type ActionUIAProps = {
|
|||||||
export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) {
|
export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const completed = useUIACompleted(authData);
|
const completed = useUIACompleted(authData);
|
||||||
const { getStageToComplete } = useUIAFlow(authData, ongoingFlow);
|
// The server may offer several ways to confirm (e.g. password or SSO). Start
|
||||||
|
// with the given one but let the user switch: an account that signs in with
|
||||||
|
// SSO may have no password it knows.
|
||||||
|
const supportedFlows = useSupportedUIAFlows(authData.flows ?? [], SUPPORTED_IN_APP_UIA_STAGES);
|
||||||
|
const [flow, setFlow] = useState<UIAFlow>(ongoingFlow);
|
||||||
|
const { getStageToComplete } = useUIAFlow(authData, flow);
|
||||||
|
const other = supportedFlows.find((f) => f.stages.join() !== flow.stages.join());
|
||||||
|
const otherLabel = other?.stages.includes(AuthType.Sso)
|
||||||
|
? 'Use SSO instead'
|
||||||
|
: other?.stages.includes(AuthType.Password)
|
||||||
|
? 'Use your password instead'
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const stageToComplete = getStageToComplete();
|
const stageToComplete = getStageToComplete();
|
||||||
|
|
||||||
@@ -31,7 +43,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
|
|||||||
return (
|
return (
|
||||||
<UIAFlowOverlay
|
<UIAFlowOverlay
|
||||||
currentStep={completed.length + 1}
|
currentStep={completed.length + 1}
|
||||||
stepCount={ongoingFlow.stages.length}
|
stepCount={flow.stages.length}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
>
|
>
|
||||||
{stageToComplete.type === AuthType.Password && (
|
{stageToComplete.type === AuthType.Password && (
|
||||||
@@ -50,6 +62,21 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
|
|||||||
submitAuthDict={action}
|
submitAuthDict={action}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{other && otherLabel && completed.length === 0 && (
|
||||||
|
<Box justifyContent="Center" style={{ marginTop: 8 }}>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => setFlow(other)}
|
||||||
|
>
|
||||||
|
<Text as="span" size="B300">
|
||||||
|
{otherLabel}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</UIAFlowOverlay>
|
</UIAFlowOverlay>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
|
|||||||
|
|
||||||
const [state, load] = useAsyncCallback(
|
const [state, load] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
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 loginFlows = promiseFulfilledResult(result[0]);
|
||||||
const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined;
|
const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined;
|
||||||
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
|
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
|
||||||
@@ -42,6 +47,7 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
|
|||||||
const authFlows: AuthFlows = {
|
const authFlows: AuthFlows = {
|
||||||
loginFlows,
|
loginFlows,
|
||||||
registerFlows,
|
registerFlows,
|
||||||
|
authMetadata: promiseFulfilledResult(result[2]),
|
||||||
};
|
};
|
||||||
|
|
||||||
return authFlows;
|
return authFlows;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
useCallThemeSync,
|
useCallThemeSync,
|
||||||
useCallStart,
|
useCallStart,
|
||||||
} from '../hooks/useCallEmbed';
|
} from '../hooks/useCallEmbed';
|
||||||
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
import { callChatAtom, callEmbedAtom, immersiveCallAtom } from '../state/callEmbed';
|
||||||
import { toastQueueAtom } from '../state/toast';
|
import { toastQueueAtom } from '../state/toast';
|
||||||
import { CallEmbed, useCallControlState, useScreensharePromptOpen } from '../plugins/call';
|
import { CallEmbed, useCallControlState, useScreensharePromptOpen } from '../plugins/call';
|
||||||
import { hangupCallAndWait } from '../plugins/call/hangup';
|
import { hangupCallAndWait } from '../plugins/call/hangup';
|
||||||
@@ -52,6 +52,7 @@ import { useCallEndedToast } from '../hooks/useCallEndedToast';
|
|||||||
import { useCallRejoin } from '../hooks/useCallRejoin';
|
import { useCallRejoin } from '../hooks/useCallRejoin';
|
||||||
import { usePttHaptics } from '../hooks/usePttHaptics';
|
import { usePttHaptics } from '../hooks/usePttHaptics';
|
||||||
import { useScreenshareNotices } from '../hooks/useScreenshareNotices';
|
import { useScreenshareNotices } from '../hooks/useScreenshareNotices';
|
||||||
|
import { ExpectedDevicesPrompt } from '../features/call/ExpectedDevicesPrompt';
|
||||||
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
|
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
|
||||||
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
|
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
|
||||||
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
||||||
@@ -59,6 +60,7 @@ import { SrOnly } from '../features/call-status/styles.css';
|
|||||||
import { useCallHotkeys } from '../hooks/useCallHotkeys';
|
import { useCallHotkeys } from '../hooks/useCallHotkeys';
|
||||||
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
|
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
|
||||||
import { useCallQuality } from '../hooks/useCallQuality';
|
import { useCallQuality } from '../hooks/useCallQuality';
|
||||||
|
import { useCallDenoise } from '../hooks/useCallDenoise';
|
||||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
@@ -571,7 +573,11 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
? content.sender_ts
|
? content.sender_ts
|
||||||
: event.getTs();
|
: event.getTs();
|
||||||
const lifetime = Math.min(content.lifetime, 120000);
|
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 =
|
const relation =
|
||||||
event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined;
|
event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined;
|
||||||
const refEventId = relation?.event_id;
|
const refEventId = relation?.event_id;
|
||||||
@@ -653,7 +659,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
mx.getRooms().forEach((r) => {
|
mx.getRooms().forEach((r) => {
|
||||||
mx.matrixRTC
|
mx.matrixRTC
|
||||||
.getActiveRoomSession(r)
|
.getActiveRoomSession(r)
|
||||||
?._onRTCSessionMemberUpdate()
|
?.ensureRecalculateSessionMembers()
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -831,6 +837,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
|||||||
useMutedTalkWarning(embed, joined);
|
useMutedTalkWarning(embed, joined);
|
||||||
useCallThemeSync(embed);
|
useCallThemeSync(embed);
|
||||||
useCallQuality(embed);
|
useCallQuality(embed);
|
||||||
|
useCallDenoise(embed);
|
||||||
useCallHangupEvent(
|
useCallHangupEvent(
|
||||||
embed,
|
embed,
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -854,7 +861,14 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
|||||||
};
|
};
|
||||||
}, [joined]);
|
}, [joined]);
|
||||||
|
|
||||||
return null;
|
return (
|
||||||
|
<ExpectedDevicesPrompt
|
||||||
|
embed={embed}
|
||||||
|
onCancel={() => {
|
||||||
|
setCallEmbed(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -942,6 +956,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
const inCallRoom = callEmbed && selectedRoom === callEmbed.roomId;
|
const inCallRoom = callEmbed && selectedRoom === callEmbed.roomId;
|
||||||
const callActive = callEmbed && joined;
|
const callActive = callEmbed && joined;
|
||||||
const callVisible = inCallRoom && callActive && !chatOnlyView;
|
const callVisible = inCallRoom && callActive && !chatOnlyView;
|
||||||
|
const immersiveCall = useAtomValue(immersiveCallAtom);
|
||||||
const pipMode = callActive && !inCallRoom;
|
const pipMode = callActive && !inCallRoom;
|
||||||
const pipPromptOpen = useScreensharePromptOpen(callEmbed?.control);
|
const pipPromptOpen = useScreensharePromptOpen(callEmbed?.control);
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
@@ -1446,7 +1461,13 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
...(pipMode
|
...(pipMode
|
||||||
? { background: color.Surface.Container }
|
? { background: color.Surface.Container }
|
||||||
: callVisible
|
: 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}
|
ref={callEmbedRef}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { useModalStyle } from '../hooks/useModalStyle';
|
|||||||
* cinny-desktop #5: the first time the window is closed, ask whether Lotus Chat
|
* cinny-desktop #5: the first time the window is closed, ask whether Lotus Chat
|
||||||
* should keep running in the tray (calls, messages, notifications) or quit —
|
* should keep running in the tray (calls, messages, notifications) or quit —
|
||||||
* and, in the same moment, whether it should start when you sign in. Asked
|
* and, in the same moment, whether it should start when you sign in. Asked
|
||||||
* once; changeable in Settings → General. The native side sends
|
* once; changeable in Settings → Desktop. The native side sends
|
||||||
* `lotus-close-requested` only while the choice is still "ask", and never
|
* `lotus-close-requested` only while the choice is still "ask", and never
|
||||||
* during a call (a call always goes to the tray).
|
* during a call (a call always goes to the tray).
|
||||||
*/
|
*/
|
||||||
@@ -149,7 +149,7 @@ export function CloseBehaviorPrompt() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
You can change this in Settings → General.
|
You can change this in Settings → Desktop.
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { FormEventHandler, useCallback } from 'react';
|
import React, { FormEventHandler, useCallback } from 'react';
|
||||||
import { Box, Text, Button, Spinner, color } from 'folds';
|
import { Box, Text, Button, Spinner, color } from 'folds';
|
||||||
import { decodeRecoveryKey, deriveRecoveryKeyFromPassphrase } from 'matrix-js-sdk/lib/crypto-api';
|
import { decodeRecoveryKey, deriveRecoveryKeyFromPassphrase } from 'matrix-js-sdk/lib/crypto-api';
|
||||||
|
import type { SecretStorageKeyDescription } from 'matrix-js-sdk/lib/secret-storage';
|
||||||
import { PasswordInput } from './password-input';
|
import { PasswordInput } from './password-input';
|
||||||
import {
|
import {
|
||||||
SecretStorageKeyContent,
|
SecretStorageKeyContent,
|
||||||
@@ -39,7 +40,10 @@ export function SecretStorageRecoveryPassphrase({
|
|||||||
bits,
|
bits,
|
||||||
);
|
);
|
||||||
|
|
||||||
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
|
const match = await mx.secretStorage.checkKey(
|
||||||
|
decodedRecoveryKey,
|
||||||
|
keyContent as SecretStorageKeyDescription,
|
||||||
|
);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error('Invalid recovery passphrase.');
|
throw new Error('Invalid recovery passphrase.');
|
||||||
@@ -132,7 +136,10 @@ export function SecretStorageRecoveryKey({
|
|||||||
async (recoveryKey) => {
|
async (recoveryKey) => {
|
||||||
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
|
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
|
||||||
|
|
||||||
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
|
const match = await mx.secretStorage.checkKey(
|
||||||
|
decodedRecoveryKey,
|
||||||
|
keyContent as SecretStorageKeyDescription,
|
||||||
|
);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error('Invalid recovery key.');
|
throw new Error('Invalid recovery key.');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ReactNode, useCallback, useMemo } from 'react';
|
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 { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
import { MediaConfig } from '../hooks/useMediaConfig';
|
import { MediaConfig } from '../hooks/useMediaConfig';
|
||||||
@@ -28,23 +28,14 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
|
|||||||
|
|
||||||
const capabilities = promiseFulfilledResult(result[0]);
|
const capabilities = promiseFulfilledResult(result[0]);
|
||||||
const mediaConfig = promiseFulfilledResult(result[1]);
|
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]);
|
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 {
|
return {
|
||||||
capabilities,
|
capabilities,
|
||||||
mediaConfig,
|
mediaConfig,
|
||||||
authMetadata: validatedAuthMetadata,
|
authMetadata,
|
||||||
};
|
};
|
||||||
}, [mx]),
|
}, [mx]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { Chip, Icon, IconSrc, Text, Tooltip, TooltipProvider } from 'folds';
|
||||||
|
import {
|
||||||
|
BRIDGE_NETWORK_NAME,
|
||||||
|
BridgeNetwork,
|
||||||
|
describeRoomBridge,
|
||||||
|
getBridgedUserNetwork,
|
||||||
|
} from '../../utils/bridge';
|
||||||
|
import { useRoomBridges } from '../../hooks/useRoomBridges';
|
||||||
|
|
||||||
|
// Discord glyph (simple-icons, 24x24 viewBox like the folds icons).
|
||||||
|
const DiscordIcon: IconSrc = () => (
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BRIDGE_ICON: Record<BridgeNetwork, IconSrc> = {
|
||||||
|
discord: DiscordIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BRIDGE_COLOR: Record<BridgeNetwork, string> = {
|
||||||
|
discord: '#5865F2',
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserBridgeBadgeProps = {
|
||||||
|
userId: string;
|
||||||
|
size?: '50' | '100' | '200';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Network icon for a user who is bridged in from another chat network, else nothing. */
|
||||||
|
export function UserBridgeBadge({ userId, size = '100' }: UserBridgeBadgeProps) {
|
||||||
|
const network = getBridgedUserNetwork(userId);
|
||||||
|
if (!network) return null;
|
||||||
|
const label = `On ${BRIDGE_NETWORK_NAME[network]} (bridged)`;
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Top"
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text size="T200">{label}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(ref) => (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
role="img"
|
||||||
|
aria-label={label}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<Icon size={size} src={BRIDGE_ICON[network]} style={{ color: BRIDGE_COLOR[network] }} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoomBridgeChipProps = {
|
||||||
|
room: Room;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Header chip for a room bridged to another network; links to the channel when known. */
|
||||||
|
export function RoomBridgeChip({ room }: RoomBridgeChipProps) {
|
||||||
|
const bridges = useRoomBridges(room);
|
||||||
|
const bridge = bridges[0];
|
||||||
|
if (!bridge) return null;
|
||||||
|
const description = describeRoomBridge(bridge);
|
||||||
|
const linkProps = bridge.externalUrl
|
||||||
|
? { as: 'a' as const, href: bridge.externalUrl, target: '_blank', rel: 'noreferrer noopener' }
|
||||||
|
: {};
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>
|
||||||
|
{bridge.externalUrl
|
||||||
|
? `${description}. Open in ${BRIDGE_NETWORK_NAME[bridge.network]}.`
|
||||||
|
: description}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<Chip
|
||||||
|
ref={triggerRef}
|
||||||
|
size="400"
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
aria-label={description}
|
||||||
|
before={
|
||||||
|
<Icon
|
||||||
|
size="50"
|
||||||
|
src={BRIDGE_ICON[bridge.network]}
|
||||||
|
style={{ color: BRIDGE_COLOR[bridge.network] }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{...linkProps}
|
||||||
|
>
|
||||||
|
<Text size="T200">{BRIDGE_NETWORK_NAME[bridge.network]}</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './BridgeBadge';
|
||||||
@@ -33,6 +33,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
|||||||
import { ModalWide } from '../../../styles/Modal.css';
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
import { useNearViewport } from '../../../hooks/useNearViewport';
|
import { useNearViewport } from '../../../hooks/useNearViewport';
|
||||||
|
import { whenMediaAuthReady } from '../../../../sw-session';
|
||||||
|
|
||||||
type RenderViewerProps = {
|
type RenderViewerProps = {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -115,13 +116,24 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
const handleLoad = () => {
|
const handleLoad = () => {
|
||||||
setLoad(true);
|
setLoad(true);
|
||||||
};
|
};
|
||||||
|
// An image requested before the service worker controls the page goes out
|
||||||
|
// without the access token and fails (first visit, just after an update).
|
||||||
|
// Retry once by itself when that's ready; only then offer "Retry".
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const autoRetried = useRef(false);
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
setLoad(false);
|
setLoad(false);
|
||||||
|
if (!autoRetried.current && !encInfo) {
|
||||||
|
autoRetried.current = true;
|
||||||
|
whenMediaAuthReady().then(() => setAttempt((n) => n + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(true);
|
setError(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
setError(false);
|
setError(false);
|
||||||
|
setAttempt((n) => n + 1);
|
||||||
loadSrc();
|
loadSrc();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,7 +194,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
<Box key={attempt} className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||||
{renderImage({
|
{renderImage({
|
||||||
alt: body,
|
alt: body,
|
||||||
title: body,
|
title: body,
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export function SSOStage({
|
|||||||
}) {
|
}) {
|
||||||
const { errorCode, error, session } = stageData;
|
const { errorCode, error, session } = stageData;
|
||||||
const [ssoWindow, setSSOWindow] = useState<Window>();
|
const [ssoWindow, setSSOWindow] = useState<Window>();
|
||||||
|
// Whether the SSO page was opened. Not the same as having a window handle:
|
||||||
|
// the desktop app opens it in the system browser and gets none back, and a
|
||||||
|
// popup can lose its link to us (cross-origin opener policy on the identity
|
||||||
|
// provider). In both cases "authDone" never arrives, so "Continue" must still
|
||||||
|
// be offered once the page was opened, or the only way on is starting over.
|
||||||
|
const [started, setStarted] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
submitAuthDict({
|
submitAuthDict({
|
||||||
@@ -22,6 +28,7 @@ export function SSOStage({
|
|||||||
const handleContinue = () => {
|
const handleContinue = () => {
|
||||||
const w = window.open(ssoRedirectURL, '_blank');
|
const w = window.open(ssoRedirectURL, '_blank');
|
||||||
setSSOWindow(w ?? undefined);
|
setSSOWindow(w ?? undefined);
|
||||||
|
setStarted(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -74,7 +81,9 @@ export function SSOStage({
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<Text size="T200">
|
<Text size="T200">
|
||||||
To perform this action you need to authenticate yourself by SSO login.
|
{started
|
||||||
|
? 'Finish signing in on the page that opened, then come back here and select Continue.'
|
||||||
|
: 'To do this, confirm it’s you by signing in with SSO.'}
|
||||||
</Text>
|
</Text>
|
||||||
{errorCode && (
|
{errorCode && (
|
||||||
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
|
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
|
||||||
@@ -85,12 +94,19 @@ export function SSOStage({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ssoWindow ? (
|
{started ? (
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
<Button variant="Primary" onClick={handleSubmit}>
|
<Button variant="Primary" onClick={handleSubmit}>
|
||||||
<Text as="span" size="B400">
|
<Text as="span" size="B400">
|
||||||
Continue
|
Continue
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="Secondary" fill="None" onClick={handleContinue}>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Open the sign-in page again
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="Primary" onClick={handleContinue}>
|
<Button variant="Primary" onClick={handleContinue}>
|
||||||
<Text as="span" size="B400">
|
<Text as="span" size="B400">
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useLocalTime } from '../../hooks/useLocalTime';
|
import { useLocalTime } from '../../hooks/useLocalTime';
|
||||||
|
import { UserBridgeBadge } from '../bridge';
|
||||||
|
|
||||||
type UserHeroProps = {
|
type UserHeroProps = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -143,6 +144,7 @@ export function UserHeroName({
|
|||||||
>
|
>
|
||||||
{displayName ?? username ?? userId}
|
{displayName ?? username ?? userId}
|
||||||
</Text>
|
</Text>
|
||||||
|
<UserBridgeBadge userId={userId} size="200" />
|
||||||
</Box>
|
</Box>
|
||||||
{pronouns && (
|
{pronouns && (
|
||||||
<Box alignItems="Center" gap="100" style={{ marginTop: '1px', overflow: 'hidden' }}>
|
<Box alignItems="Center" gap="100" style={{ marginTop: '1px', overflow: 'hidden' }}>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
config,
|
||||||
|
Dialog,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Text,
|
||||||
|
} from 'folds';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { callSummaryViewAtom } from '../../state/callSummary';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #253] The post-call "Details" card: opened from the call-ended toast,
|
||||||
|
* shows who was in the call, connection quality and whose side it was on,
|
||||||
|
* network numbers and your own activity (technical wording).
|
||||||
|
*/
|
||||||
|
export function CallSummaryDialog() {
|
||||||
|
const [view, setView] = useAtom(callSummaryViewAtom);
|
||||||
|
const modalStyle = useModalStyle(400);
|
||||||
|
if (!view) return null;
|
||||||
|
const close = () => setView(undefined);
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog
|
||||||
|
variant="Surface"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="call-summary-title"
|
||||||
|
style={modalStyle}
|
||||||
|
>
|
||||||
|
<Header
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Icon src={Icons.Phone} size="100" />
|
||||||
|
<Text id="call-summary-title" size="H4" truncate>
|
||||||
|
Call summary · {view.roomName}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" radii="300" onClick={close} aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||||
|
{view.sections.map((section) => (
|
||||||
|
<Box key={section.title} direction="Column" gap="100">
|
||||||
|
<Text size="L400">{section.title}</Text>
|
||||||
|
{section.lines.map((line) => (
|
||||||
|
<Text key={line} size="T300" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{line}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
|
|||||||
import { CallMemberRenderer } from './CallMemberCard';
|
import { CallMemberRenderer } from './CallMemberCard';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { CallControls } from './CallControls';
|
import { CallControls } from './CallControls';
|
||||||
|
import { ImmersiveCallOverlay } from './ImmersiveCallOverlay';
|
||||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||||
import { CallsUnavailableMessage } from './CallsUnavailable';
|
import { CallsUnavailableMessage } from './CallsUnavailable';
|
||||||
import { webRTCSupported } from '../../utils/rtc';
|
import { webRTCSupported } from '../../utils/rtc';
|
||||||
@@ -198,10 +199,24 @@ function CallLoadErrorMessage() {
|
|||||||
type CallJoinedProps = {
|
type CallJoinedProps = {
|
||||||
containerRef: RefObject<HTMLDivElement>;
|
containerRef: RefObject<HTMLDivElement>;
|
||||||
joined: boolean;
|
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();
|
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 (
|
return (
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<Box grow="Yes" ref={containerRef} />
|
<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 room = useRoom();
|
||||||
const callContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
const callContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||||
useCallEmbedPlacementSync(callContainerRef);
|
useCallEmbedPlacementSync(callContainerRef);
|
||||||
@@ -227,6 +242,26 @@ export function CallView() {
|
|||||||
// made it into the call (a hung iframe / blank spinner otherwise).
|
// made it into the call (a hung iframe / blank spinner otherwise).
|
||||||
const showLoadError = isCurrentRoom && !currentJoined && Boolean(loadError);
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className={ContainerColor({ variant: 'Surface' })}
|
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;
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
AccountDataSubmitCallback,
|
AccountDataSubmitCallback,
|
||||||
} from '../../../components/AccountDataEditor';
|
} from '../../../components/AccountDataEditor';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { setRoomAccountData } from '../../../utils/accountData';
|
||||||
|
|
||||||
type DeveloperToolsProps = {
|
type DeveloperToolsProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -58,8 +59,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
|||||||
|
|
||||||
const submitAccountData: AccountDataSubmitCallback = useCallback(
|
const submitAccountData: AccountDataSubmitCallback = useCallback(
|
||||||
async (type, content) => {
|
async (type, content) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
await setRoomAccountData(mx, room.roomId, type, content);
|
||||||
await mx.setRoomAccountData(room.roomId, type as any, content);
|
|
||||||
},
|
},
|
||||||
[mx, room.roomId],
|
[mx, room.roomId],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
|
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
|
||||||
import { MatrixError } from 'matrix-js-sdk';
|
import { IContent, MatrixError } from 'matrix-js-sdk';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Chip,
|
Chip,
|
||||||
@@ -22,6 +22,7 @@ import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { syntaxErrorPosition } from '../../../utils/dom';
|
import { syntaxErrorPosition } from '../../../utils/dom';
|
||||||
import { Cursor } from '../../../plugins/text-area';
|
import { Cursor } from '../../../plugins/text-area';
|
||||||
|
import { sendRoomEvent, sendStateEvent } from '../../../utils/room';
|
||||||
|
|
||||||
const EDITOR_INTENT_SPACE_COUNT = 2;
|
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||||
|
|
||||||
@@ -51,9 +52,9 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
|
|||||||
useCallback(
|
useCallback(
|
||||||
(evtType, evtStateKey, evtContent) => {
|
(evtType, evtStateKey, evtContent) => {
|
||||||
if (typeof evtStateKey === 'string') {
|
if (typeof evtStateKey === 'string') {
|
||||||
return mx.sendStateEvent(room.roomId, evtType as any, evtContent, evtStateKey);
|
return sendStateEvent(mx, room.roomId, evtType, evtContent, evtStateKey);
|
||||||
}
|
}
|
||||||
return mx.sendEvent(room.roomId, evtType as any, evtContent);
|
return sendRoomEvent(mx, room.roomId, null, evtType, evtContent as IContent);
|
||||||
},
|
},
|
||||||
[mx, room],
|
[mx, room],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
|||||||
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||||
|
import { sendStateEvent } from '../../../utils/room';
|
||||||
|
|
||||||
const EDITOR_INTENT_SPACE_COUNT = 2;
|
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEdi
|
|||||||
|
|
||||||
const [submitState, submit] = useAsyncCallback<object, MatrixError, [object]>(
|
const [submitState, submit] = useAsyncCallback<object, MatrixError, [object]>(
|
||||||
useCallback(
|
useCallback(
|
||||||
(c) => mx.sendStateEvent(room.roomId, type as any, c, stateKey),
|
(c) => sendStateEvent(mx, room.roomId, type, c, stateKey),
|
||||||
[mx, room, type, stateKey],
|
[mx, room, type, stateKey],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function RoomRetention({ permissions }: RoomRetentionProps) {
|
|||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Message Retention"
|
title="Message Retention"
|
||||||
description="Messages older than this window disappear from the timeline. Each member can opt in to permanently delete their own expired messages in Settings → General; full server-side deletion also requires homeserver retention to be configured."
|
description="Messages older than this window disappear from the timeline. Each member can opt in to permanently delete their own expired messages in Settings → Messages; full server-side deletion also requires homeserver retention to be configured."
|
||||||
>
|
>
|
||||||
<Box gap="200" alignItems="Center" style={{ flexWrap: 'wrap' }}>
|
<Box gap="200" alignItems="Center" style={{ flexWrap: 'wrap' }}>
|
||||||
{RETENTION_PRESETS.map((preset) => {
|
{RETENTION_PRESETS.map((preset) => {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/use
|
|||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
|
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
|
||||||
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
|
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
|
||||||
|
import { UserBridgeBadge } from '../../components/bridge';
|
||||||
import { useUserPresence } from '../../hooks/useUserPresence';
|
import { useUserPresence } from '../../hooks/useUserPresence';
|
||||||
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
|
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
|
||||||
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
|
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
|
||||||
@@ -178,6 +179,7 @@ function MemberItem({
|
|||||||
{presence && (
|
{presence && (
|
||||||
<PresenceBadge presence={presence.presence} status={presence.status} size="300" />
|
<PresenceBadge presence={presence.presence} status={presence.status} size="300" />
|
||||||
)}
|
)}
|
||||||
|
<UserBridgeBadge userId={member.userId} />
|
||||||
{showEncryption && <MemberVerificationBadge userId={member.userId} />}
|
{showEncryption && <MemberVerificationBadge userId={member.userId} />}
|
||||||
{typing && (
|
{typing && (
|
||||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||||
|
|||||||
@@ -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 { Box, Line } from 'folds';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
@@ -11,7 +11,6 @@ import { WidgetsPanel } from './widgets/WidgetsPanel';
|
|||||||
import { widgetsPanelAtom } from '../../state/widgetsPanel';
|
import { widgetsPanelAtom } from '../../state/widgetsPanel';
|
||||||
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
|
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { useMediaQuery } from '../../hooks/useMediaQuery';
|
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
|
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
@@ -21,10 +20,12 @@ import { markAsRead } from '../../utils/notifications';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||||
import { CallView } from '../call/CallView';
|
import { CallView } from '../call/CallView';
|
||||||
|
import { LANDSCAPE_PHONE_QUERY, shouldGoImmersive } from '../call/landscapeCall';
|
||||||
|
import { useMediaQuery } from '../../hooks/useMediaQuery';
|
||||||
import { RoomViewHeader } from './RoomViewHeader';
|
import { RoomViewHeader } from './RoomViewHeader';
|
||||||
import { callChatAtom } from '../../state/callEmbed';
|
import { callChatAtom, immersiveCallAtom } from '../../state/callEmbed';
|
||||||
import { CallChatView } from './CallChatView';
|
import { CallChatView } from './CallChatView';
|
||||||
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
import { useCallEmbed, useCallJoined } from '../../hooks/useCallEmbed';
|
||||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||||
import { threadsListAtom } from '../../state/threadsList';
|
import { threadsListAtom } from '../../state/threadsList';
|
||||||
@@ -57,6 +58,25 @@ export function Room() {
|
|||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
const members = useRoomMembers(mx, room.roomId);
|
const members = useRoomMembers(mx, room.roomId);
|
||||||
const chat = useAtomValue(callChatAtom);
|
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 —
|
// Hoisted here (instead of inside RoomTimeline) so ThreadPanel/ThreadTimeline —
|
||||||
// rendered as siblings of RoomView, not descendants — share the same computed
|
// rendered as siblings of RoomView, not descendants — share the same computed
|
||||||
// positions instead of falling back to the context default (Gitea #38).
|
// positions instead of falling back to the context default (Gitea #38).
|
||||||
@@ -170,7 +190,8 @@ export function Room() {
|
|||||||
<PowerLevelsContextProvider value={powerLevels}>
|
<PowerLevelsContextProvider value={powerLevels}>
|
||||||
<ReadPositionsContext.Provider value={readPositions}>
|
<ReadPositionsContext.Provider value={readPositions}>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
|
{immersive && <CallView onExitImmersive={() => setImmersiveDismissed(true)} />}
|
||||||
|
{!immersive && callView && (screenSize === ScreenSize.Desktop || !chat) && (
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<RoomViewHeader callView />
|
<RoomViewHeader callView />
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { PageHeader } from '../../components/page';
|
|||||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||||
|
import { RoomBridgeChip } from '../../components/bridge';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||||
@@ -690,6 +691,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
<RoomBridgeChip room={room} />
|
||||||
</Box>
|
</Box>
|
||||||
{topic && (
|
{topic && (
|
||||||
<UseStateProvider initial={false}>
|
<UseStateProvider initial={false}>
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
|||||||
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
|
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
|
||||||
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
|
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
|
||||||
import { PowerIcon } from '../../../components/power';
|
import { PowerIcon } from '../../../components/power';
|
||||||
|
import { UserBridgeBadge } from '../../../components/bridge';
|
||||||
import colorMXID from '../../../../util/colorMXID';
|
import colorMXID from '../../../../util/colorMXID';
|
||||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||||
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
||||||
@@ -1084,6 +1085,7 @@ export const Message = React.memo(
|
|||||||
</Text>
|
</Text>
|
||||||
</Username>
|
</Username>
|
||||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||||
|
<UserBridgeBadge userId={senderId} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" gap="100">
|
<Box shrink="No" gap="100">
|
||||||
{messageLayout === MessageLayout.Modern && hover && (
|
{messageLayout === MessageLayout.Modern && hover && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
|
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
export const MessageBase = style({
|
export const MessageBase = style({
|
||||||
@@ -77,3 +77,14 @@ export const ReactionsMore = style([
|
|||||||
export const ReactionsTooltipText = style({
|
export const ReactionsTooltipText = style({
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// [Gitea #199] Long-press opens the message action sheet on touch screens. On
|
||||||
|
// iOS a long-press otherwise belongs to the system: a link/image callout, or
|
||||||
|
// the text-selection loupe, both of which race (and win against) the 450 ms
|
||||||
|
// press. No callout on message rows, and no selection while a finger is
|
||||||
|
// down; copying text is in the sheet.
|
||||||
|
globalStyle('[data-touch-actions]', { WebkitTouchCallout: 'none' });
|
||||||
|
globalStyle('[data-touch-actions][data-pressing], [data-touch-actions][data-pressing] *', {
|
||||||
|
userSelect: 'none',
|
||||||
|
WebkitUserSelect: 'none',
|
||||||
|
});
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
|||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const thread = useThreadInstance(room, threadId);
|
const thread = useThreadInstance(room, threadId);
|
||||||
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
|
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
|
||||||
// "Hide Typing & Read Receipts" must also make thread receipts private (matches markAsRead).
|
// "Hide typing and read receipts" must also make thread receipts private (matches markAsRead).
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||||
|
import { Box, color, config, Icon, IconButton, Icons, Text } from 'folds';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import {
|
||||||
|
destroyAllWidgetSessions,
|
||||||
|
destroyWidgetSession,
|
||||||
|
FLOATING_WIDGET,
|
||||||
|
floatingWidgetSessions,
|
||||||
|
subscribeWidgetSessions,
|
||||||
|
widgetSessionsVersion,
|
||||||
|
} from './widgetSessions';
|
||||||
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||||
|
import { widgetViewingAtom, widgetsPanelAtom } from '../../../state/widgetsPanel';
|
||||||
|
import { zIndices } from '../../../styles/zIndex';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #205] Title bar for a widget kept on screen (MSC2931) after its room
|
||||||
|
* or the Widgets panel was left. The widget's own frame is positioned by
|
||||||
|
* widgetSessions.ts right below this bar. Mounted once for the signed-in
|
||||||
|
* client; unmounting (logout) stops every widget.
|
||||||
|
*/
|
||||||
|
export function FloatingWidget() {
|
||||||
|
useSyncExternalStore(subscribeWidgetSessions, widgetSessionsVersion);
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
const setPanel = useSetAtom(widgetsPanelAtom);
|
||||||
|
const setViewing = useSetAtom(widgetViewingAtom);
|
||||||
|
useEffect(() => () => destroyAllWidgetSessions(), []);
|
||||||
|
|
||||||
|
const session = floatingWidgetSessions()[0];
|
||||||
|
if (!session) return null;
|
||||||
|
const f = FLOATING_WIDGET;
|
||||||
|
const back = () => {
|
||||||
|
setViewing((prev) => ({ ...prev, [session.roomId]: session.widgetId }));
|
||||||
|
setPanel(true);
|
||||||
|
navigateRoom(session.roomId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
role="region"
|
||||||
|
aria-label={`${session.name} (kept on screen)`}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
right: f.right,
|
||||||
|
bottom: f.bottom + f.height,
|
||||||
|
width: f.width,
|
||||||
|
height: f.bar,
|
||||||
|
zIndex: zIndices.floatingWidget,
|
||||||
|
padding: `0 ${config.space.S100} 0 ${config.space.S300}`,
|
||||||
|
background: color.Surface.Container,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Category} />
|
||||||
|
<Text size="T300" truncate style={{ flexGrow: 1 }}>
|
||||||
|
{session.name}
|
||||||
|
</Text>
|
||||||
|
<IconButton size="300" radii="300" variant="Surface" aria-label="Back to room" onClick={back}>
|
||||||
|
<Icon size="100" src={Icons.ArrowGoLeft} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Surface"
|
||||||
|
aria-label={`Close ${session.name}`}
|
||||||
|
onClick={() => destroyWidgetSession(session.key)}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,133 +1,48 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useSyncExternalStore } from 'react';
|
||||||
import { Box, Icon, Icons, Text, color } from 'folds';
|
import { Box, Icon, Icons, Text, color } from 'folds';
|
||||||
import {
|
import { Room } from 'matrix-js-sdk';
|
||||||
MatrixEvent,
|
import { Widget } from 'matrix-widget-api';
|
||||||
MatrixEventEvent,
|
|
||||||
Room,
|
|
||||||
RoomEvent,
|
|
||||||
RoomEventHandlerMap,
|
|
||||||
RoomStateEvent,
|
|
||||||
} from 'matrix-js-sdk';
|
|
||||||
import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-api';
|
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
|
||||||
import { WidgetPermissionRequest, isWidgetUrlSafe, widgetCompleteUrl } from './widgetUtils';
|
|
||||||
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
|
||||||
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
||||||
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
import {
|
||||||
|
attachWidgetSession,
|
||||||
type PendingAsk = {
|
detachWidgetSession,
|
||||||
requests: WidgetPermissionRequest[];
|
getWidgetSession,
|
||||||
origin: string;
|
subscribeWidgetSessions,
|
||||||
resolve: (allowed: Set<Capability>, remember: boolean) => void;
|
widgetSessionKey,
|
||||||
};
|
widgetSessionsVersion,
|
||||||
|
} from './widgetSessions';
|
||||||
|
|
||||||
type RoomWidgetViewProps = {
|
type RoomWidgetViewProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
widget: Widget;
|
widget: Widget;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hosts one room widget in a sandboxed iframe via ClientWidgetApi (so widgets
|
// Shows one room widget in the Widgets panel. The widget itself runs in a
|
||||||
// that wait on the client handshake load). Display-only access is granted
|
// session (widgetSessions.ts) whose sandboxed iframe lives in an app-level
|
||||||
// silently; reading/sending in this room is asked for (WidgetPermissionPrompt)
|
// layer and is positioned over this placeholder, so a widget that asked to
|
||||||
// and, once granted, the room's live events are fed to the widget.
|
// stay on screen can keep running in a floating window after the panel closes.
|
||||||
// Re-mounts only when the widget id or its (template) URL changes — not on every
|
// Display-only access is granted silently; reading/sending in this room is
|
||||||
// unrelated room-state update — so viewing a widget doesn't reload constantly.
|
// asked for (WidgetPermissionPrompt) and, once granted, the room's live events
|
||||||
|
// are fed to the widget. Re-attaches only when the widget id or its (template)
|
||||||
|
// URL changes, so viewing a widget doesn't reload it on unrelated state updates.
|
||||||
export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const slotRef = useRef<HTMLDivElement>(null);
|
||||||
const widgetRef = useRef(widget);
|
const widgetRef = useRef(widget);
|
||||||
widgetRef.current = widget;
|
widgetRef.current = widget;
|
||||||
const [blocked, setBlocked] = useState(false);
|
const key = widgetSessionKey(room.roomId, widget.id);
|
||||||
const [pendingAsk, setPendingAsk] = useState<PendingAsk>();
|
useSyncExternalStore(subscribeWidgetSessions, widgetSessionsVersion);
|
||||||
|
const session = getWidgetSession(key);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const slot = slotRef.current;
|
||||||
if (!container) return undefined;
|
if (!slot) return undefined;
|
||||||
const current = widgetRef.current;
|
attachWidgetSession(mx, room, widgetRef.current, slot);
|
||||||
|
return () => detachWidgetSession(key, slot);
|
||||||
|
}, [mx, room, key, widget.templateUrl]);
|
||||||
|
|
||||||
const completeUrl = widgetCompleteUrl(current, mx, room.roomId);
|
if (session?.blocked) {
|
||||||
|
|
||||||
// Security: never render a same-origin widget with allow-same-origin (a
|
|
||||||
// same-origin frame could break out of the sandbox against our own origin).
|
|
||||||
if (!isWidgetUrlSafe(completeUrl, window.location.origin)) {
|
|
||||||
setBlocked(true);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
setBlocked(false);
|
|
||||||
|
|
||||||
const iframe = document.createElement('iframe');
|
|
||||||
iframe.title = current.name || 'Widget';
|
|
||||||
iframe.sandbox.value =
|
|
||||||
'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
|
|
||||||
iframe.allow = 'autoplay; clipboard-write;';
|
|
||||||
iframe.src = completeUrl;
|
|
||||||
iframe.style.width = '100%';
|
|
||||||
iframe.style.height = '100%';
|
|
||||||
iframe.style.border = 'none';
|
|
||||||
container.append(iframe);
|
|
||||||
|
|
||||||
let origin = completeUrl;
|
|
||||||
try {
|
|
||||||
origin = new URL(completeUrl).host;
|
|
||||||
} catch {
|
|
||||||
// isWidgetUrlSafe already rejected unparsable URLs
|
|
||||||
}
|
|
||||||
let cancelAsk: (() => void) | undefined;
|
|
||||||
const driver = new GeneralWidgetDriver(
|
|
||||||
mx,
|
|
||||||
room.roomId,
|
|
||||||
{ id: current.id, url: current.templateUrl },
|
|
||||||
(requests) =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
let settled = false;
|
|
||||||
const done = (allowed: Set<Capability>, remember: boolean) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
cancelAsk = undefined;
|
|
||||||
setPendingAsk(undefined);
|
|
||||||
resolve({ allowed, remember });
|
|
||||||
};
|
|
||||||
cancelAsk = () => done(new Set(), false);
|
|
||||||
setPendingAsk({ requests, origin, resolve: done });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const clientApi = new ClientWidgetApi(current, iframe, driver);
|
|
||||||
// Only messages from this widget's own frame and origin are handled, so a
|
|
||||||
// widget can't impersonate another one (or the call).
|
|
||||||
restrictWidgetMessages(clientApi, iframe, current.origin);
|
|
||||||
clientApi.setViewedRoomId(room.roomId);
|
|
||||||
|
|
||||||
// Live room events for widgets that were allowed to see them
|
|
||||||
// (ClientWidgetApi drops anything the widget has no capability for).
|
|
||||||
const feed = (ev: MatrixEvent) => {
|
|
||||||
if (ev.getRoomId() !== room.roomId) return;
|
|
||||||
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
|
|
||||||
clientApi.feedEvent(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
|
|
||||||
};
|
|
||||||
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
|
|
||||||
if (toStart || removed || !data?.liveEvent) return;
|
|
||||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
|
|
||||||
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
|
|
||||||
else feed(ev);
|
|
||||||
};
|
|
||||||
const onState = (ev: MatrixEvent) => {
|
|
||||||
if (ev.getRoomId() !== room.roomId) return;
|
|
||||||
clientApi.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
|
|
||||||
};
|
|
||||||
mx.on(RoomEvent.Timeline, onTimeline);
|
|
||||||
mx.on(RoomStateEvent.Events, onState);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mx.off(RoomEvent.Timeline, onTimeline);
|
|
||||||
mx.off(RoomStateEvent.Events, onState);
|
|
||||||
cancelAsk?.();
|
|
||||||
clientApi.stop();
|
|
||||||
iframe.remove();
|
|
||||||
};
|
|
||||||
}, [mx, room.roomId, widget.id, widget.templateUrl]);
|
|
||||||
|
|
||||||
if (blocked) {
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
||||||
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
|
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
|
||||||
@@ -144,14 +59,14 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
|
<Box ref={slotRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
|
||||||
{pendingAsk && (
|
{session?.pendingAsk && (
|
||||||
<WidgetPermissionPrompt
|
<WidgetPermissionPrompt
|
||||||
widgetName={widget.name || 'Widget'}
|
widgetName={widget.name || 'Widget'}
|
||||||
origin={pendingAsk.origin}
|
origin={session.pendingAsk.origin}
|
||||||
addedBy={addedBy}
|
addedBy={addedBy}
|
||||||
requests={pendingAsk.requests}
|
requests={session.pendingAsk.requests}
|
||||||
onDone={pendingAsk.resolve}
|
onDone={session.pendingAsk.resolve}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { widgetViewingAtom } from '../../../state/widgetsPanel';
|
||||||
import * as css from './WidgetsPanel.css';
|
import * as css from './WidgetsPanel.css';
|
||||||
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||||
import { RoomWidgetView } from './RoomWidgetView';
|
import { RoomWidgetView } from './RoomWidgetView';
|
||||||
@@ -60,7 +62,10 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
|||||||
const permissions = useRoomPermissions(creators, powerLevels);
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
const canModify = permissions.stateEvent(StateEvent.Widget, mx.getSafeUserId());
|
const canModify = permissions.stateEvent(StateEvent.Widget, mx.getSafeUserId());
|
||||||
|
|
||||||
const [viewingId, setViewingId] = useState<string | null>(null);
|
const [viewingByRoom, setViewingByRoom] = useAtom(widgetViewingAtom);
|
||||||
|
const viewingId = viewingByRoom[room.roomId] ?? null;
|
||||||
|
const setViewingId = (id: string | null) =>
|
||||||
|
setViewingByRoom((prev) => ({ ...prev, [room.roomId]: id }));
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import {
|
||||||
|
MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
MatrixEventEvent,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomEventHandlerMap,
|
||||||
|
RoomStateEvent,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import {
|
||||||
|
Capability,
|
||||||
|
ClientWidgetApi,
|
||||||
|
IRoomEvent,
|
||||||
|
IWidgetApiRequest,
|
||||||
|
MatrixCapabilities,
|
||||||
|
Widget,
|
||||||
|
WidgetApiFromWidgetAction,
|
||||||
|
} from 'matrix-widget-api';
|
||||||
|
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
||||||
|
import { WidgetPermissionRequest, isWidgetUrlSafe, widgetCompleteUrl } from './widgetUtils';
|
||||||
|
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
||||||
|
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
||||||
|
import { zIndices } from '../../../styles/zIndex';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #205] Running room widgets, kept outside React so a widget that asked
|
||||||
|
* to stay on screen (MSC2931 `set_always_on_screen`) survives closing the
|
||||||
|
* Widgets panel or switching rooms.
|
||||||
|
*
|
||||||
|
* Moving an iframe in the DOM reloads it, so every widget frame lives from the
|
||||||
|
* start in one app-level fixed layer. While the panel shows the widget, the
|
||||||
|
* frame is positioned over the panel's placeholder (`attach`), so it looks and
|
||||||
|
* behaves as before. When the placeholder goes away (`detach`) the widget is
|
||||||
|
* torn down, unless it is always-on-screen: then the same live frame moves to
|
||||||
|
* a small floating window (see FloatingWidget) until it is closed, the widget
|
||||||
|
* says it no longer needs to be on screen, or it is removed from the room.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PendingAsk = {
|
||||||
|
requests: WidgetPermissionRequest[];
|
||||||
|
origin: string;
|
||||||
|
resolve: (allowed: Set<Capability>, remember: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetSession = {
|
||||||
|
readonly key: string;
|
||||||
|
readonly roomId: string;
|
||||||
|
readonly widgetId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly templateUrl: string;
|
||||||
|
/** The URL is on this app's own origin: never rendered. */
|
||||||
|
readonly blocked: boolean;
|
||||||
|
alwaysOnScreen: boolean;
|
||||||
|
floating: boolean;
|
||||||
|
pendingAsk?: PendingAsk;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Internal = WidgetSession & {
|
||||||
|
frame: HTMLDivElement;
|
||||||
|
slot: HTMLElement | null;
|
||||||
|
raf: number | undefined;
|
||||||
|
stop: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The floating window's geometry (FloatingWidget draws its title bar above). */
|
||||||
|
export const FLOATING_WIDGET = { right: 16, bottom: 96, width: 360, height: 240, bar: 36 };
|
||||||
|
|
||||||
|
const DOCKED_Z = 600; // just above the side panel (500 on phones), below popovers/modals
|
||||||
|
|
||||||
|
const sessions = new Map<string, Internal>();
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let version = 0;
|
||||||
|
const notify = () => {
|
||||||
|
version += 1;
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
};
|
||||||
|
export const subscribeWidgetSessions = (l: () => void): (() => void) => {
|
||||||
|
listeners.add(l);
|
||||||
|
return () => listeners.delete(l);
|
||||||
|
};
|
||||||
|
export const widgetSessionsVersion = (): number => version;
|
||||||
|
|
||||||
|
export const widgetSessionKey = (roomId: string, widgetId: string): string =>
|
||||||
|
`${roomId}|${widgetId}`;
|
||||||
|
|
||||||
|
export const getWidgetSession = (key: string): WidgetSession | undefined => sessions.get(key);
|
||||||
|
|
||||||
|
export const floatingWidgetSessions = (): WidgetSession[] =>
|
||||||
|
[...sessions.values()].filter((s) => s.floating);
|
||||||
|
|
||||||
|
let layerEl: HTMLDivElement | undefined;
|
||||||
|
const layer = (): HTMLDivElement => {
|
||||||
|
if (!layerEl || !layerEl.isConnected) {
|
||||||
|
layerEl = document.createElement('div');
|
||||||
|
layerEl.id = 'lotus-widget-layer';
|
||||||
|
// The layer itself is click-through; only the frames inside take input.
|
||||||
|
Object.assign(layerEl.style, { position: 'fixed', inset: '0', pointerEvents: 'none' });
|
||||||
|
document.body.append(layerEl);
|
||||||
|
}
|
||||||
|
return layerEl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideFrame = (s: Internal) => {
|
||||||
|
s.frame.style.visibility = 'hidden';
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeDocked = (s: Internal) => {
|
||||||
|
const slot = s.slot;
|
||||||
|
if (!slot) return;
|
||||||
|
const r = slot.getBoundingClientRect();
|
||||||
|
if (r.width === 0 || r.height === 0) {
|
||||||
|
hideFrame(s);
|
||||||
|
} else {
|
||||||
|
Object.assign(s.frame.style, {
|
||||||
|
visibility: 'visible',
|
||||||
|
left: `${r.left}px`,
|
||||||
|
top: `${r.top}px`,
|
||||||
|
width: `${r.width}px`,
|
||||||
|
height: `${r.height}px`,
|
||||||
|
right: '',
|
||||||
|
bottom: '',
|
||||||
|
borderRadius: '0',
|
||||||
|
boxShadow: 'none',
|
||||||
|
zIndex: String(DOCKED_Z),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
s.raf = requestAnimationFrame(() => placeDocked(s));
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeFloating = (s: Internal) => {
|
||||||
|
const f = FLOATING_WIDGET;
|
||||||
|
Object.assign(s.frame.style, {
|
||||||
|
visibility: 'visible',
|
||||||
|
left: '',
|
||||||
|
top: '',
|
||||||
|
right: `${f.right}px`,
|
||||||
|
bottom: `${f.bottom}px`,
|
||||||
|
width: `${f.width}px`,
|
||||||
|
height: `${f.height}px`,
|
||||||
|
borderRadius: '0 0 8px 8px',
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
|
||||||
|
zIndex: String(zIndices.floatingWidget),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPlacing = (s: Internal) => {
|
||||||
|
if (s.raf !== undefined) cancelAnimationFrame(s.raf);
|
||||||
|
s.raf = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const destroyWidgetSession = (key: string): void => {
|
||||||
|
const s = sessions.get(key);
|
||||||
|
if (!s) return;
|
||||||
|
sessions.delete(key);
|
||||||
|
stopPlacing(s);
|
||||||
|
s.stop();
|
||||||
|
s.frame.remove();
|
||||||
|
notify();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const destroyAllWidgetSessions = (): void => {
|
||||||
|
[...sessions.keys()].forEach(destroyWidgetSession);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Start (or reuse) the widget's session and dock its frame over `slot`. */
|
||||||
|
export function attachWidgetSession(
|
||||||
|
mx: MatrixClient,
|
||||||
|
room: Room,
|
||||||
|
widget: Widget,
|
||||||
|
slot: HTMLElement,
|
||||||
|
): WidgetSession {
|
||||||
|
const key = widgetSessionKey(room.roomId, widget.id);
|
||||||
|
let s = sessions.get(key);
|
||||||
|
if (s && s.templateUrl !== widget.templateUrl) {
|
||||||
|
destroyWidgetSession(key);
|
||||||
|
s = undefined;
|
||||||
|
}
|
||||||
|
if (!s) s = startSession(mx, room, widget, key);
|
||||||
|
stopPlacing(s);
|
||||||
|
s.slot = slot;
|
||||||
|
s.floating = false;
|
||||||
|
if (!s.blocked) placeDocked(s);
|
||||||
|
notify();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The panel stopped showing the widget: float it, or tear it down. */
|
||||||
|
export function detachWidgetSession(key: string, slot: HTMLElement): void {
|
||||||
|
const s = sessions.get(key);
|
||||||
|
if (!s || s.slot !== slot) return;
|
||||||
|
s.slot = null;
|
||||||
|
stopPlacing(s);
|
||||||
|
if (!s.alwaysOnScreen || s.blocked) {
|
||||||
|
destroyWidgetSession(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// One floating widget at a time: a newer one replaces an older one.
|
||||||
|
floatingWidgetSessions()
|
||||||
|
.filter((o) => o.key !== key)
|
||||||
|
.forEach((o) => destroyWidgetSession(o.key));
|
||||||
|
// Nobody is there to answer a permission prompt any more.
|
||||||
|
s.pendingAsk?.resolve(new Set(), false);
|
||||||
|
s.floating = true;
|
||||||
|
placeFloating(s);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSession(mx: MatrixClient, room: Room, widget: Widget, key: string): Internal {
|
||||||
|
const frame = document.createElement('div');
|
||||||
|
Object.assign(frame.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
overflow: 'hidden',
|
||||||
|
visibility: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
const completeUrl = widgetCompleteUrl(widget, mx, room.roomId);
|
||||||
|
const blocked = !isWidgetUrlSafe(completeUrl, window.location.origin);
|
||||||
|
const s: Internal = {
|
||||||
|
key,
|
||||||
|
roomId: room.roomId,
|
||||||
|
widgetId: widget.id,
|
||||||
|
name: widget.name || 'Widget',
|
||||||
|
templateUrl: widget.templateUrl,
|
||||||
|
blocked,
|
||||||
|
alwaysOnScreen: false,
|
||||||
|
floating: false,
|
||||||
|
frame,
|
||||||
|
slot: null,
|
||||||
|
raf: undefined,
|
||||||
|
stop: () => undefined,
|
||||||
|
};
|
||||||
|
sessions.set(key, s);
|
||||||
|
if (blocked) return s;
|
||||||
|
|
||||||
|
// Security: never render a same-origin widget with allow-same-origin (a
|
||||||
|
// same-origin frame could break out of the sandbox against our own origin).
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.title = widget.name || 'Widget';
|
||||||
|
iframe.sandbox.value = 'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
|
||||||
|
iframe.allow = 'autoplay; clipboard-write;';
|
||||||
|
iframe.src = completeUrl;
|
||||||
|
Object.assign(iframe.style, { width: '100%', height: '100%', border: 'none' });
|
||||||
|
frame.append(iframe);
|
||||||
|
layer().append(frame);
|
||||||
|
|
||||||
|
let origin = completeUrl;
|
||||||
|
try {
|
||||||
|
origin = new URL(completeUrl).host;
|
||||||
|
} catch {
|
||||||
|
// isWidgetUrlSafe already rejected unparsable URLs
|
||||||
|
}
|
||||||
|
let cancelAsk: (() => void) | undefined;
|
||||||
|
const driver = new GeneralWidgetDriver(
|
||||||
|
mx,
|
||||||
|
room.roomId,
|
||||||
|
{ id: widget.id, url: widget.templateUrl },
|
||||||
|
(requests) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const done = (allowed: Set<Capability>, remember: boolean) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cancelAsk = undefined;
|
||||||
|
s.pendingAsk = undefined;
|
||||||
|
notify();
|
||||||
|
resolve({ allowed, remember });
|
||||||
|
};
|
||||||
|
cancelAsk = () => done(new Set(), false);
|
||||||
|
// A floating widget has nobody to ask: grant nothing extra.
|
||||||
|
if (s.floating) {
|
||||||
|
done(new Set(), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.pendingAsk = { requests, origin, resolve: done };
|
||||||
|
notify();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const clientApi = new ClientWidgetApi(widget, iframe, driver);
|
||||||
|
// Only messages from this widget's own frame and origin are handled, so a
|
||||||
|
// widget can't impersonate another one (or the call).
|
||||||
|
restrictWidgetMessages(clientApi, iframe, widget.origin);
|
||||||
|
clientApi.setViewedRoomId(room.roomId);
|
||||||
|
|
||||||
|
// MSC2931: the widget asks to stay visible when the user navigates away.
|
||||||
|
const onAlwaysOnScreen = (ev: CustomEvent<IWidgetApiRequest>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!clientApi.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
|
||||||
|
clientApi.transport.reply(ev.detail, {
|
||||||
|
error: { message: 'Missing capability' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = (ev.detail.data as { value?: unknown } | undefined)?.value === true;
|
||||||
|
s.alwaysOnScreen = value;
|
||||||
|
clientApi.transport.reply(ev.detail, {});
|
||||||
|
// Floating only because it asked: once it no longer needs to be, go.
|
||||||
|
if (!value && s.floating) destroyWidgetSession(key);
|
||||||
|
else notify();
|
||||||
|
};
|
||||||
|
clientApi.on(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, onAlwaysOnScreen);
|
||||||
|
|
||||||
|
// Live room events for widgets that were allowed to see them
|
||||||
|
// (ClientWidgetApi drops anything the widget has no capability for).
|
||||||
|
const feed = (ev: MatrixEvent) => {
|
||||||
|
if (ev.getRoomId() !== room.roomId) return;
|
||||||
|
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
|
||||||
|
clientApi.feedEvent(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
|
||||||
|
};
|
||||||
|
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
|
||||||
|
if (toStart || removed || !data?.liveEvent) return;
|
||||||
|
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
|
||||||
|
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
|
||||||
|
else feed(ev);
|
||||||
|
};
|
||||||
|
const onState = (ev: MatrixEvent) => {
|
||||||
|
if (ev.getRoomId() !== room.roomId) return;
|
||||||
|
// The widget was removed from the room while it was floating.
|
||||||
|
if (
|
||||||
|
s.floating &&
|
||||||
|
(ev.getType() === 'im.vector.modular.widgets' || ev.getType() === 'm.widget') &&
|
||||||
|
ev.getStateKey() === widget.id &&
|
||||||
|
!ev.getContent()?.url
|
||||||
|
) {
|
||||||
|
destroyWidgetSession(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clientApi.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
|
||||||
|
};
|
||||||
|
mx.on(RoomEvent.Timeline, onTimeline);
|
||||||
|
mx.on(RoomStateEvent.Events, onState);
|
||||||
|
|
||||||
|
s.stop = () => {
|
||||||
|
mx.off(RoomEvent.Timeline, onTimeline);
|
||||||
|
mx.off(RoomStateEvent.Events, onState);
|
||||||
|
clientApi.off(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, onAlwaysOnScreen);
|
||||||
|
cancelAsk?.();
|
||||||
|
clientApi.stop();
|
||||||
|
};
|
||||||
|
return s;
|
||||||
|
}
|
||||||
@@ -23,8 +23,11 @@ import {
|
|||||||
nudgeFor,
|
nudgeFor,
|
||||||
NudgeRecord,
|
NudgeRecord,
|
||||||
parseNudgeRecord,
|
parseNudgeRecord,
|
||||||
shouldShowNudge,
|
pickNudge,
|
||||||
|
staleSessions,
|
||||||
|
staleSessionsText,
|
||||||
} from '../../utils/securityNudge';
|
} from '../../utils/securityNudge';
|
||||||
|
import { preselectSignOutAtom } from '../../state/staleSessions';
|
||||||
import { StatusStrip } from '../server-status/ServerStatusBanner';
|
import { StatusStrip } from '../server-status/ServerStatusBanner';
|
||||||
|
|
||||||
/** This device's backup connection: undefined until known, then true/false. */
|
/** This device's backup connection: undefined until known, then true/false. */
|
||||||
@@ -40,6 +43,35 @@ const useBackupActive = (crypto: CryptoApi): boolean | undefined => {
|
|||||||
return active;
|
return active;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #113] Ids of this account's sessions unused for 90+ days. Fetched
|
||||||
|
* once sync has settled, then hourly; [] until known or on error.
|
||||||
|
*/
|
||||||
|
const useStaleSessionIds = (enabled: boolean): string[] => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const alive = useAlive();
|
||||||
|
const [ids, setIds] = useState<string[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return undefined;
|
||||||
|
const load = () =>
|
||||||
|
mx
|
||||||
|
.getDevices()
|
||||||
|
.then(({ devices }) => {
|
||||||
|
if (alive())
|
||||||
|
setIds(
|
||||||
|
staleSessions(devices, mx.getDeviceId() ?? undefined, Date.now()).map(
|
||||||
|
(d) => d.device_id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
load();
|
||||||
|
const t = window.setInterval(load, 60 * 60 * 1000);
|
||||||
|
return () => window.clearInterval(t);
|
||||||
|
}, [mx, enabled, alive]);
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
|
||||||
const readRecord = (key: string): NudgeRecord => {
|
const readRecord = (key: string): NudgeRecord => {
|
||||||
let raw: string | null = null;
|
let raw: string | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -84,12 +116,15 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
|||||||
let deviceVerified: boolean | undefined;
|
let deviceVerified: boolean | undefined;
|
||||||
if (status === VerificationStatus.Verified) deviceVerified = true;
|
if (status === VerificationStatus.Verified) deviceVerified = true;
|
||||||
else if (status === VerificationStatus.Unverified) deviceVerified = false;
|
else if (status === VerificationStatus.Unverified) deviceVerified = false;
|
||||||
const kind = nudgeFor({
|
const securityKind = nudgeFor({
|
||||||
crossSigning,
|
crossSigning,
|
||||||
deviceVerified,
|
deviceVerified,
|
||||||
backupOnServer: backupInfo === undefined ? undefined : backupInfo !== null,
|
backupOnServer: backupInfo === undefined ? undefined : backupInfo !== null,
|
||||||
backupActive,
|
backupActive,
|
||||||
});
|
});
|
||||||
|
const staleIds = useStaleSessionIds(settled);
|
||||||
|
const setPreselect = useSetAtom(preselectSignOutAtom);
|
||||||
|
const kind = pickNudge(securityKind, staleIds.length, record, Date.now());
|
||||||
|
|
||||||
// One strip at a time: outages, maintenance and a lost connection win.
|
// One strip at a time: outages, maintenance and a lost connection win.
|
||||||
// [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't
|
// [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't
|
||||||
@@ -97,10 +132,12 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
|||||||
const otherStrip =
|
const otherStrip =
|
||||||
syncLost ||
|
syncLost ||
|
||||||
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
|
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
|
||||||
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
|
if (!settled || !deviceId || !kind || otherStrip) return null;
|
||||||
return null;
|
|
||||||
|
|
||||||
const copy = NUDGE_COPY[kind];
|
const copy =
|
||||||
|
kind === 'stale-sessions'
|
||||||
|
? { ...NUDGE_COPY[kind], text: staleSessionsText(staleIds.length) }
|
||||||
|
: NUDGE_COPY[kind];
|
||||||
const dismiss = () => {
|
const dismiss = () => {
|
||||||
const next = dismissNudge(kind, record, Date.now());
|
const next = dismissNudge(kind, record, Date.now());
|
||||||
try {
|
try {
|
||||||
@@ -113,8 +150,15 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
|||||||
return (
|
return (
|
||||||
<StatusStrip
|
<StatusStrip
|
||||||
banner={{ key: kind, tone: 'Primary', text: copy.text, dismissable: true }}
|
banner={{ key: kind, tone: 'Primary', text: copy.text, dismissable: true }}
|
||||||
icon={Icons.ShieldUser}
|
action={{
|
||||||
action={{ label: copy.action, onClick: () => requestSettings('devices') }}
|
label: copy.action,
|
||||||
|
onClick: () => {
|
||||||
|
// Review: the Devices page opens with these ticked for sign-out.
|
||||||
|
if (kind === 'stale-sessions') setPreselect(staleIds);
|
||||||
|
requestSettings('devices');
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
icon={kind === 'stale-sessions' ? Icons.Category : Icons.ShieldUser}
|
||||||
dismissLabel="Not now"
|
dismissLabel="Not now"
|
||||||
onDismiss={dismiss}
|
onDismiss={dismiss}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -13,9 +13,19 @@ import {
|
|||||||
OverlayBackdrop,
|
OverlayBackdrop,
|
||||||
OverlayCenter,
|
OverlayCenter,
|
||||||
Text,
|
Text,
|
||||||
|
color,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { General } from './general';
|
import {
|
||||||
|
AdvancedDisplayOptions,
|
||||||
|
AppearancePage,
|
||||||
|
CallsPage,
|
||||||
|
DataStoragePage,
|
||||||
|
DesktopPage,
|
||||||
|
MessagesPage,
|
||||||
|
Privacy,
|
||||||
|
} from './general';
|
||||||
|
import { isTauri as isTauriEnv } from '../../hooks/useTauri';
|
||||||
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
|
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { Account } from './account';
|
import { Account } from './account';
|
||||||
@@ -34,6 +44,8 @@ import { UseStateProvider } from '../../components/UseStateProvider';
|
|||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||||
import { SettingsPages } from './pages';
|
import { SettingsPages } from './pages';
|
||||||
|
import { SettingsSearch } from './search/SettingsSearch';
|
||||||
|
import type { SettingsEntry } from './search/settingsIndex';
|
||||||
|
|
||||||
export { SettingsPages };
|
export { SettingsPages };
|
||||||
|
|
||||||
@@ -45,43 +57,23 @@ type SettingsMenuItem = {
|
|||||||
|
|
||||||
const useSettingsMenuItems = (): SettingsMenuItem[] =>
|
const useSettingsMenuItems = (): SettingsMenuItem[] =>
|
||||||
useMemo(
|
useMemo(
|
||||||
() => [
|
() =>
|
||||||
{
|
[
|
||||||
page: SettingsPages.GeneralPage,
|
{ page: SettingsPages.AccountPage, name: 'Account', icon: Icons.User },
|
||||||
name: 'General',
|
{ page: SettingsPages.AppearancePage, name: 'Appearance', icon: Icons.Sun },
|
||||||
icon: Icons.Setting,
|
{ page: SettingsPages.MessagesPage, name: 'Messages', icon: Icons.Message },
|
||||||
},
|
{ page: SettingsPages.NotificationPage, name: 'Notifications', icon: Icons.Bell },
|
||||||
{
|
{ page: SettingsPages.CallsPage, name: 'Calls & audio', icon: Icons.Phone },
|
||||||
page: SettingsPages.AccountPage,
|
{ page: SettingsPages.DevicesPage, name: 'Privacy & security', icon: Icons.ShieldUser },
|
||||||
name: 'Account',
|
{ page: SettingsPages.DataStoragePage, name: 'Data & storage', icon: Icons.Download },
|
||||||
icon: Icons.User,
|
// [Gitea #459] Desktop-only options: nothing to show in a browser.
|
||||||
},
|
...(isTauriEnv()
|
||||||
{
|
? [{ page: SettingsPages.DesktopPage, name: 'Desktop', icon: Icons.Monitor }]
|
||||||
page: SettingsPages.NotificationPage,
|
: []),
|
||||||
name: 'Notifications',
|
{ page: SettingsPages.EmojisStickersPage, name: 'Emojis & stickers', icon: Icons.Smile },
|
||||||
icon: Icons.Bell,
|
{ page: SettingsPages.DeveloperToolsPage, name: 'Advanced', icon: Icons.Terminal },
|
||||||
},
|
{ page: SettingsPages.AboutPage, name: 'About', icon: Icons.Info },
|
||||||
{
|
] as SettingsMenuItem[],
|
||||||
page: SettingsPages.DevicesPage,
|
|
||||||
name: 'Devices',
|
|
||||||
icon: Icons.Monitor,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
page: SettingsPages.EmojisStickersPage,
|
|
||||||
name: 'Emojis & Stickers',
|
|
||||||
icon: Icons.Smile,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
page: SettingsPages.DeveloperToolsPage,
|
|
||||||
name: 'Developer Tools',
|
|
||||||
icon: Icons.Terminal,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
page: SettingsPages.AboutPage,
|
|
||||||
name: 'About',
|
|
||||||
icon: Icons.Info,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -102,9 +94,52 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const [activePage, setActivePage] = useState<SettingsPages | undefined>(() => {
|
const [activePage, setActivePage] = useState<SettingsPages | undefined>(() => {
|
||||||
if (initialPage) return initialPage;
|
if (initialPage) return initialPage;
|
||||||
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
|
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.AccountPage;
|
||||||
});
|
});
|
||||||
const menuItems = useSettingsMenuItems();
|
const menuItems = useSettingsMenuItems();
|
||||||
|
// [Gitea #463] Settings search: results replace the page list while typing;
|
||||||
|
// picking one opens its page and briefly highlights the setting.
|
||||||
|
const pageNames = useMemo(
|
||||||
|
() => new Map(menuItems.map((item) => [item.page, item.name])),
|
||||||
|
[menuItems],
|
||||||
|
);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [highlight, setHighlight] = useState<string>();
|
||||||
|
const pickResult = useCallback((entry: SettingsEntry) => {
|
||||||
|
setActivePage(entry.page);
|
||||||
|
setHighlight(entry.title);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlight) return undefined;
|
||||||
|
let tries = 0;
|
||||||
|
let flashTimer: number | undefined;
|
||||||
|
const find = () => {
|
||||||
|
const match = [...document.querySelectorAll<HTMLElement>('p, span, label, h1, h2, h3')].find(
|
||||||
|
(el) =>
|
||||||
|
!el.closest('nav') && el.textContent?.trim() === highlight && el.offsetParent !== null,
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
tries += 1;
|
||||||
|
if (tries < 20) poll = window.setTimeout(find, 100);
|
||||||
|
else setHighlight(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = (match.closest('[class*="SequenceCard"]') as HTMLElement | null) ?? match;
|
||||||
|
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
const prev = target.style.boxShadow;
|
||||||
|
target.style.boxShadow = `0 0 0 2px ${color.Primary.Main}`;
|
||||||
|
target.style.transition = 'box-shadow 0.3s';
|
||||||
|
flashTimer = window.setTimeout(() => {
|
||||||
|
target.style.boxShadow = prev;
|
||||||
|
setHighlight(undefined);
|
||||||
|
}, 1800);
|
||||||
|
};
|
||||||
|
let poll = window.setTimeout(find, 50);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(poll);
|
||||||
|
window.clearTimeout(flashTimer);
|
||||||
|
};
|
||||||
|
}, [highlight, activePage]);
|
||||||
|
|
||||||
const handlePageRequestClose = () => {
|
const handlePageRequestClose = () => {
|
||||||
if (screenSize === ScreenSize.Mobile) {
|
if (screenSize === ScreenSize.Mobile) {
|
||||||
@@ -145,8 +180,13 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</PageNavHeader>
|
</PageNavHeader>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
|
<SettingsSearch
|
||||||
|
pageNames={pageNames}
|
||||||
|
onActiveChange={setSearching}
|
||||||
|
onPick={pickResult}
|
||||||
|
/>
|
||||||
<PageNavContent>
|
<PageNavContent>
|
||||||
<nav aria-label="Settings sections" style={{ flexGrow: 1 }}>
|
<nav aria-label="Settings sections" style={{ flexGrow: 1 }} hidden={searching}>
|
||||||
{menuItems.map((item) => (
|
{menuItems.map((item) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={item.name}
|
key={item.name}
|
||||||
@@ -207,8 +247,20 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{activePage === SettingsPages.GeneralPage && (
|
{activePage === SettingsPages.AppearancePage && (
|
||||||
<General requestClose={handlePageRequestClose} />
|
<AppearancePage requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
|
{activePage === SettingsPages.MessagesPage && (
|
||||||
|
<MessagesPage requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
|
{activePage === SettingsPages.CallsPage && (
|
||||||
|
<CallsPage requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
|
{activePage === SettingsPages.DataStoragePage && (
|
||||||
|
<DataStoragePage requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
|
{activePage === SettingsPages.DesktopPage && (
|
||||||
|
<DesktopPage requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
{activePage === SettingsPages.AccountPage && (
|
{activePage === SettingsPages.AccountPage && (
|
||||||
<Account requestClose={handlePageRequestClose} />
|
<Account requestClose={handlePageRequestClose} />
|
||||||
@@ -217,13 +269,21 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|||||||
<Notifications requestClose={handlePageRequestClose} />
|
<Notifications requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
{activePage === SettingsPages.DevicesPage && (
|
{activePage === SettingsPages.DevicesPage && (
|
||||||
<Devices requestClose={handlePageRequestClose} />
|
<Devices
|
||||||
|
title="Privacy & security"
|
||||||
|
before={<Privacy />}
|
||||||
|
requestClose={handlePageRequestClose}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{activePage === SettingsPages.EmojisStickersPage && (
|
{activePage === SettingsPages.EmojisStickersPage && (
|
||||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
{activePage === SettingsPages.DeveloperToolsPage && (
|
{activePage === SettingsPages.DeveloperToolsPage && (
|
||||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
<DeveloperTools
|
||||||
|
title="Advanced"
|
||||||
|
before={<AdvancedDisplayOptions />}
|
||||||
|
requestClose={handlePageRequestClose}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
|
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
|
||||||
</PageRoot>
|
</PageRoot>
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ export function About({ requestClose }: AboutProps) {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Clear Cache & Reload"
|
title="Clear cache and reload"
|
||||||
description="Clear all your locally stored data and reload from server. This also deletes this device's encryption keys — older encrypted messages stay unreadable here unless key backup is set up."
|
description="Clear all your locally stored data and reload from server. This also deletes this device's encryption keys — older encrypted messages stay unreadable here unless key backup is set up."
|
||||||
after={
|
after={
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ export function ContactInformation() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Contact Information</Text>
|
<Text size="L400">Contact information</Text>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile title="Email Address" description="Email address attached to your account.">
|
<SettingTile title="Email address" description="Email address attached to your account.">
|
||||||
<Box>
|
<Box>
|
||||||
{emailIds?.map((email) => (
|
{emailIds?.map((email) => (
|
||||||
<Chip key={email.address} as="span" variant="Secondary" radii="Pill">
|
<Chip key={email.address} as="span" variant="Secondary" radii="Pill">
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function IgnoredUserList() {
|
|||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||||
<Text size="L400">Blocked Users</Text>
|
<Text size="L400">Blocked users</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
@@ -145,8 +145,8 @@ export function IgnoredUserList() {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Select User"
|
title="Block someone"
|
||||||
description="Prevent receiving messages or invites from user by adding their userId."
|
description="Stop someone's messages and invites reaching you. Enter their Matrix ID, e.g. @name:server."
|
||||||
>
|
>
|
||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
<IgnoreUserInput userList={ignoredUsers} />
|
<IgnoreUserInput userList={ignoredUsers} />
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function OidcManageAccount() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Account Management</Text>
|
<Text size="L400">Account management</Text>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|||||||
@@ -38,11 +38,7 @@ import { SettingTile } from '../../../components/setting-tile';
|
|||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { getAccountData, setAccountData } from '../../../utils/accountData';
|
import { getAccountData, setAccountData } from '../../../utils/accountData';
|
||||||
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
|
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
|
||||||
import {
|
import { describePresenceError, queuePresenceWrite } from '../../../utils/presenceWrite';
|
||||||
describePresenceError,
|
|
||||||
setPresenceWithRetry,
|
|
||||||
writePresence,
|
|
||||||
} from '../../../utils/presenceWrite';
|
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||||
@@ -313,7 +309,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
|||||||
<SettingTile
|
<SettingTile
|
||||||
title={
|
title={
|
||||||
<Text as="span" size="L400">
|
<Text as="span" size="L400">
|
||||||
Display Name
|
Display name
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -419,6 +415,10 @@ function ProfileStatus() {
|
|||||||
// until our own echo lands or a short window elapses (bounded so a genuinely
|
// until our own echo lands or a short window elapses (bounded so a genuinely
|
||||||
// dropped echo can't block real cross-device updates forever).
|
// dropped echo can't block real cross-device updates forever).
|
||||||
const pendingAppliedRef = useRef<{ value: string; ts: number } | null>(null);
|
const pendingAppliedRef = useRef<{ value: string; ts: number } | null>(null);
|
||||||
|
// The status as this device last applied or synced it. "Save" compares with
|
||||||
|
// this, not with the server echo, so it switches off the moment a status is
|
||||||
|
// applied instead of staying lit until the (rate-limited) write lands.
|
||||||
|
const [savedMsg, setSavedMsg] = useState<string>(initialStatus);
|
||||||
const [clearAfter, setClearAfter] = useState('0');
|
const [clearAfter, setClearAfter] = useState('0');
|
||||||
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
||||||
const { presets, addPreset, removePreset } = useStatusPresets();
|
const { presets, addPreset, removePreset } = useStatusPresets();
|
||||||
@@ -442,7 +442,7 @@ function ProfileStatus() {
|
|||||||
if (pending) {
|
if (pending) {
|
||||||
if (remoteStatus === pending.value) {
|
if (remoteStatus === pending.value) {
|
||||||
pendingAppliedRef.current = null; // our own echo landed — accept it
|
pendingAppliedRef.current = null; // our own echo landed — accept it
|
||||||
} else if (Date.now() - pending.ts < 15_000) {
|
} else if (Date.now() - pending.ts < 30_000) {
|
||||||
return; // stale echo of the previous status; ignore within the window
|
return; // stale echo of the previous status; ignore within the window
|
||||||
} else {
|
} else {
|
||||||
pendingAppliedRef.current = null; // window elapsed — accept external changes
|
pendingAppliedRef.current = null; // window elapsed — accept external changes
|
||||||
@@ -451,6 +451,7 @@ function ProfileStatus() {
|
|||||||
// [Gitea #283] "dnd" is the Do Not Disturb sentinel, not something the user
|
// [Gitea #283] "dnd" is the Do Not Disturb sentinel, not something the user
|
||||||
// typed: don't show it in the status field or keep it as their status.
|
// typed: don't show it in the status field or keep it as their status.
|
||||||
if (remoteStatus === DND_STATUS_SENTINEL) return;
|
if (remoteStatus === DND_STATUS_SENTINEL) return;
|
||||||
|
setSavedMsg(remoteStatus);
|
||||||
if (remoteStatus) {
|
if (remoteStatus) {
|
||||||
setStatusMsg(remoteStatus);
|
setStatusMsg(remoteStatus);
|
||||||
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
|
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
|
||||||
@@ -467,9 +468,10 @@ function ProfileStatus() {
|
|||||||
useCallback(
|
useCallback(
|
||||||
(msg: string) =>
|
(msg: string) =>
|
||||||
// Synapse allows ONE presence write per 10 s per user (shared by all
|
// Synapse allows ONE presence write per 10 s per user (shared by all
|
||||||
// devices) and our heartbeat spends that budget too — wait out a 429
|
// devices) and our heartbeat spends that budget too. The shared queue
|
||||||
// instead of failing the user's save.
|
// waits out a 429 and folds quick successive changes into one write;
|
||||||
setPresenceWithRetry(mx, {
|
// the UI doesn't wait on it (the status is applied locally at once).
|
||||||
|
queuePresenceWrite(mx, {
|
||||||
// Derive presence from the user's chosen setting so writing a status
|
// Derive presence from the user's chosen setting so writing a status
|
||||||
// never overrides Invisible/DND/Idle (e.g. outing an Invisible user).
|
// never overrides Invisible/DND/Idle (e.g. outing an Invisible user).
|
||||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||||
@@ -498,6 +500,7 @@ function ProfileStatus() {
|
|||||||
(rawMsg: string, clearAfterValue: string) => {
|
(rawMsg: string, clearAfterValue: string) => {
|
||||||
statusDirtyRef.current = false;
|
statusDirtyRef.current = false;
|
||||||
const msg = rawMsg.trim();
|
const msg = rawMsg.trim();
|
||||||
|
setSavedMsg(msg);
|
||||||
// Guard against a stale presence echo reverting this value (see the sync effect).
|
// Guard against a stale presence echo reverting this value (see the sync effect).
|
||||||
pendingAppliedRef.current = { value: msg, ts: Date.now() };
|
pendingAppliedRef.current = { value: msg, ts: Date.now() };
|
||||||
noteLocalStatusWrite();
|
noteLocalStatusWrite();
|
||||||
@@ -527,19 +530,17 @@ function ProfileStatus() {
|
|||||||
|
|
||||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
if (saving) return;
|
|
||||||
applyStatus(statusMsg, clearAfter);
|
applyStatus(statusMsg, clearAfter);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Preset click = one-click apply: reflect it in the inputs and save immediately.
|
// Preset click = one-click apply: reflect it in the inputs and save immediately.
|
||||||
const applyPreset = useCallback(
|
const applyPreset = useCallback(
|
||||||
(preset: StatusPreset) => {
|
(preset: StatusPreset) => {
|
||||||
if (saving) return;
|
|
||||||
setStatusMsg(preset.label);
|
setStatusMsg(preset.label);
|
||||||
setClearAfter(preset.clearAfter);
|
setClearAfter(preset.clearAfter);
|
||||||
applyStatus(preset.label, preset.clearAfter);
|
applyStatus(preset.label, preset.clearAfter);
|
||||||
},
|
},
|
||||||
[saving, applyStatus],
|
[applyStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSaveCurrent = useCallback(() => {
|
const handleSaveCurrent = useCallback(() => {
|
||||||
@@ -560,23 +561,24 @@ function ProfileStatus() {
|
|||||||
pendingAppliedRef.current = { value: '', ts: Date.now() };
|
pendingAppliedRef.current = { value: '', ts: Date.now() };
|
||||||
noteLocalStatusWrite();
|
noteLocalStatusWrite();
|
||||||
setStatusMsg('');
|
setStatusMsg('');
|
||||||
|
setSavedMsg('');
|
||||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||||
writeStatusExpiry(mx, '').catch(() => undefined);
|
writeStatusExpiry(mx, '').catch(() => undefined);
|
||||||
// Preserve the user's chosen presence when clearing the status message.
|
// Preserve the user's chosen presence when clearing the status message.
|
||||||
writePresence(mx, {
|
queuePresenceWrite(mx, {
|
||||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||||
status_msg: '',
|
status_msg: '',
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasChanges = statusMsg !== (presence?.status ?? '');
|
const hasChanges = statusMsg.trim() !== savedMsg;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title={
|
title={
|
||||||
<Text as="span" size="L400">
|
<Text as="span" size="L400">
|
||||||
Status Message
|
Status message
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
@@ -599,7 +601,6 @@ function ProfileStatus() {
|
|||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
disabled={saving}
|
|
||||||
onClick={() => applyPreset(preset)}
|
onClick={() => applyPreset(preset)}
|
||||||
>
|
>
|
||||||
<Text as="span" size="B300">
|
<Text as="span" size="B300">
|
||||||
@@ -625,7 +626,6 @@ function ProfileStatus() {
|
|||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
disabled={saving}
|
|
||||||
onClick={() => applyPreset(preset)}
|
onClick={() => applyPreset(preset)}
|
||||||
>
|
>
|
||||||
<Text as="span" size="B300">
|
<Text as="span" size="B300">
|
||||||
@@ -651,7 +651,7 @@ function ProfileStatus() {
|
|||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
outlined
|
outlined
|
||||||
disabled={saving || !statusMsg.trim()}
|
disabled={!statusMsg.trim()}
|
||||||
onClick={handleSaveCurrent}
|
onClick={handleSaveCurrent}
|
||||||
before={<Icon size="100" src={Icons.Plus} />}
|
before={<Icon size="100" src={Icons.Plus} />}
|
||||||
>
|
>
|
||||||
@@ -662,7 +662,7 @@ function ProfileStatus() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}>
|
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center">
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
<Input
|
<Input
|
||||||
name="statusMsgInput"
|
name="statusMsgInput"
|
||||||
@@ -672,7 +672,6 @@ function ProfileStatus() {
|
|||||||
placeholder="What's on your mind?"
|
placeholder="What's on your mind?"
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
radii="300"
|
radii="300"
|
||||||
readOnly={saving}
|
|
||||||
maxLength={64}
|
maxLength={64}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
@@ -733,13 +732,17 @@ function ProfileStatus() {
|
|||||||
fill={hasChanges ? 'Solid' : 'Soft'}
|
fill={hasChanges ? 'Solid' : 'Soft'}
|
||||||
outlined
|
outlined
|
||||||
radii="300"
|
radii="300"
|
||||||
disabled={!hasChanges || saving}
|
disabled={!hasChanges}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{saving && <Spinner variant="Success" fill="Solid" size="300" />}
|
|
||||||
<Text size="B400">Save</Text>
|
<Text size="B400">Save</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
{saving && (
|
||||||
|
<Text size="T200" priority="300" role="status">
|
||||||
|
Status set. Sharing it with others, this can take a few seconds.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{saveState.status === AsyncStatus.Error && (
|
{saveState.status === AsyncStatus.Error && (
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
{describePresenceError(saveState.error)}
|
{describePresenceError(saveState.error)}
|
||||||
@@ -764,7 +767,6 @@ function ProfileStatus() {
|
|||||||
radii="300"
|
radii="300"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
disabled={saving}
|
|
||||||
>
|
>
|
||||||
<Text size="B300">Clear Status</Text>
|
<Text size="B300">Clear Status</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export function ProfileDecoration() {
|
|||||||
|
|
||||||
const [current, setCurrent] = useState<string | null>(null);
|
const [current, setCurrent] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
// [Gitea #460] The catalog stays closed until "Change"; it was always open.
|
||||||
|
const [browsing, setBrowsing] = useState(false);
|
||||||
// Distinguish "confirmed no decoration" from "failed to load": a fetch
|
// Distinguish "confirmed no decoration" from "failed to load": a fetch
|
||||||
// failure must not be shown as (and saved over) "None".
|
// failure must not be shown as (and saved over) "None".
|
||||||
const [loadError, setLoadError] = useState(false);
|
const [loadError, setLoadError] = useState(false);
|
||||||
@@ -199,7 +201,7 @@ export function ProfileDecoration() {
|
|||||||
<SettingTile
|
<SettingTile
|
||||||
title={
|
title={
|
||||||
<Text as="span" size="L400">
|
<Text as="span" size="L400">
|
||||||
Avatar Decoration
|
Avatar decoration
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
@@ -282,6 +284,20 @@ export function ProfileDecoration() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
{!loadError && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
outlined
|
||||||
|
aria-expanded={browsing}
|
||||||
|
onClick={() => setBrowsing((b) => !b)}
|
||||||
|
>
|
||||||
|
<Text size="B300">{browsing ? 'Done' : 'Change'}</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{hasChanges && !loadError && (
|
{hasChanges && !loadError && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -324,6 +340,8 @@ export function ProfileDecoration() {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{browsing && (
|
||||||
|
<>
|
||||||
{/* Search + category tabs. Only one category (or the search results)
|
{/* Search + category tabs. Only one category (or the search results)
|
||||||
is mounted at a time so the picker never loads the whole catalog. */}
|
is mounted at a time so the picker never loads the whole catalog. */}
|
||||||
<Input
|
<Input
|
||||||
@@ -391,6 +409,8 @@ export function ProfileDecoration() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</SettingTile>
|
</SettingTile>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState, ReactNode } from 'react';
|
||||||
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
|
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
@@ -18,8 +18,12 @@ import { CryptoDiagnostics } from '../developer/CryptoDiagnostics';
|
|||||||
|
|
||||||
type DeveloperToolsProps = {
|
type DeveloperToolsProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
|
/** [Gitea #459] Shown as the page title (the menu entry's name). */
|
||||||
|
title?: string;
|
||||||
|
/** [Gitea #459] Sections shown above this page's own. */
|
||||||
|
before?: ReactNode;
|
||||||
};
|
};
|
||||||
export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
export function DeveloperTools({ requestClose, title, before }: DeveloperToolsProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
const [expand, setExpend] = useState(false);
|
const [expand, setExpend] = useState(false);
|
||||||
@@ -49,7 +53,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
|||||||
<Box grow="Yes" gap="200">
|
<Box grow="Yes" gap="200">
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
<Text size="H3" truncate>
|
<Text size="H3" truncate>
|
||||||
Developer Tools
|
{title ?? 'Developer Tools'}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
@@ -63,6 +67,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
|||||||
<Scroll hideTrack visibility="Hover">
|
<Scroll hideTrack visibility="Hover">
|
||||||
<PageContent>
|
<PageContent>
|
||||||
<Box direction="Column" gap="700">
|
<Box direction="Column" gap="700">
|
||||||
|
{before}
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Options</Text>
|
<Text size="L400">Options</Text>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
@@ -72,7 +77,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Enable Developer Tools"
|
title="Enable developer tools"
|
||||||
after={
|
after={
|
||||||
<Switch
|
<Switch
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
@@ -90,7 +95,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Access Token"
|
title="Access token"
|
||||||
description="Copy access token to clipboard."
|
description="Copy access token to clipboard."
|
||||||
after={
|
after={
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function CryptoDiagnostics() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Crypto Diagnostics</Text>
|
<Text size="L400">Crypto diagnostics</Text>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
@@ -43,8 +43,8 @@ export function CryptoDiagnostics() {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Crypto Diagnostics — captures E2EE error signatures this session"
|
title="Crypto diagnostics"
|
||||||
description="Ring-buffers up to 200 matched console warnings/errors for the KE-1..KE-4 bug cluster. Local only — no network calls. The downloaded report includes the matched log lines as evidence."
|
description="Captures encryption error signatures from this session. Ring-buffers up to 200 matched console warnings/errors for the KE-1..KE-4 bug cluster. Local only — no network calls. The downloaded report includes the matched log lines as evidence."
|
||||||
after={
|
after={
|
||||||
<Box alignItems="Center" gap="200" shrink="No">
|
<Box alignItems="Center" gap="200" shrink="No">
|
||||||
<Badge variant={count > 0 ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
|
<Badge variant={count > 0 ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ function DeviceRename({ device, onCancel, onRename, refreshDeviceList }: DeviceR
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
|
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
|
||||||
<Text size="L400">Device Name</Text>
|
<Text size="L400">Device name</Text>
|
||||||
<Box gap="200">
|
<Box gap="200">
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
|
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
@@ -38,8 +38,12 @@ function DevicesPlaceholder() {
|
|||||||
|
|
||||||
type DevicesProps = {
|
type DevicesProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
|
/** [Gitea #459] Shown as the page title (the menu entry's name). */
|
||||||
|
title?: string;
|
||||||
|
/** [Gitea #459] Sections shown above this page's own. */
|
||||||
|
before?: ReactNode;
|
||||||
};
|
};
|
||||||
export function Devices({ requestClose }: DevicesProps) {
|
export function Devices({ requestClose, title, before }: DevicesProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const crypto = mx.getCrypto();
|
const crypto = mx.getCrypto();
|
||||||
const crossSigningActive = useCrossSigningActive();
|
const crossSigningActive = useCrossSigningActive();
|
||||||
@@ -70,7 +74,7 @@ export function Devices({ requestClose }: DevicesProps) {
|
|||||||
<Box grow="Yes" gap="200">
|
<Box grow="Yes" gap="200">
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
<Text size="H3" truncate>
|
<Text size="H3" truncate>
|
||||||
Devices
|
{title ?? 'Devices'}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
@@ -84,6 +88,7 @@ export function Devices({ requestClose }: DevicesProps) {
|
|||||||
<Scroll hideTrack visibility="Hover">
|
<Scroll hideTrack visibility="Hover">
|
||||||
<PageContent>
|
<PageContent>
|
||||||
<Box direction="Column" gap="700">
|
<Box direction="Column" gap="700">
|
||||||
|
{before}
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Security</Text>
|
<Text size="L400">Security</Text>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
@@ -93,8 +98,8 @@ export function Devices({ requestClose }: DevicesProps) {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Device Verification"
|
title="Device verification"
|
||||||
description="To verify device identity and grant access to encrypted messages."
|
description="Verify this device so it can read your encrypted messages and others can trust it."
|
||||||
after={
|
after={
|
||||||
<>
|
<>
|
||||||
<EnableVerification visible={!crossSigningActive} />
|
<EnableVerification visible={!crossSigningActive} />
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function ExportKeys() {
|
|||||||
{(match, doMatch, passRef, confPassRef) => (
|
{(match, doMatch, passRef, confPassRef) => (
|
||||||
<>
|
<>
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
<Text size="L400">New Password</Text>
|
<Text size="L400">New password</Text>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
ref={passRef}
|
ref={passRef}
|
||||||
name="passwordInput"
|
name="passwordInput"
|
||||||
@@ -82,7 +82,7 @@ function ExportKeys() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
<Text size="L400">Confirm Password</Text>
|
<Text size="L400">Confirm password</Text>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
ref={confPassRef}
|
ref={confPassRef}
|
||||||
style={{ color: match ? undefined : color.Critical.Main }}
|
style={{ color: match ? undefined : color.Critical.Main }}
|
||||||
@@ -130,8 +130,8 @@ function ExportKeysTile() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Export Messages Data"
|
title="Export encryption keys"
|
||||||
description="Save password protected copy of encryption data on your device to decrypt messages later."
|
description="Save a password-protected file of your encryption keys. Useful when moving to a device without key backup, or as an offline copy."
|
||||||
after={
|
after={
|
||||||
<Box>
|
<Box>
|
||||||
<Button
|
<Button
|
||||||
@@ -260,8 +260,8 @@ function ImportKeysTile() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Import Messages Data"
|
title="Import encryption keys"
|
||||||
description="Load password protected copy of encryption data from device to decrypt your messages."
|
description="Load a password-protected encryption key file to read older encrypted messages on this device."
|
||||||
after={
|
after={
|
||||||
<Box>
|
<Box>
|
||||||
{file ? (
|
{file ? (
|
||||||
@@ -307,7 +307,7 @@ function ImportKeysTile() {
|
|||||||
export function LocalBackup() {
|
export function LocalBackup() {
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Local Backup</Text>
|
<Text size="L400">Encryption key backup file</Text>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
import { Box, Button, config, Menu, Spinner, Text } from 'folds';
|
import { Box, Button, config, Menu, Spinner, Text } from 'folds';
|
||||||
import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk';
|
import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
@@ -15,6 +16,8 @@ import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
|
|||||||
import { withSearchParam } from '../../../pages/pathUtils';
|
import { withSearchParam } from '../../../pages/pathUtils';
|
||||||
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
|
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { preselectSignOutAtom } from '../../../state/staleSessions';
|
||||||
|
import { staleSessions } from '../../../utils/securityNudge';
|
||||||
|
|
||||||
type OtherDevicesProps = {
|
type OtherDevicesProps = {
|
||||||
devices: IMyDevice[];
|
devices: IMyDevice[];
|
||||||
@@ -29,6 +32,23 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
|||||||
|
|
||||||
const [deleted, setDeleted] = useState<Set<string>>(new Set());
|
const [deleted, setDeleted] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// [Gitea #113] The stale-sessions nudge's Review: tick those sessions for
|
||||||
|
// sign-out (one confirm below). OIDC accounts sign out on the provider's
|
||||||
|
// dashboard instead, so nothing is ticked there.
|
||||||
|
const [preselect, setPreselect] = useAtom(preselectSignOutAtom);
|
||||||
|
useEffect(() => {
|
||||||
|
if (preselect.length === 0) return;
|
||||||
|
if (!authMetadata) {
|
||||||
|
const present = new Set(devices.map((d) => d.device_id));
|
||||||
|
setDeleted(new Set(preselect.filter((id) => present.has(id))));
|
||||||
|
}
|
||||||
|
setPreselect([]);
|
||||||
|
}, [preselect, setPreselect, devices, authMetadata]);
|
||||||
|
|
||||||
|
const staleIds = new Set(
|
||||||
|
staleSessions(devices, mx.getDeviceId() ?? undefined, Date.now()).map((d) => d.device_id),
|
||||||
|
);
|
||||||
|
|
||||||
const handleDashboardOIDC = useCallback(() => {
|
const handleDashboardOIDC = useCallback(() => {
|
||||||
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
|
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
|
||||||
if (!authUrl) return;
|
if (!authUrl) return;
|
||||||
@@ -115,7 +135,7 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Device Dashboard"
|
title="Device dashboard"
|
||||||
description="Manage your devices on OIDC dashboard."
|
description="Manage your devices on OIDC dashboard."
|
||||||
after={
|
after={
|
||||||
<Button
|
<Button
|
||||||
@@ -132,8 +152,20 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
|||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
)}
|
)}
|
||||||
{devices
|
{staleIds.size > 0 && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
{staleIds.size === 1
|
||||||
|
? "1 session hasn't been used in 3 months or more (marked Inactive)."
|
||||||
|
: `${staleIds.size} sessions haven't been used in 3 months or more (marked Inactive).`}{' '}
|
||||||
|
Sign out the ones you no longer use.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{[...devices]
|
||||||
|
// [Gitea #113] Inactive (90+ days) sessions first, then most recent.
|
||||||
.sort((d1, d2) => {
|
.sort((d1, d2) => {
|
||||||
|
const s1 = staleIds.has(d1.device_id) ? 0 : 1;
|
||||||
|
const s2 = staleIds.has(d2.device_id) ? 0 : 1;
|
||||||
|
if (s1 !== s2) return s1 - s2;
|
||||||
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
|
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
|
||||||
return d1.last_seen_ts < d2.last_seen_ts ? 1 : -1;
|
return d1.last_seen_ts < d2.last_seen_ts ? 1 : -1;
|
||||||
})
|
})
|
||||||
@@ -145,6 +177,11 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
|||||||
direction="Column"
|
direction="Column"
|
||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
|
{staleIds.has(device.device_id) && (
|
||||||
|
<Text size="L400" style={{ color: 'inherit' }}>
|
||||||
|
Inactive for 3+ months
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<DeviceTile
|
<DeviceTile
|
||||||
device={device}
|
device={device}
|
||||||
deleted={deleted.has(device.device_id)}
|
deleted={deleted.has(device.device_id)}
|
||||||
|
|||||||
@@ -423,7 +423,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Favorite Packs</Text>
|
<Text size="L400">Favourite packs</Text>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
@@ -431,7 +431,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Select Pack"
|
title="Add a pack"
|
||||||
description="Pick emojis and stickers pack from rooms to use in all rooms."
|
description="Pick emojis and stickers pack from rooms to use in all rooms."
|
||||||
after={
|
after={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function UserPack({ onViewPack }: UserPackProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Default Pack</Text>
|
<Text size="L400">Default pack</Text>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
DenoiseNode,
|
DenoiseNode,
|
||||||
buildGateNode,
|
buildGateNode,
|
||||||
buildModelNode,
|
buildModelNode,
|
||||||
|
monoDownmix,
|
||||||
readDb,
|
readDb,
|
||||||
sampleRateFor,
|
sampleRateFor,
|
||||||
} from '../../../utils/denoisePipeline';
|
} from '../../../utils/denoisePipeline';
|
||||||
@@ -139,7 +140,7 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
inAnalyser.fftSize = 1024;
|
inAnalyser.fftSize = 1024;
|
||||||
source.connect(inAnalyser);
|
source.connect(inAnalyser);
|
||||||
|
|
||||||
let head: AudioNode = source;
|
let head: AudioNode = source.connect(monoDownmix(ctx));
|
||||||
let gate: AudioWorkletNode | null = null;
|
let gate: AudioWorkletNode | null = null;
|
||||||
if (useGate) {
|
if (useGate) {
|
||||||
gate = await buildGateNode(ctx, gateThreshold);
|
gate = await buildGateNode(ctx, gateThreshold);
|
||||||
@@ -308,10 +309,11 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
|
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
|
||||||
const source = ctx.createBufferSource();
|
const source = ctx.createBufferSource();
|
||||||
source.buffer = clip;
|
source.buffer = clip;
|
||||||
|
const mono = source.connect(monoDownmix(ctx));
|
||||||
let playGate: AudioWorkletNode | null = null;
|
let playGate: AudioWorkletNode | null = null;
|
||||||
let playModelNode: DenoiseNode | null = null;
|
let playModelNode: DenoiseNode | null = null;
|
||||||
if (playModel) {
|
if (playModel) {
|
||||||
let head: AudioNode = source;
|
let head: AudioNode = mono;
|
||||||
if (useGate) {
|
if (useGate) {
|
||||||
playGate = await buildGateNode(ctx, gateThreshold);
|
playGate = await buildGateNode(ctx, gateThreshold);
|
||||||
head.connect(playGate);
|
head.connect(playGate);
|
||||||
@@ -321,7 +323,7 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
head.connect(playModelNode.node);
|
head.connect(playModelNode.node);
|
||||||
playModelNode.node.connect(ctx.destination);
|
playModelNode.node.connect(ctx.destination);
|
||||||
} else {
|
} else {
|
||||||
source.connect(ctx.destination);
|
mono.connect(ctx.destination);
|
||||||
}
|
}
|
||||||
// Superseded while the WASM/worklet loaded (another Play, a Stop, or the
|
// Superseded while the WASM/worklet loaded (another Play, a Stop, or the
|
||||||
// panel unmounted)? Tear down this now-orphaned graph instead of storing
|
// panel unmounted)? Tear down this now-orphaned graph instead of storing
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,287 @@
|
|||||||
|
import React, { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { Method } from 'matrix-js-sdk';
|
||||||
|
import { Box, Button, RadioButton, Text, color } from 'folds';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { Settings, settingsAtom } from '../../../state/settings';
|
||||||
|
import {
|
||||||
|
buildExport,
|
||||||
|
EXPORT_ACCOUNT_DATA,
|
||||||
|
ExportFile,
|
||||||
|
ImportPlanItem,
|
||||||
|
mergeData,
|
||||||
|
parseExport,
|
||||||
|
planImport,
|
||||||
|
} from '../../../utils/dataExport';
|
||||||
|
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
||||||
|
|
||||||
|
const DECORATION_FIELD = 'io.lotus.avatar_decoration';
|
||||||
|
|
||||||
|
type Current = {
|
||||||
|
accountData: Record<string, unknown>;
|
||||||
|
decoration: string | null;
|
||||||
|
localSettings: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #114] "Your data": export the account's Lotus data (settings,
|
||||||
|
* bookmarks, notes, sections, ...) to a file and import it here or on another
|
||||||
|
* account. Import merges: new things are added; for things that differ the
|
||||||
|
* user picks "keep mine" (default) or "use the file's". Message history and
|
||||||
|
* encryption keys have their own exports.
|
||||||
|
*/
|
||||||
|
export function YourDataSection() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const userId = mx.getSafeUserId();
|
||||||
|
const [settings, setSettings] = useAtom(settingsAtom);
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
const [pending, setPending] = useState<{ file: ExportFile; plan: ImportPlanItem[] }>();
|
||||||
|
const [preferFile, setPreferFile] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ tone: 'ok' | 'error'; text: string }>();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const readCurrent = useCallback(async (): Promise<Current> => {
|
||||||
|
const accountData: Record<string, unknown> = {};
|
||||||
|
EXPORT_ACCOUNT_DATA.forEach(({ type }) => {
|
||||||
|
const c = mx.getAccountData(type as never)?.getContent();
|
||||||
|
if (c && Object.keys(c).length > 0) accountData[type] = c;
|
||||||
|
});
|
||||||
|
let decoration: string | null = null;
|
||||||
|
try {
|
||||||
|
const profile = await mx.http.authedRequest<Record<string, unknown>>(
|
||||||
|
Method.Get,
|
||||||
|
`/profile/${encodeURIComponent(userId)}`,
|
||||||
|
);
|
||||||
|
const v = profile[DECORATION_FIELD];
|
||||||
|
decoration = typeof v === 'string' && v ? v : null;
|
||||||
|
} catch {
|
||||||
|
/* no decoration */
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
accountData,
|
||||||
|
decoration,
|
||||||
|
localSettings: settings as unknown as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
}, [mx, userId, settings]);
|
||||||
|
|
||||||
|
const exportData = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setMessage(undefined);
|
||||||
|
try {
|
||||||
|
const cur = await readCurrent();
|
||||||
|
const file = buildExport({ userId, ...cur });
|
||||||
|
const blob = new Blob([JSON.stringify(file, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `lotus-chat-data-${userId.replace(/[^a-z0-9]+/gi, '-')}-${file.exportedAt.slice(0, 10)}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setMessage({ tone: 'ok', text: 'Exported. Keep the file somewhere safe.' });
|
||||||
|
} catch {
|
||||||
|
setMessage({ tone: 'error', text: "Couldn't export your data. Please try again." });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseFile = async (f: File | undefined) => {
|
||||||
|
if (!f) return;
|
||||||
|
setMessage(undefined);
|
||||||
|
const parsed = parseExport(await f.text());
|
||||||
|
if (!parsed.ok) {
|
||||||
|
setMessage({ tone: 'error', text: parsed.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const plan = planImport(parsed.file, await readCurrent());
|
||||||
|
setPreferFile(false);
|
||||||
|
setPending({ file: parsed.file, plan });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyImport = async () => {
|
||||||
|
if (!pending) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const cur = await readCurrent();
|
||||||
|
const { file } = pending;
|
||||||
|
await Object.entries(file.accountData).reduce<Promise<void>>(
|
||||||
|
async (prev, [type, incoming]) => {
|
||||||
|
await prev;
|
||||||
|
const r = mergeData(cur.accountData[type], incoming, preferFile);
|
||||||
|
if (r.added > 0 || (preferFile && r.conflicts > 0)) {
|
||||||
|
await mx.setAccountData(type as never, r.value as never);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Promise.resolve(),
|
||||||
|
);
|
||||||
|
const deco = file.profile.decoration;
|
||||||
|
if (deco && deco !== cur.decoration && (!cur.decoration || preferFile)) {
|
||||||
|
await mx.http.authedRequest(
|
||||||
|
Method.Put,
|
||||||
|
`/profile/${encodeURIComponent(userId)}/${DECORATION_FIELD}`,
|
||||||
|
undefined,
|
||||||
|
{ [DECORATION_FIELD]: deco },
|
||||||
|
);
|
||||||
|
invalidateDecorationCache(userId);
|
||||||
|
}
|
||||||
|
if (file.local.settings) {
|
||||||
|
const r = mergeData(cur.localSettings ?? {}, file.local.settings, preferFile);
|
||||||
|
setSettings({ ...settings, ...(r.value as Partial<Settings>) });
|
||||||
|
}
|
||||||
|
setPending(undefined);
|
||||||
|
setMessage({ tone: 'ok', text: 'Imported.' });
|
||||||
|
} catch {
|
||||||
|
setMessage({ tone: 'error', text: "Couldn't finish the import. Nothing else was changed." });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totals = pending?.plan.reduce(
|
||||||
|
(t, p) => ({ added: t.added + p.added, conflicts: t.conflicts + p.conflicts }),
|
||||||
|
{ added: 0, conflicts: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Your data</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Export your data"
|
||||||
|
description="Settings, bookmarks, private notes, room sections and names, reminders, status presets, soundboard and emoji, as one file. Not your messages or encryption keys (they have their own exports)."
|
||||||
|
after={
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
disabled={busy}
|
||||||
|
onClick={exportData}
|
||||||
|
>
|
||||||
|
<Text size="B300">Export</Text>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Import data"
|
||||||
|
description="Load a Lotus Chat data file into this account. New items are added; you choose what happens to things that differ."
|
||||||
|
after={
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => fileInput.current?.click()}
|
||||||
|
>
|
||||||
|
<Text size="B300">Import…</Text>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
hidden
|
||||||
|
aria-label="Lotus Chat data file"
|
||||||
|
onChange={(e) => {
|
||||||
|
chooseFile(e.target.files?.[0]).catch(() => undefined);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{pending && totals && (
|
||||||
|
<Box direction="Column" gap="200" role="region" aria-label="Import summary">
|
||||||
|
{pending.file.userId && pending.file.userId !== userId && (
|
||||||
|
<Text size="T200">
|
||||||
|
This file is from <b>{pending.file.userId}</b>.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{pending.plan.map((p) => (
|
||||||
|
<Text key={p.key} size="T200">
|
||||||
|
{p.label}:{' '}
|
||||||
|
{p.added === 0 && p.conflicts === 0
|
||||||
|
? 'already up to date'
|
||||||
|
: [p.added > 0 && `${p.added} new`, p.conflicts > 0 && `${p.conflicts} differ`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{totals.conflicts > 0 && (
|
||||||
|
<Box direction="Column" gap="100" role="radiogroup" aria-label="Things that differ">
|
||||||
|
<Text size="T200">
|
||||||
|
<b>{totals.conflicts}</b>{' '}
|
||||||
|
{totals.conflicts === 1 ? 'thing differs' : 'things differ'} from what's
|
||||||
|
here:
|
||||||
|
</Text>
|
||||||
|
<label>
|
||||||
|
<RadioButton
|
||||||
|
name="your-data-conflicts"
|
||||||
|
variant="Primary"
|
||||||
|
size="300"
|
||||||
|
checked={!preferFile}
|
||||||
|
onClick={() => setPreferFile(false)}
|
||||||
|
/>{' '}
|
||||||
|
<Text as="span" size="T200">
|
||||||
|
Keep mine
|
||||||
|
</Text>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<RadioButton
|
||||||
|
name="your-data-conflicts"
|
||||||
|
variant="Primary"
|
||||||
|
size="300"
|
||||||
|
checked={preferFile}
|
||||||
|
onClick={() => setPreferFile(true)}
|
||||||
|
/>{' '}
|
||||||
|
<Text as="span" size="T200">
|
||||||
|
Use the file's
|
||||||
|
</Text>
|
||||||
|
</label>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Primary"
|
||||||
|
radii="300"
|
||||||
|
disabled={busy || (totals.added === 0 && !(preferFile && totals.conflicts > 0))}
|
||||||
|
onClick={applyImport}
|
||||||
|
>
|
||||||
|
<Text size="B300">Import</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setPending(undefined)}
|
||||||
|
>
|
||||||
|
<Text size="B300">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{message && (
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
role="status"
|
||||||
|
style={{ color: message.tone === 'error' ? color.Critical.Main : undefined }}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ export function AllMessagesNotifications() {
|
|||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||||
<Text size="L400">All Messages</Text>
|
<Text size="L400">All messages</Text>
|
||||||
<Box gap="100">
|
<Box gap="100">
|
||||||
<Text size="T200">Badge: </Text>
|
<Text size="T200">Badge: </Text>
|
||||||
<Badge radii="300" variant="Secondary" fill="Solid">
|
<Badge radii="300" variant="Secondary" fill="Solid">
|
||||||
@@ -97,7 +97,7 @@ export function AllMessagesNotifications() {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="1-to-1 Chats"
|
title="1-to-1 chats"
|
||||||
after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.DM} oneToOne />}
|
after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.DM} oneToOne />}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
@@ -108,7 +108,7 @@ export function AllMessagesNotifications() {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="1-to-1 Chats (Encrypted)"
|
title="1-to-1 chats (encrypted)"
|
||||||
after={
|
after={
|
||||||
<AllMessagesModeSwitcher
|
<AllMessagesModeSwitcher
|
||||||
pushRules={pushRules}
|
pushRules={pushRules}
|
||||||
@@ -137,7 +137,7 @@ export function AllMessagesNotifications() {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Rooms (Encrypted)"
|
title="Rooms (encrypted)"
|
||||||
after={
|
after={
|
||||||
<AllMessagesModeSwitcher
|
<AllMessagesModeSwitcher
|
||||||
pushRules={pushRules}
|
pushRules={pushRules}
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export function KeywordMessagesNotifications() {
|
|||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||||
<Text size="L400">Keyword Messages</Text>
|
<Text size="L400">Keyword messages</Text>
|
||||||
<Box gap="100">
|
<Box gap="100">
|
||||||
<Text size="T200">Badge: </Text>
|
<Text size="T200">Badge: </Text>
|
||||||
<Badge radii="300" variant="Success" fill="Solid">
|
<Badge radii="300" variant="Success" fill="Solid">
|
||||||
@@ -186,7 +186,7 @@ export function KeywordMessagesNotifications() {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Select Keyword"
|
title="Add a keyword"
|
||||||
description="Set a notification preference for message containing given keyword."
|
description="Set a notification preference for message containing given keyword."
|
||||||
>
|
>
|
||||||
<KeywordInput />
|
<KeywordInput />
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { KeywordMessagesNotifications } from './KeywordMessages';
|
|||||||
import { PushRuleEditor } from './PushRuleEditor';
|
import { PushRuleEditor } from './PushRuleEditor';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
|
||||||
import { settingsAtom, Settings } from '../../../state/settings';
|
import { settingsAtom, Settings } from '../../../state/settings';
|
||||||
import { notificationSnoozeUntilAtom } from '../../../state/notificationSnooze';
|
import { notificationSnoozeUntilAtom } from '../../../state/notificationSnooze';
|
||||||
|
|
||||||
@@ -81,7 +80,7 @@ function NotificationPresets() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Quick Presets</Text>
|
<Text size="L400">Quick presets</Text>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<Box gap="300" style={{ padding: config.space.S300, flexWrap: 'wrap' }}>
|
<Box gap="300" style={{ padding: config.space.S300, flexWrap: 'wrap' }}>
|
||||||
{PRESETS.map((preset) => (
|
{PRESETS.map((preset) => (
|
||||||
@@ -150,17 +149,6 @@ export function Notifications({ requestClose }: NotificationsProps) {
|
|||||||
<SpecialMessagesNotifications />
|
<SpecialMessagesNotifications />
|
||||||
<KeywordMessagesNotifications />
|
<KeywordMessagesNotifications />
|
||||||
<PushRuleEditor />
|
<PushRuleEditor />
|
||||||
<Box direction="Column" gap="100">
|
|
||||||
<Text size="L400">Block Messages</Text>
|
|
||||||
<SequenceCard
|
|
||||||
className={SequenceCardStyle}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
direction="Column"
|
|
||||||
gap="400"
|
|
||||||
>
|
|
||||||
<SettingTile description='This option has been moved to "Account > Block Users" section.' />
|
|
||||||
</SequenceCard>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
|||||||
@@ -333,10 +333,15 @@ export function PushRuleEditor() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Advanced Push Rules</Text>
|
<Text size="L400">Advanced push rules</Text>
|
||||||
<RuleSection title="Override Rules" kind={PushRuleKind.Override} rules={overrideRules} />
|
{/* [Gitea #459] Matrix terms kept, with one line on what they are. */}
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
The raw Matrix notification rules behind the options above, checked in this order: override,
|
||||||
|
room, sender, then underride. You rarely need to change these.
|
||||||
|
</Text>
|
||||||
|
<RuleSection title="Override rules" kind={PushRuleKind.Override} rules={overrideRules} />
|
||||||
<RuleSection
|
<RuleSection
|
||||||
title="Room Rules"
|
title="Room rules"
|
||||||
kind={PushRuleKind.RoomSpecific}
|
kind={PushRuleKind.RoomSpecific}
|
||||||
rules={roomRules}
|
rules={roomRules}
|
||||||
addForm={
|
addForm={
|
||||||
@@ -348,7 +353,7 @@ export function PushRuleEditor() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<RuleSection
|
<RuleSection
|
||||||
title="Sender Rules"
|
title="Sender rules"
|
||||||
kind={PushRuleKind.SenderSpecific}
|
kind={PushRuleKind.SenderSpecific}
|
||||||
rules={senderRules}
|
rules={senderRules}
|
||||||
addForm={
|
addForm={
|
||||||
@@ -359,7 +364,7 @@ export function PushRuleEditor() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<RuleSection title="Underride Rules" kind={PushRuleKind.Underride} rules={underrideRules} />
|
<RuleSection title="Underride rules" kind={PushRuleKind.Underride} rules={underrideRules} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function SpecialMessagesNotifications() {
|
|||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||||
<Text size="L400">Special Messages</Text>
|
<Text size="L400">Special messages</Text>
|
||||||
<Box gap="100">
|
<Box gap="100">
|
||||||
<Text size="T200">Badge: </Text>
|
<Text size="T200">Badge: </Text>
|
||||||
<Badge radii="300" variant="Success" fill="Solid">
|
<Badge radii="300" variant="Success" fill="Solid">
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm
|
|||||||
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { isTauri } from '../../../hooks/useTauri';
|
||||||
|
import { useWebPushConfig, webPushSupported } from '../../../hooks/useWebPush';
|
||||||
|
|
||||||
function EmailNotification() {
|
function EmailNotification() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -63,7 +65,7 @@ function EmailNotification() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Email Notification"
|
title="Email notifications"
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
{result && !result.email && (
|
{result && !result.email && (
|
||||||
@@ -161,7 +163,7 @@ function PauseNotifications() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Pause Notifications"
|
title="Pause notifications"
|
||||||
description={<span style={active ? { color: color.Warning.Main } : undefined}>{status}</span>}
|
description={<span style={active ? { color: color.Warning.Main } : undefined}>{status}</span>}
|
||||||
after={
|
after={
|
||||||
active ? (
|
active ? (
|
||||||
@@ -197,6 +199,9 @@ function PauseNotifications() {
|
|||||||
export function SystemNotification() {
|
export function SystemNotification() {
|
||||||
const notifPermission = usePermissionState('notifications', getNotificationState());
|
const notifPermission = usePermissionState('notifications', getNotificationState());
|
||||||
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
|
const [backgroundPush, setBackgroundPush] = useSetting(settingsAtom, 'backgroundPush');
|
||||||
|
const webPushConfig = useWebPushConfig();
|
||||||
|
const showBackgroundPush = !isTauri() && webPushSupported() && webPushConfig !== undefined;
|
||||||
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
|
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
'isNotificationSounds',
|
'isNotificationSounds',
|
||||||
@@ -221,7 +226,7 @@ export function SystemNotification() {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Desktop Notifications"
|
title="Desktop notifications"
|
||||||
description={
|
description={
|
||||||
notifPermission === 'denied' ? (
|
notifPermission === 'denied' ? (
|
||||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
@@ -230,7 +235,7 @@ export function SystemNotification() {
|
|||||||
: 'Notifications are not supported by the system.'}
|
: 'Notifications are not supported by the system.'}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<span>Show desktop notifications when message arrive.</span>
|
<span>Show desktop notifications when messages arrive.</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
after={
|
after={
|
||||||
@@ -247,6 +252,19 @@ export function SystemNotification() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{showBackgroundPush && (
|
||||||
|
<SettingTile
|
||||||
|
title="Notify when Lotus is closed"
|
||||||
|
description="Keep getting notifications for new messages, mentions and calls when no Lotus tab is open, through your browser's push service. Encrypted messages are never shown in the notification. Pause and quiet hours still apply."
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
disabled={notifPermission !== 'granted' || !showNotifications}
|
||||||
|
value={backgroundPush}
|
||||||
|
onChange={setBackgroundPush}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
@@ -263,8 +281,8 @@ export function SystemNotification() {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Notification Sound"
|
title="Notification sound"
|
||||||
description="Play sound when new message arrive."
|
description="Play a sound when a new message arrives."
|
||||||
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
||||||
/>
|
/>
|
||||||
{isNotificationSounds && (
|
{isNotificationSounds && (
|
||||||
@@ -353,7 +371,7 @@ export function SystemNotification() {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Quiet Hours"
|
title="Quiet hours"
|
||||||
description="Notifications are silenced between these times. Handles overnight (e.g. 11:00 PM – 8:00 AM)."
|
description="Notifications are silenced between these times. Handles overnight (e.g. 11:00 PM – 8:00 AM)."
|
||||||
after={<Switch value={quietHoursEnabled} onChange={setQuietHoursEnabled} />}
|
after={<Switch value={quietHoursEnabled} onChange={setQuietHoursEnabled} />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
/** Settings dialog pages. Kept apart from Settings.tsx so callers can name a
|
/** Settings dialog pages. Kept apart from Settings.tsx so callers can name a
|
||||||
* page without pulling the dialog into the entry chunk. */
|
* page without pulling the dialog into the entry chunk. */
|
||||||
export enum SettingsPages {
|
export enum SettingsPages {
|
||||||
GeneralPage,
|
|
||||||
AccountPage,
|
AccountPage,
|
||||||
|
// [Gitea #459] These replace the old catch-all General page.
|
||||||
|
AppearancePage,
|
||||||
|
MessagesPage,
|
||||||
NotificationPage,
|
NotificationPage,
|
||||||
|
CallsPage,
|
||||||
|
/** "Privacy & security": privacy options plus devices and encryption. */
|
||||||
DevicesPage,
|
DevicesPage,
|
||||||
|
DataStoragePage,
|
||||||
|
/** Desktop app only. */
|
||||||
|
DesktopPage,
|
||||||
EmojisStickersPage,
|
EmojisStickersPage,
|
||||||
|
/** "Advanced": developer tools and display plumbing. */
|
||||||
DeveloperToolsPage,
|
DeveloperToolsPage,
|
||||||
AboutPage,
|
AboutPage,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Box, Icon, IconButton, Icons, Input, MenuItem, Text, config } from 'folds';
|
||||||
|
import { SettingsPages } from '../pages';
|
||||||
|
import { searchSettings, SettingsEntry } from './settingsIndex';
|
||||||
|
import { loadSettingsIndex } from './loadSettingsIndex';
|
||||||
|
import { isTauri } from '../../../hooks/useTauri';
|
||||||
|
|
||||||
|
type SettingsSearchProps = {
|
||||||
|
pageNames: Map<SettingsPages, string>;
|
||||||
|
/** Whether a query is active (the page list is hidden meanwhile). */
|
||||||
|
onActiveChange: (active: boolean) => void;
|
||||||
|
onPick: (entry: SettingsEntry) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #463] Search box at the top of the settings sidebar. Ctrl/Cmd+F
|
||||||
|
* focuses it while settings are open; Esc clears it.
|
||||||
|
*/
|
||||||
|
export function SettingsSearch({ pageNames, onActiveChange, onPick }: SettingsSearchProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [entries, setEntries] = useState<SettingsEntry[]>();
|
||||||
|
const active = query.trim().length > 0;
|
||||||
|
|
||||||
|
useEffect(() => onActiveChange(active), [active, onActiveChange]);
|
||||||
|
|
||||||
|
const ensureIndex = () => {
|
||||||
|
if (!entries) loadSettingsIndex().then(setEntries, () => setEntries([]));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') {
|
||||||
|
// Capture phase + stop: settings is on top, so this beats the app's
|
||||||
|
// own Ctrl/Cmd+F (message search) underneath.
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey, true);
|
||||||
|
return () => window.removeEventListener('keydown', onKey, true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Only desktop-app options in the desktop app; only pages that exist here.
|
||||||
|
const results = entries
|
||||||
|
? searchSettings(entries, query, { desktop: isTauri() }).filter((e) => pageNames.has(e.page))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="200" style={{ padding: `0 ${config.space.S200}` }}>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
size="400"
|
||||||
|
variant="Background"
|
||||||
|
radii="400"
|
||||||
|
placeholder="Search settings"
|
||||||
|
aria-label="Search settings"
|
||||||
|
value={query}
|
||||||
|
onFocus={ensureIndex}
|
||||||
|
onChange={(e) => {
|
||||||
|
ensureIndex();
|
||||||
|
setQuery(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape' && query) {
|
||||||
|
e.stopPropagation();
|
||||||
|
setQuery('');
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && results[0]) {
|
||||||
|
onPick(results[0]);
|
||||||
|
setQuery('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
before={<Icon size="100" src={Icons.Search} />}
|
||||||
|
after={
|
||||||
|
query ? (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Background"
|
||||||
|
aria-label="Clear search"
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{active && (
|
||||||
|
<Box direction="Column" role="list" aria-label="Matching settings">
|
||||||
|
{entries === undefined && (
|
||||||
|
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
||||||
|
Searching…
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{entries !== undefined && results.length === 0 && (
|
||||||
|
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
||||||
|
No settings match “{query.trim()}”.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{results.map((r) => (
|
||||||
|
<MenuItem
|
||||||
|
key={`${r.page}-${r.title}`}
|
||||||
|
role="listitem"
|
||||||
|
variant="Background"
|
||||||
|
radii="400"
|
||||||
|
onClick={() => {
|
||||||
|
onPick(r);
|
||||||
|
setQuery('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box direction="Column" style={{ padding: `${config.space.S100} 0`, minWidth: 0 }}>
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{r.title}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" priority="300" truncate>
|
||||||
|
{pageNames.get(r.page)}
|
||||||
|
{r.section && r.section !== r.title ? ` · ${r.section}` : ''}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { parseSettingsSource, SettingsEntry } from './settingsIndex';
|
||||||
|
|
||||||
|
// [Gitea #463] The settings source, as text, in its own lazily loaded chunk:
|
||||||
|
// fetched the first time someone searches, never at startup.
|
||||||
|
const sources = import.meta.glob(['../**/*.tsx', '!../**/*.test.tsx'], {
|
||||||
|
query: '?raw',
|
||||||
|
import: 'default',
|
||||||
|
}) as Record<string, () => Promise<string>>;
|
||||||
|
|
||||||
|
let index: Promise<SettingsEntry[]> | undefined;
|
||||||
|
|
||||||
|
export const loadSettingsIndex = (): Promise<SettingsEntry[]> => {
|
||||||
|
index ??= Promise.all(
|
||||||
|
Object.entries(sources).map(async ([path, load]) =>
|
||||||
|
// '../general/General.tsx' -> '/general/General.tsx' (what the parser matches)
|
||||||
|
parseSettingsSource(path.replace(/^\.\./, ''), await load()),
|
||||||
|
),
|
||||||
|
).then((all) => all.flat());
|
||||||
|
return index;
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { parseSettingsSource, searchSettings } from './settingsIndex';
|
||||||
|
import { SettingsPages } from '../pages';
|
||||||
|
|
||||||
|
const general = fs.readFileSync('src/app/features/settings/general/General.tsx', 'utf8');
|
||||||
|
const devices = fs.readFileSync('src/app/features/settings/devices/Devices.tsx', 'utf8');
|
||||||
|
const sys = fs.readFileSync(
|
||||||
|
'src/app/features/settings/notifications/SystemNotification.tsx',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const entries = [
|
||||||
|
...parseSettingsSource('src/app/features/settings/general/General.tsx', general),
|
||||||
|
...parseSettingsSource('src/app/features/settings/devices/Devices.tsx', devices),
|
||||||
|
...parseSettingsSource('src/app/features/settings/notifications/SystemNotification.tsx', sys),
|
||||||
|
];
|
||||||
|
const find = (t: string) => entries.find((e) => e.title === t);
|
||||||
|
|
||||||
|
test('[#463] indexes the real settings source with the right pages', () => {
|
||||||
|
assert.equal(find('Push to deafen')?.page, SettingsPages.CallsPage);
|
||||||
|
assert.equal(find('Strip tracking parameters from links')?.page, SettingsPages.DevicesPage);
|
||||||
|
assert.equal(find('Page zoom')?.page, SettingsPages.AppearancePage);
|
||||||
|
assert.equal(find('Enter for a new line')?.page, SettingsPages.MessagesPage);
|
||||||
|
assert.equal(find('Show hidden events')?.page, SettingsPages.DeveloperToolsPage);
|
||||||
|
assert.equal(find('Device verification')?.page, SettingsPages.DevicesPage);
|
||||||
|
assert.equal(find('Quiet hours')?.page, SettingsPages.NotificationPage);
|
||||||
|
assert.equal(find('Launch on login')?.desktopOnly, true);
|
||||||
|
assert.match(find('Strip tracking parameters from links')?.description ?? '', /utm_/);
|
||||||
|
assert.ok(entries.length > 70, `indexed ${entries.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[#463] search: words anywhere, title first, desktop-only hidden in a browser', () => {
|
||||||
|
assert.equal(searchSettings(entries, 'deafen')[0]?.title, 'Push to deafen');
|
||||||
|
assert.equal(
|
||||||
|
searchSettings(entries, 'tracking')[0]?.title,
|
||||||
|
'Strip tracking parameters from links',
|
||||||
|
);
|
||||||
|
assert.equal(searchSettings(entries, 'zoom')[0]?.title, 'Page zoom');
|
||||||
|
assert.ok(searchSettings(entries, 'utm').some((e) => e.title.startsWith('Strip tracking')));
|
||||||
|
assert.equal(searchSettings(entries, 'launch on login').length, 0);
|
||||||
|
assert.equal(
|
||||||
|
searchSettings(entries, 'launch on login', { desktop: true })[0]?.title,
|
||||||
|
'Launch on login',
|
||||||
|
);
|
||||||
|
assert.deepEqual(searchSettings(entries, ' '), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[#463] parser handles the attribute forms and skips dynamic titles', () => {
|
||||||
|
const src = `
|
||||||
|
<Text size="L400">Things</Text>
|
||||||
|
<SettingTile title="Plain" description="A & B" after={<Switch value={x} />} />
|
||||||
|
<SettingTile title={'Quoted'} description={\`tick\`} />
|
||||||
|
<SettingTile title={\`Dynamic \${name}\`} />
|
||||||
|
<SettingTile title={someVar} />`;
|
||||||
|
const got = parseSettingsSource('src/app/features/settings/about/About.tsx', src);
|
||||||
|
assert.deepEqual(
|
||||||
|
got.map((e) => [e.title, e.description, e.section]),
|
||||||
|
[
|
||||||
|
['Plain', 'A & B', 'Things'],
|
||||||
|
['Quoted', 'tick', 'Things'],
|
||||||
|
['Things', '', undefined],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { SettingsPages } from '../pages';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #463] Search across all settings. The index is built from the
|
||||||
|
* settings source itself (each <SettingTile title=… description=…> and each
|
||||||
|
* section header), so options added later are found without a hand-kept list.
|
||||||
|
* Pure: path + source text in, entries out.
|
||||||
|
*/
|
||||||
|
export type SettingsEntry = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
page: SettingsPages;
|
||||||
|
/** Section header the setting sits under, when known. */
|
||||||
|
section?: string;
|
||||||
|
/** Desktop-only (hidden in a browser). */
|
||||||
|
desktopOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// General.tsx: which page each section (or helper used by a section) is on.
|
||||||
|
const GENERAL_SECTIONS: Record<string, SettingsPages> = {
|
||||||
|
Appearance: SettingsPages.AppearancePage,
|
||||||
|
SystemThemePreferences: SettingsPages.AppearancePage,
|
||||||
|
SelectTheme: SettingsPages.AppearancePage,
|
||||||
|
PageZoomInput: SettingsPages.AppearancePage,
|
||||||
|
CollapsiblePicker: SettingsPages.AppearancePage,
|
||||||
|
SeasonalBgGrid: SettingsPages.AppearancePage,
|
||||||
|
ChatBgGrid: SettingsPages.AppearancePage,
|
||||||
|
Messages: SettingsPages.MessagesPage,
|
||||||
|
SelectMessageLayout: SettingsPages.MessagesPage,
|
||||||
|
SelectMessageSpacing: SettingsPages.MessagesPage,
|
||||||
|
Editor: SettingsPages.MessagesPage,
|
||||||
|
ComposerToolbarReorder: SettingsPages.MessagesPage,
|
||||||
|
ComposerToolbarButtonRow: SettingsPages.MessagesPage,
|
||||||
|
DateAndTime: SettingsPages.MessagesPage,
|
||||||
|
SelectDateFormat: SettingsPages.MessagesPage,
|
||||||
|
DateHint: SettingsPages.MessagesPage,
|
||||||
|
CustomDateFormat: SettingsPages.MessagesPage,
|
||||||
|
PresetDateFormat: SettingsPages.MessagesPage,
|
||||||
|
Calls: SettingsPages.CallsPage,
|
||||||
|
Privacy: SettingsPages.DevicesPage,
|
||||||
|
SettingsSyncSection: SettingsPages.DataStoragePage,
|
||||||
|
AdvancedDisplayOptions: SettingsPages.DeveloperToolsPage,
|
||||||
|
DesktopChromeSetting: SettingsPages.DesktopPage,
|
||||||
|
AutostartSetting: SettingsPages.DesktopPage,
|
||||||
|
KeychainMirrorSetting: SettingsPages.DesktopPage,
|
||||||
|
DesktopSettings: SettingsPages.DesktopPage,
|
||||||
|
PackageUpdate: SettingsPages.DesktopPage,
|
||||||
|
AppUpdates: SettingsPages.DesktopPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FOLDER_PAGES: [RegExp, SettingsPages][] = [
|
||||||
|
[/\/account\//, SettingsPages.AccountPage],
|
||||||
|
[/\/notifications\//, SettingsPages.NotificationPage],
|
||||||
|
[/\/devices\//, SettingsPages.DevicesPage],
|
||||||
|
[/\/developer(-tools)?\//, SettingsPages.DeveloperToolsPage],
|
||||||
|
[/\/emojis-stickers\//, SettingsPages.EmojisStickersPage],
|
||||||
|
[/\/about\//, SettingsPages.AboutPage],
|
||||||
|
[/\/general\/(StorageUsage|YourData)\.tsx$/, SettingsPages.DataStoragePage],
|
||||||
|
];
|
||||||
|
|
||||||
|
const decode = (s: string): string =>
|
||||||
|
s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/'|'/g, "'")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/\\'/g, "'")
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
/** A JSX attribute's static text: "…", {'…'}, {"…"} or {`…`} without ${}. */
|
||||||
|
const attr = (tag: string, name: string): string | undefined => {
|
||||||
|
const m = new RegExp(
|
||||||
|
`\\b${name}=(?:"([^"]*)"|\\{\\s*'((?:[^'\\\\]|\\\\.)*)'\\s*\\}|\\{\\s*"([^"]*)"\\s*\\}|\\{\\s*\`([^\`$]*)\`\\s*\\})`,
|
||||||
|
).exec(tag);
|
||||||
|
const v = m && (m[1] ?? m[2] ?? m[3] ?? m[4]);
|
||||||
|
return v ? decode(v) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The opening tag starting at `start` (up to the matching '>' outside braces/strings). */
|
||||||
|
const openingTag = (src: string, start: number): string => {
|
||||||
|
let depth = 0;
|
||||||
|
let quote: string | null = null;
|
||||||
|
for (let i = start; i < src.length; i += 1) {
|
||||||
|
const c = src[i];
|
||||||
|
if (quote) {
|
||||||
|
if (c === quote && src[i - 1] !== '\\') quote = null;
|
||||||
|
} else if (c === '"' || c === "'" || c === '`') quote = c;
|
||||||
|
else if (c === '{') depth += 1;
|
||||||
|
else if (c === '}') depth -= 1;
|
||||||
|
else if (c === '>' && depth === 0) return src.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
return src.slice(start);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseSettingsSource = (path: string, src: string): SettingsEntry[] => {
|
||||||
|
const isGeneral = /\/general\/General\.tsx$/.test(path);
|
||||||
|
const folderPage = FOLDER_PAGES.find(([re]) => re.test(path))?.[1];
|
||||||
|
if (!isGeneral && folderPage === undefined) return [];
|
||||||
|
|
||||||
|
// For General.tsx: positions of each top-level function, to know the section.
|
||||||
|
const fns = isGeneral
|
||||||
|
? [...src.matchAll(/\n(?:export )?function (\w+)\(/g)].map((m) => ({
|
||||||
|
at: m.index ?? 0,
|
||||||
|
name: m[1],
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const pageAt = (pos: number): SettingsPages | undefined => {
|
||||||
|
if (!isGeneral) return folderPage;
|
||||||
|
let name: string | undefined;
|
||||||
|
fns.forEach((f) => {
|
||||||
|
if (f.at < pos) name = f.name;
|
||||||
|
});
|
||||||
|
return name ? GENERAL_SECTIONS[name] : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const out: SettingsEntry[] = [];
|
||||||
|
const headers = [...src.matchAll(/<Text size="L400">([^<{]+)<\/Text>/g)].map((m) => ({
|
||||||
|
at: m.index ?? 0,
|
||||||
|
text: decode(m[1]),
|
||||||
|
}));
|
||||||
|
const sectionAt = (pos: number): string | undefined => {
|
||||||
|
let s: string | undefined;
|
||||||
|
headers.forEach((h) => {
|
||||||
|
if (h.at < pos) s = h.text;
|
||||||
|
});
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const m of src.matchAll(/<SettingTile\b/g)) {
|
||||||
|
const at = m.index ?? 0;
|
||||||
|
const tag = openingTag(src, at);
|
||||||
|
const title = attr(tag, 'title');
|
||||||
|
const page = pageAt(at);
|
||||||
|
if (title && page !== undefined) {
|
||||||
|
out.push({
|
||||||
|
title,
|
||||||
|
description: attr(tag, 'description') ?? '',
|
||||||
|
page,
|
||||||
|
section: sectionAt(at),
|
||||||
|
desktopOnly: page === SettingsPages.DesktopPage || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headers.forEach((h) => {
|
||||||
|
const page = pageAt(h.at);
|
||||||
|
if (page !== undefined && !out.some((e) => e.title === h.text && e.page === page)) {
|
||||||
|
out.push({
|
||||||
|
title: h.text,
|
||||||
|
description: '',
|
||||||
|
page,
|
||||||
|
desktopOnly: page === SettingsPages.DesktopPage || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const norm = (s: string): string => s.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure: rank entries for a query. Every word must appear in the title,
|
||||||
|
* description or section; title matches rank first.
|
||||||
|
*/
|
||||||
|
export const searchSettings = (
|
||||||
|
entries: SettingsEntry[],
|
||||||
|
query: string,
|
||||||
|
{ desktop = false, limit = 30 }: { desktop?: boolean; limit?: number } = {},
|
||||||
|
): SettingsEntry[] => {
|
||||||
|
const words = norm(query).split(/\s+/).filter(Boolean);
|
||||||
|
if (words.length === 0) return [];
|
||||||
|
const scored: { e: SettingsEntry; score: number }[] = [];
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (e.desktopOnly && !desktop) return;
|
||||||
|
const title = norm(e.title);
|
||||||
|
const rest = norm(`${e.description} ${e.section ?? ''}`);
|
||||||
|
let score = 0;
|
||||||
|
const all = words.every((w) => {
|
||||||
|
if (title.startsWith(w)) score += 3;
|
||||||
|
else if (title.includes(w)) score += 2;
|
||||||
|
else if (rest.includes(w)) score += 1;
|
||||||
|
else return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (all) scored.push({ e, score });
|
||||||
|
});
|
||||||
|
return scored
|
||||||
|
.sort((a, b) => b.score - a.score || a.e.title.localeCompare(b.e.title))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((s) => s.e);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, CSSProperties } from 'react';
|
import React, { useEffect, useRef, CSSProperties } from 'react';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { color, config, Icon, IconButton, Icons } from 'folds';
|
import { Button, color, config, Icon, IconButton, Icons, Text } from 'folds';
|
||||||
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||||
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
|
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
@@ -43,13 +43,17 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toast.sticky) return;
|
if (toast.sticky) return;
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(
|
||||||
|
() => {
|
||||||
dismiss(toast.id);
|
dismiss(toast.id);
|
||||||
}, 4000);
|
},
|
||||||
|
// [Gitea #253] A toast with a button stays long enough to use it.
|
||||||
|
toast.actionLabel ? 10_000 : 4000,
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
||||||
};
|
};
|
||||||
}, [dismiss, toast.id, toast.sticky]);
|
}, [dismiss, toast.id, toast.sticky, toast.actionLabel]);
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
if (toast.onClick) {
|
if (toast.onClick) {
|
||||||
@@ -212,6 +216,24 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div style={bodyStyle}>{toast.body}</div>
|
<div style={bodyStyle}>{toast.body}</div>
|
||||||
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
|
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
|
||||||
|
{toast.actionLabel && toast.onAction && (
|
||||||
|
<div style={{ marginTop: config.space.S200 }}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toast.onAction?.();
|
||||||
|
dismiss(toast.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">{toast.actionLabel}</Text>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useContext } from 'react';
|
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';
|
import { ILoginFlowsResponse } from 'matrix-js-sdk/lib/@types/auth';
|
||||||
|
|
||||||
export enum RegisterFlowStatus {
|
export enum RegisterFlowStatus {
|
||||||
@@ -44,6 +44,13 @@ export const parseRegisterErrResp = (matrixError: MatrixError): RegisterFlowsRes
|
|||||||
export type AuthFlows = {
|
export type AuthFlows = {
|
||||||
loginFlows: ILoginFlowsResponse;
|
loginFlows: ILoginFlowsResponse;
|
||||||
registerFlows: RegisterFlowsResponse;
|
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);
|
const AuthFlowsContext = createContext<AuthFlows | null>(null);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { CallEmbed } from '../plugins/call';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { buildDenoisePayload } from '../utils/callDenoise';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply noise-suppression setting changes to a call that's already running
|
||||||
|
* (the fork's `io.lotus.set_denoise`): switching between off, browser-native
|
||||||
|
* and an ML model, or between models, takes effect without rejoining. The join
|
||||||
|
* itself still carries the settings in the call URL, so the first send is a
|
||||||
|
* no-op fork-side.
|
||||||
|
*/
|
||||||
|
export function useCallDenoise(embed: CallEmbed): void {
|
||||||
|
const {
|
||||||
|
callNoiseSuppression,
|
||||||
|
callDenoiseModel,
|
||||||
|
callDenoiseNativeNS,
|
||||||
|
callDenoiseGate,
|
||||||
|
callDenoiseGateThreshold,
|
||||||
|
} = useAtomValue(settingsAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
embed.control.setDenoise(
|
||||||
|
buildDenoisePayload({
|
||||||
|
callNoiseSuppression,
|
||||||
|
callDenoiseModel,
|
||||||
|
callDenoiseNativeNS,
|
||||||
|
callDenoiseGate,
|
||||||
|
callDenoiseGateThreshold,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
embed,
|
||||||
|
callNoiseSuppression,
|
||||||
|
callDenoiseModel,
|
||||||
|
callDenoiseNativeNS,
|
||||||
|
callDenoiseGate,
|
||||||
|
callDenoiseGateThreshold,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -3,7 +3,10 @@ import { useSetAtom } from 'jotai';
|
|||||||
import { CallEmbed } from '../plugins/call';
|
import { CallEmbed } from '../plugins/call';
|
||||||
import { useCallHangupEvent } from './useCallEmbed';
|
import { useCallHangupEvent } from './useCallEmbed';
|
||||||
import { toastQueueAtom } from '../state/toast';
|
import { toastQueueAtom } from '../state/toast';
|
||||||
import { describeCallEnd } from '../utils/callSummary';
|
import { describeCallDetails, describeCallEnd } from '../utils/callSummary';
|
||||||
|
import { callSummaryViewAtom } from '../state/callSummary';
|
||||||
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
|
||||||
// The fork sends io.lotus.call_summary on SFU disconnect, which normally lands
|
// The fork sends io.lotus.call_summary on SFU disconnect, which normally lands
|
||||||
// before Element Call's hangup echo; give a late one this long before toasting.
|
// before Element Call's hangup echo; give a late one this long before toasting.
|
||||||
@@ -16,6 +19,8 @@ const SUMMARY_GRACE_MS = 400;
|
|||||||
*/
|
*/
|
||||||
export function useCallEndedToast(embed: CallEmbed): void {
|
export function useCallEndedToast(embed: CallEmbed): void {
|
||||||
const setToast = useSetAtom(toastQueueAtom);
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
const setSummaryView = useSetAtom(callSummaryViewAtom);
|
||||||
|
const [callSummaryToast] = useSetting(settingsAtom, 'callSummaryToast');
|
||||||
const fired = useRef(false);
|
const fired = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -25,14 +30,25 @@ export function useCallEndedToast(embed: CallEmbed): void {
|
|||||||
useCallHangupEvent(embed, () => {
|
useCallHangupEvent(embed, () => {
|
||||||
if (fired.current || !embed.joined || embed.joinedAt === undefined) return;
|
if (fired.current || !embed.joined || embed.joinedAt === undefined) return;
|
||||||
fired.current = true;
|
fired.current = true;
|
||||||
|
if (!callSummaryToast) return;
|
||||||
const durationMs = Date.now() - embed.joinedAt;
|
const durationMs = Date.now() - embed.joinedAt;
|
||||||
|
const deafenedMs = embed.deafenedMs();
|
||||||
|
const roomName = embed.room.name ?? 'Voice call';
|
||||||
|
const nameOf = (userId: string) => embed.room.getMember(userId)?.name ?? userId;
|
||||||
const toast = () =>
|
const toast = () =>
|
||||||
setToast({
|
setToast({
|
||||||
id: `call-ended-${Date.now()}`,
|
id: `call-ended-${Date.now()}`,
|
||||||
displayName: 'Lotus Chat',
|
displayName: 'Lotus Chat',
|
||||||
body: describeCallEnd(durationMs, embed.lastSummary),
|
body: describeCallEnd(durationMs, embed.lastSummary),
|
||||||
roomName: embed.room.name ?? 'Voice call',
|
roomName,
|
||||||
roomId: embed.roomId,
|
roomId: embed.roomId,
|
||||||
|
// [Gitea #253] The full breakdown, snapshotted at hangup.
|
||||||
|
actionLabel: 'Details',
|
||||||
|
onAction: () =>
|
||||||
|
setSummaryView({
|
||||||
|
roomName,
|
||||||
|
sections: describeCallDetails(embed.lastSummary, durationMs, deafenedMs, nameOf),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (embed.lastSummary) toast();
|
if (embed.lastSummary) toast();
|
||||||
else setTimeout(toast, SUMMARY_GRACE_MS);
|
else setTimeout(toast, SUMMARY_GRACE_MS);
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ export type ClientConfig = {
|
|||||||
*/
|
*/
|
||||||
statusPages?: Record<string, unknown>;
|
statusPages?: Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #202] Web Push gateway per homeserver:
|
||||||
|
* { "<server name>": { gatewayUrl, appId, vapidPublicKey } }. `gatewayUrl` is
|
||||||
|
* the push gateway's `/_matrix/push/v1/notify` URL, `appId` the Sygnal app
|
||||||
|
* id, `vapidPublicKey` that app's VAPID public key (base64url). Homeservers
|
||||||
|
* not listed never register a pusher. Web only (the desktop app notifies
|
||||||
|
* natively while running).
|
||||||
|
*/
|
||||||
|
webPush?: Record<string, unknown>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Absolute https URL of the public web app (e.g. https://chat.lotusguild.org).
|
* Absolute https URL of the public web app (e.g. https://chat.lotusguild.org).
|
||||||
* The desktop app sets it so it can hand calls it can't make to the browser.
|
* The desktop app sets it so it can hand calls it can't make to the browser.
|
||||||
|
|||||||
@@ -17,22 +17,37 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) {
|
|||||||
const timer = useRef<number | undefined>(undefined);
|
const timer = useRef<number | undefined>(undefined);
|
||||||
const start = useRef<{ x: number; y: number } | null>(null);
|
const start = useRef<{ x: number; y: number } | null>(null);
|
||||||
const fired = useRef(false);
|
const fired = useRef(false);
|
||||||
|
// [Gitea #199] The element being pressed carries `data-pressing` (set on the
|
||||||
|
// DOM directly, no re-render) so iOS can't start a text selection under
|
||||||
|
// the finger; see `[data-touch-actions]` in room/message/styles.css.ts.
|
||||||
|
const pressed = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
const stopTimer = useCallback(() => {
|
||||||
if (timer.current !== undefined) window.clearTimeout(timer.current);
|
if (timer.current !== undefined) window.clearTimeout(timer.current);
|
||||||
timer.current = undefined;
|
timer.current = undefined;
|
||||||
start.current = null;
|
start.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The finger is up (or the gesture was cancelled): only now drop
|
||||||
|
// `data-pressing`, so the system selection can't start under a finger that
|
||||||
|
// is still down after the sheet opened.
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
stopTimer();
|
||||||
|
pressed.current?.removeAttribute('data-pressing');
|
||||||
|
pressed.current = null;
|
||||||
|
}, [stopTimer]);
|
||||||
|
|
||||||
const onTouchStart = useCallback(
|
const onTouchStart = useCallback(
|
||||||
(evt: React.TouchEvent) => {
|
(evt: React.TouchEvent) => {
|
||||||
if (!coarse || evt.touches.length !== 1) return;
|
if (!coarse || evt.touches.length !== 1) return;
|
||||||
const t = evt.touches[0];
|
const t = evt.touches[0];
|
||||||
start.current = { x: t.clientX, y: t.clientY };
|
start.current = { x: t.clientX, y: t.clientY };
|
||||||
fired.current = false;
|
fired.current = false;
|
||||||
|
pressed.current = evt.currentTarget as HTMLElement;
|
||||||
|
pressed.current.setAttribute('data-pressing', '');
|
||||||
timer.current = window.setTimeout(() => {
|
timer.current = window.setTimeout(() => {
|
||||||
const s = start.current;
|
const s = start.current;
|
||||||
clear();
|
stopTimer();
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
fired.current = true;
|
fired.current = true;
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
@@ -41,7 +56,7 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) {
|
|||||||
onLongPress(s.x, s.y);
|
onLongPress(s.x, s.y);
|
||||||
}, LONG_PRESS_MS);
|
}, LONG_PRESS_MS);
|
||||||
},
|
},
|
||||||
[coarse, clear, onLongPress],
|
[coarse, stopTimer, onLongPress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onTouchMove = useCallback(
|
const onTouchMove = useCallback(
|
||||||
@@ -72,7 +87,16 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
coarse,
|
coarse,
|
||||||
handlers: coarse ? { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: clear } : {},
|
handlers: coarse
|
||||||
|
? {
|
||||||
|
// [Gitea #199] Marks the element for the iOS hardening CSS.
|
||||||
|
'data-touch-actions': '',
|
||||||
|
onTouchStart,
|
||||||
|
onTouchMove,
|
||||||
|
onTouchEnd,
|
||||||
|
onTouchCancel: clear,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
/** True for ~1 s after a long-press fired (drop the Android contextmenu echo). */
|
/** True for ~1 s after a long-press fired (drop the Android contextmenu echo). */
|
||||||
suppressContextMenu: fired,
|
suppressContextMenu: fired,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const fillMissingPowers = (powerLevels: IPowerLevels): IPowerLevels =>
|
|||||||
const keys = Object.keys(DEFAULT_POWER_LEVELS) as unknown as (keyof IPowerLevels)[];
|
const keys = Object.keys(DEFAULT_POWER_LEVELS) as unknown as (keyof IPowerLevels)[];
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
if (draftPl[key] === undefined) {
|
if (draftPl[key] === undefined) {
|
||||||
draftPl[key] = DEFAULT_POWER_LEVELS[key] as any;
|
(draftPl as Record<string, unknown>)[key] = DEFAULT_POWER_LEVELS[key];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (draftPl.notifications && typeof draftPl.notifications.room !== 'number') {
|
if (draftPl.notifications && typeof draftPl.notifications.room !== 'number') {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { SetPresence } from 'matrix-js-sdk';
|
import { SetPresence } from 'matrix-js-sdk';
|
||||||
import { PresenceDeduper, PresenceWrite, writePresence } from '../utils/presenceWrite';
|
import { PresenceDeduper, PresenceWrite, queuePresenceWrite } from '../utils/presenceWrite';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
|
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
@@ -70,9 +70,25 @@ export function usePresenceUpdater() {
|
|||||||
// by all devices) and /sync already keeps us online — only send when the
|
// by all devices) and /sync already keeps us online — only send when the
|
||||||
// state or status actually changes, so a manual status save isn't 429'd
|
// state or status actually changes, so a manual status save isn't 429'd
|
||||||
// by a heartbeat that changed nothing.
|
// by a heartbeat that changed nothing.
|
||||||
|
// What the server reported in the last resolveStatus() (consumed by send).
|
||||||
|
let serverNow: { presence: string; status_msg?: string } | undefined;
|
||||||
const send = (write: PresenceWrite, label: string) => {
|
const send = (write: PresenceWrite, label: string) => {
|
||||||
|
const server = serverNow;
|
||||||
|
serverNow = undefined;
|
||||||
if (!deduper.shouldSend(write)) return Promise.resolve();
|
if (!deduper.shouldSend(write)) return Promise.resolve();
|
||||||
return writePresence(mx, write)
|
// The server already shows exactly this (e.g. /sync keeps us online and
|
||||||
|
// the status is unchanged): writing it again would only spend the
|
||||||
|
// 10-second budget a status save may need next.
|
||||||
|
if (
|
||||||
|
server &&
|
||||||
|
server.presence === write.presence &&
|
||||||
|
(server.status_msg ?? '') === (write.status_msg ?? '')
|
||||||
|
) {
|
||||||
|
deduper.sent(write);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
// Shared queue with the status save: waits out a 429, newest write wins.
|
||||||
|
return queuePresenceWrite(mx, write)
|
||||||
.then(() => deduper.sent(write))
|
.then(() => deduper.sent(write))
|
||||||
.catch((err) => warnPresenceFailure(label, err));
|
.catch((err) => warnPresenceFailure(label, err));
|
||||||
};
|
};
|
||||||
@@ -84,6 +100,7 @@ export function usePresenceUpdater() {
|
|||||||
const local = readStatus();
|
const local = readStatus();
|
||||||
if (!userId) return local;
|
if (!userId) return local;
|
||||||
const server = await mx.getPresence(userId).catch(() => undefined);
|
const server = await mx.getPresence(userId).catch(() => undefined);
|
||||||
|
serverNow = server;
|
||||||
const status = pickHeartbeatStatus({
|
const status = pickHeartbeatStatus({
|
||||||
local,
|
local,
|
||||||
server,
|
server,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useStateEventCallback } from './useStateEventCallback';
|
||||||
|
import { useForceUpdate } from './useForceUpdate';
|
||||||
|
import { getRoomBridges, RoomBridgeInfo } from '../utils/bridge';
|
||||||
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
|
|
||||||
|
export const useRoomBridges = (room: Room): RoomBridgeInfo[] => {
|
||||||
|
const [updateCount, forceUpdate] = useForceUpdate();
|
||||||
|
|
||||||
|
useStateEventCallback(
|
||||||
|
room.client,
|
||||||
|
useCallback(
|
||||||
|
(event) => {
|
||||||
|
const type = event.getType();
|
||||||
|
if (
|
||||||
|
event.getRoomId() === room.roomId &&
|
||||||
|
(type === StateEvent.Bridge || type === StateEvent.BridgeUnstable)
|
||||||
|
) {
|
||||||
|
forceUpdate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[room, forceUpdate],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => getRoomBridges(room),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[room, updateCount],
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -62,7 +62,7 @@ export const useServerStatus = (
|
|||||||
]);
|
]);
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
failures = 0;
|
failures = 0;
|
||||||
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups);
|
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups, page!.noticePrefix);
|
||||||
setStatus(last);
|
setStatus(last);
|
||||||
// While this client's connection is lost, keep asking every minute: that
|
// While this client's connection is lost, keep asking every minute: that
|
||||||
// is how it learns Kuma has checked the server since the drop.
|
// is how it learns Kuma has checked the server since the drop.
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export function useSettingsSync(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual actions for Settings → General → Sync. `pushNow` stamps a fresh
|
* Manual actions for Settings → Data & storage → Sync. `pushNow` stamps a fresh
|
||||||
* snapshot of THIS device so it wins on every other device; `clearRemote`
|
* snapshot of THIS device so it wins on every other device; `clearRemote`
|
||||||
* blanks the account-data event (other devices ignore the invalid content and
|
* blanks the account-data event (other devices ignore the invalid content and
|
||||||
* keep their local settings until someone pushes again).
|
* keep their local settings until someone pushes again).
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { MatrixClient, User, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
|
import {
|
||||||
|
ClientEvent,
|
||||||
|
MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
User,
|
||||||
|
UserEvent,
|
||||||
|
UserEventHandlerMap,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
|
||||||
export enum Presence {
|
export enum Presence {
|
||||||
@@ -19,7 +26,12 @@ export type UserPresence = {
|
|||||||
|
|
||||||
const getUserPresence = (user: User): UserPresence => ({
|
const getUserPresence = (user: User): UserPresence => ({
|
||||||
presence: user.presence as Presence,
|
presence: user.presence as Presence,
|
||||||
status: user.presenceStatusMsg,
|
// From the latest presence event rather than user.presenceStatusMsg: the SDK
|
||||||
|
// only overwrites that field with a non-empty status_msg, so a cleared status
|
||||||
|
// would otherwise linger until reload.
|
||||||
|
status: user.events.presence
|
||||||
|
? (user.events.presence.getContent().status_msg as string | undefined) || undefined
|
||||||
|
: user.presenceStatusMsg,
|
||||||
active: user.currentlyActive,
|
active: user.currentlyActive,
|
||||||
lastActiveTs: user.getLastActiveTs(),
|
lastActiveTs: user.getLastActiveTs(),
|
||||||
});
|
});
|
||||||
@@ -55,6 +67,19 @@ class PresenceStore {
|
|||||||
subs.forEach((cb) => cb());
|
subs.forEach((cb) => cb());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Our own User object is created by the SDK with `new User()` (not
|
||||||
|
// User.createUser), so its User.* events are never re-emitted on the client
|
||||||
|
// and the listeners above miss our own presence changes. The client does
|
||||||
|
// emit every sync presence event as ClientEvent.Event (after updating the
|
||||||
|
// User), so refresh from that too.
|
||||||
|
private handleClientEvent = (event: MatrixEvent): void => {
|
||||||
|
if (event.getType() !== 'm.presence' || !this.mx) return;
|
||||||
|
const userId = event.getSender();
|
||||||
|
if (!userId) return;
|
||||||
|
const user = this.mx.getUser(userId);
|
||||||
|
if (user) this.handleEvent(event, user);
|
||||||
|
};
|
||||||
|
|
||||||
private start(mx: MatrixClient): void {
|
private start(mx: MatrixClient): void {
|
||||||
if (this.started && this.mx === mx) return;
|
if (this.started && this.mx === mx) return;
|
||||||
// A different client (re-login without reload) — drop the old listeners.
|
// A different client (re-login without reload) — drop the old listeners.
|
||||||
@@ -62,12 +87,14 @@ class PresenceStore {
|
|||||||
this.mx.removeListener(UserEvent.Presence, this.handleEvent);
|
this.mx.removeListener(UserEvent.Presence, this.handleEvent);
|
||||||
this.mx.removeListener(UserEvent.CurrentlyActive, this.handleEvent);
|
this.mx.removeListener(UserEvent.CurrentlyActive, this.handleEvent);
|
||||||
this.mx.removeListener(UserEvent.LastPresenceTs, this.handleEvent);
|
this.mx.removeListener(UserEvent.LastPresenceTs, this.handleEvent);
|
||||||
|
this.mx.removeListener(ClientEvent.Event, this.handleClientEvent);
|
||||||
this.cache.clear();
|
this.cache.clear();
|
||||||
}
|
}
|
||||||
this.mx = mx;
|
this.mx = mx;
|
||||||
mx.on(UserEvent.Presence, this.handleEvent);
|
mx.on(UserEvent.Presence, this.handleEvent);
|
||||||
mx.on(UserEvent.CurrentlyActive, this.handleEvent);
|
mx.on(UserEvent.CurrentlyActive, this.handleEvent);
|
||||||
mx.on(UserEvent.LastPresenceTs, this.handleEvent);
|
mx.on(UserEvent.LastPresenceTs, this.handleEvent);
|
||||||
|
mx.on(ClientEvent.Event, this.handleClientEvent);
|
||||||
this.started = true;
|
this.started = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { IPusherRequest, MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useClientConfig } from './useClientConfig';
|
||||||
|
import { getNotificationState, usePermissionState } from './usePermission';
|
||||||
|
import { isTauri } from './useTauri';
|
||||||
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { notificationSnoozeUntilAtom } from '../state/notificationSnooze';
|
||||||
|
import {
|
||||||
|
base64UrlToBytes,
|
||||||
|
buildHttpPusher,
|
||||||
|
parseStoredPusher,
|
||||||
|
pusherDeviceName,
|
||||||
|
sameServerKey,
|
||||||
|
subscriptionKeys,
|
||||||
|
WEB_PUSH_STORAGE_KEY,
|
||||||
|
WebPushConfig,
|
||||||
|
webPushConfigFor,
|
||||||
|
} from '../utils/webPush';
|
||||||
|
|
||||||
|
/** Browser support for Web Push (iOS: only in the installed PWA). */
|
||||||
|
export const webPushSupported = (): boolean =>
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
'serviceWorker' in navigator &&
|
||||||
|
'PushManager' in window &&
|
||||||
|
'Notification' in window;
|
||||||
|
|
||||||
|
/** The gateway configured for the signed-in account's homeserver, if any. */
|
||||||
|
export function useWebPushConfig(): WebPushConfig | undefined {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const { webPush } = useClientConfig();
|
||||||
|
return useMemo(() => webPushConfigFor(webPush, mx.getDomain()), [webPush, mx]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readStored = () => {
|
||||||
|
try {
|
||||||
|
return parseStoredPusher(localStorage.getItem(WEB_PUSH_STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePusher = (mx: MatrixClient, appId: string, pushkey: string): Promise<unknown> =>
|
||||||
|
mx
|
||||||
|
.setPusher({ kind: null, app_id: appId, pushkey } as unknown as IPusherRequest)
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
async function register(mx: MatrixClient, config: WebPushConfig): Promise<void> {
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
if (!userId) return;
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
let sub = await registration.pushManager.getSubscription();
|
||||||
|
if (sub && !sameServerKey(sub.options.applicationServerKey, config.vapidPublicKey)) {
|
||||||
|
await sub.unsubscribe();
|
||||||
|
sub = null;
|
||||||
|
}
|
||||||
|
if (!sub) {
|
||||||
|
sub = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: base64UrlToBytes(config.vapidPublicKey),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const keys = subscriptionKeys(sub.toJSON());
|
||||||
|
if (!keys) return;
|
||||||
|
|
||||||
|
// A previous registration of this browser for the same account with another
|
||||||
|
// key or app id would otherwise keep receiving (and failing) pushes.
|
||||||
|
const stored = readStored();
|
||||||
|
if (
|
||||||
|
stored &&
|
||||||
|
stored.userId === userId &&
|
||||||
|
(stored.pushkey !== keys.p256dh || stored.appId !== config.appId)
|
||||||
|
) {
|
||||||
|
await removePusher(mx, stored.appId, stored.pushkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = (navigator.language || 'en').split('-')[0] || 'en';
|
||||||
|
await mx.setPusher(
|
||||||
|
buildHttpPusher(
|
||||||
|
config,
|
||||||
|
keys,
|
||||||
|
userId,
|
||||||
|
pusherDeviceName(navigator.userAgent),
|
||||||
|
lang,
|
||||||
|
) as unknown as IPusherRequest,
|
||||||
|
);
|
||||||
|
localStorage.setItem(
|
||||||
|
WEB_PUSH_STORAGE_KEY,
|
||||||
|
JSON.stringify({ appId: config.appId, pushkey: keys.p256dh, userId }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unregister(mx: MatrixClient): Promise<void> {
|
||||||
|
const stored = readStored();
|
||||||
|
if (!stored) return;
|
||||||
|
if (stored.userId === mx.getUserId()) await removePusher(mx, stored.appId, stored.pushkey);
|
||||||
|
localStorage.removeItem(WEB_PUSH_STORAGE_KEY);
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration();
|
||||||
|
const sub = await registration?.pushManager.getSubscription();
|
||||||
|
await sub?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #202] Background notifications for the web app.
|
||||||
|
*
|
||||||
|
* - Mirrors the notification prefs (desktop notifications, background push,
|
||||||
|
* pause, quiet hours) into the service worker, which shows pushes while no
|
||||||
|
* Lotus tab is open.
|
||||||
|
* - While the homeserver has a gateway configured (config.json `webPush`), push
|
||||||
|
* is supported, permission is granted and both settings are on: subscribes
|
||||||
|
* this browser and registers it as an HTTP pusher. Otherwise removes
|
||||||
|
* whatever this browser registered before.
|
||||||
|
*
|
||||||
|
* Not used in the desktop app, which notifies natively while running.
|
||||||
|
*/
|
||||||
|
export function useWebPush(): void {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const config = useWebPushConfig();
|
||||||
|
const permission = usePermissionState('notifications', getNotificationState());
|
||||||
|
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
|
const [backgroundPush] = useSetting(settingsAtom, 'backgroundPush');
|
||||||
|
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
|
||||||
|
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
|
||||||
|
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
|
||||||
|
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
|
||||||
|
|
||||||
|
const active = !isTauri() && webPushSupported();
|
||||||
|
const enabled = showNotifications && backgroundPush;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
navigator.serviceWorker.ready
|
||||||
|
.then((registration) =>
|
||||||
|
registration.active?.postMessage({
|
||||||
|
type: 'setPushPrefs',
|
||||||
|
prefs: { enabled, snoozeUntil, quietHoursEnabled, quietHoursStart, quietHoursEnd },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [active, enabled, snoozeUntil, quietHoursEnabled, quietHoursStart, quietHoursEnd]);
|
||||||
|
|
||||||
|
const wanted = active && !!config && permission === 'granted' && enabled;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const run = wanted && config ? register(mx, config) : unregister(mx);
|
||||||
|
run.catch((e) => {
|
||||||
|
// Push service unreachable, permission revoked meanwhile, etc. The app
|
||||||
|
// still notifies while open; nothing to surface beyond the console.
|
||||||
|
console.warn('[web push]', e);
|
||||||
|
});
|
||||||
|
}, [active, wanted, config, mx]);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import { useCompositionEndTracking } from '../hooks/useComposingCheck';
|
|||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { isWithinTimeWindow } from '../utils/timeWindow';
|
import { isWithinTimeWindow } from '../utils/timeWindow';
|
||||||
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
||||||
|
import { CallSummaryDialog } from '../features/call-summary/CallSummaryDialog';
|
||||||
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
||||||
import { useSessionAppeared } from '../hooks/useSessionAppeared';
|
import { useSessionAppeared } from '../hooks/useSessionAppeared';
|
||||||
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
|
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
|
||||||
@@ -248,6 +249,7 @@ function App() {
|
|||||||
<SeasonalEffect />
|
<SeasonalEffect />
|
||||||
<NightLightOverlay />
|
<NightLightOverlay />
|
||||||
<LotusToastContainer />
|
<LotusToastContainer />
|
||||||
|
<CallSummaryDialog />
|
||||||
</JotaiProvider>
|
</JotaiProvider>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { immersiveCallAtom } from '../state/callEmbed';
|
||||||
import { useCallEmbed } from '../hooks/useCallEmbed';
|
import { useCallEmbed } from '../hooks/useCallEmbed';
|
||||||
import { CallStatus } from '../features/call-status';
|
import { CallStatus } from '../features/call-status';
|
||||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||||
@@ -9,8 +11,10 @@ export function CallStatusRenderer() {
|
|||||||
const selectedRoom = useSelectedRoom();
|
const selectedRoom = useSelectedRoom();
|
||||||
|
|
||||||
const screenSize = useScreenSizeContext();
|
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;
|
if (screenSize === ScreenSize.Mobile && callEmbed.roomId === selectedRoom) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Box, Button, Text, config } from 'folds';
|
||||||
|
import { removeFallbackSession } from '../state/sessions';
|
||||||
|
import type { KeychainBootResult } from '../state/keychainSession';
|
||||||
|
import type { KeychainInvoke } from '../state/keychainMirror';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #105] Shown instead of the app when the desktop app's sign-in is kept
|
||||||
|
* in the OS keychain but couldn't be read this time. Never a silent logout:
|
||||||
|
* Retry boots again; signing in again is an explicit choice.
|
||||||
|
*/
|
||||||
|
export function KeychainUnavailable({
|
||||||
|
boot,
|
||||||
|
invoke,
|
||||||
|
}: {
|
||||||
|
boot: KeychainBootResult;
|
||||||
|
invoke: KeychainInvoke | undefined;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const missing = boot.mode === 'missing';
|
||||||
|
const signInAgain = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
removeFallbackSession();
|
||||||
|
await invoke?.('secure_session_clear').catch(() => undefined);
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
gap="400"
|
||||||
|
style={{ minHeight: '100vh', padding: config.space.S700, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<Text size="H3">Couldn't read your sign-in</Text>
|
||||||
|
<Text size="T300" style={{ maxWidth: 460 }}>
|
||||||
|
{missing
|
||||||
|
? 'Lotus Chat keeps your sign-in in Windows Credential Manager, but the entry is missing. If you removed it, sign in again.'
|
||||||
|
: 'Lotus Chat keeps your sign-in in Windows Credential Manager and couldn’t read it just now. This is usually temporary.'}
|
||||||
|
</Text>
|
||||||
|
{boot.mode === 'error' && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
{boot.error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
variant="Primary"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
<Text size="B400">Retry</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={signInAgain}
|
||||||
|
>
|
||||||
|
<Text size="B400">Sign in again</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ import { SHARE_PAGE_PATH } from '../../swShare';
|
|||||||
import { RouteError } from './RouteError';
|
import { RouteError } from './RouteError';
|
||||||
import { CallStatusRenderer } from './CallStatusRenderer';
|
import { CallStatusRenderer } from './CallStatusRenderer';
|
||||||
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
||||||
|
import { FloatingWidget } from '../features/room/widgets/FloatingWidget';
|
||||||
|
|
||||||
const Share = React.lazy(() => import('./client/share/Share').then((m) => ({ default: m.Share })));
|
const Share = React.lazy(() => import('./client/share/Share').then((m) => ({ default: m.Share })));
|
||||||
|
|
||||||
@@ -197,6 +198,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</ClientLayout>
|
</ClientLayout>
|
||||||
<CallStatusRenderer />
|
<CallStatusRenderer />
|
||||||
|
<FloatingWidget />
|
||||||
</CallEmbedProvider>
|
</CallEmbedProvider>
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<SearchModalRenderer />
|
<SearchModalRenderer />
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { SSOLogin } from '../SSOLogin';
|
|||||||
import { TokenLogin } from './TokenLogin';
|
import { TokenLogin } from './TokenLogin';
|
||||||
import { OidcLogin } from './OidcLogin';
|
import { OidcLogin } from './OidcLogin';
|
||||||
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||||
import { getOidcIssuer } from '../../../cs-api';
|
|
||||||
import { OrDivider } from '../OrDivider';
|
import { OrDivider } from '../OrDivider';
|
||||||
import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils';
|
import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils';
|
||||||
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
||||||
@@ -41,7 +40,7 @@ export function Login() {
|
|||||||
const server = useAuthServer();
|
const server = useAuthServer();
|
||||||
const discovery = useAutoDiscoveryInfo();
|
const discovery = useAutoDiscoveryInfo();
|
||||||
const { hashRouter } = useClientConfig();
|
const { hashRouter } = useClientConfig();
|
||||||
const { loginFlows } = useAuthFlows();
|
const { loginFlows, authMetadata } = useAuthFlows();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const loginSearchParams = useLoginSearchParams(searchParams);
|
const loginSearchParams = useLoginSearchParams(searchParams);
|
||||||
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
|
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
|
||||||
@@ -58,21 +57,19 @@ export function Login() {
|
|||||||
|
|
||||||
const parsedFlows = useParsedLoginFlows(loginFlows.flows);
|
const parsedFlows = useParsedLoginFlows(loginFlows.flows);
|
||||||
|
|
||||||
// Next-gen auth (MSC3861/MSC2965): when the server advertises an OIDC issuer in
|
// Next-gen auth (MSC3861): when the homeserver serves OAuth metadata
|
||||||
// its `.well-known` discovery, use the native OIDC flow and suppress the
|
// (`/auth_metadata`, e.g. matrix.org via MAS), use the native OAuth flow and
|
||||||
// password / legacy-SSO forms (those servers, e.g. MAS, reject `m.login.password`).
|
// suppress the password / legacy-SSO forms, as Element does. Detected the way
|
||||||
const oidc = getOidcIssuer(discovery);
|
// the SDK finds the auth server for refresh; the old `.well-known`
|
||||||
if (oidc.issuer) {
|
// `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 (
|
return (
|
||||||
<Box direction="Column" gap="500">
|
<Box direction="Column" gap="500">
|
||||||
<Text as="h1" size="H2" priority="400">
|
<Text as="h1" size="H2" priority="400">
|
||||||
Login
|
Login
|
||||||
</Text>
|
</Text>
|
||||||
<OidcLogin
|
<OidcLogin homeserverBaseUrl={discovery['m.homeserver'].base_url} serverName={server} />
|
||||||
issuer={oidc.issuer}
|
|
||||||
homeserverBaseUrl={discovery['m.homeserver'].base_url}
|
|
||||||
serverName={server}
|
|
||||||
/>
|
|
||||||
<span data-spacing-node />
|
<span data-spacing-node />
|
||||||
<Text align="Center">
|
<Text align="Center">
|
||||||
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
|
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';
|
import { startOidcLogin } from '../oidc/oidcLoginUtil';
|
||||||
|
|
||||||
type OidcLoginProps = {
|
type OidcLoginProps = {
|
||||||
issuer: string;
|
/** Register: open the auth server's sign-up page. */
|
||||||
|
register?: boolean;
|
||||||
homeserverBaseUrl: string;
|
homeserverBaseUrl: string;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
};
|
};
|
||||||
@@ -13,7 +14,7 @@ type OidcLoginProps = {
|
|||||||
* with the homeserver's OIDC provider and redirects to it. Mirrors `SSOLogin`'s
|
* with the homeserver's OIDC provider and redirects to it. Mirrors `SSOLogin`'s
|
||||||
* single-button styling.
|
* 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>>(
|
const [state, startLogin] = useAsyncCallback<void, unknown, Parameters<typeof startOidcLogin>>(
|
||||||
useCallback(startOidcLogin, []),
|
useCallback(startOidcLogin, []),
|
||||||
);
|
);
|
||||||
@@ -26,7 +27,9 @@ export function OidcLogin({ issuer, homeserverBaseUrl, serverName }: OidcLoginPr
|
|||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
{state.status === AsyncStatus.Error && (
|
{state.status === AsyncStatus.Error && (
|
||||||
<Text size="T200" align="Center" style={{ color: color.Critical.Main }}>
|
<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>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -35,7 +38,7 @@ export function OidcLogin({ issuer, homeserverBaseUrl, serverName }: OidcLoginPr
|
|||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
outlined
|
outlined
|
||||||
onClick={() => startLogin(issuer, homeserverBaseUrl)}
|
onClick={() => startLogin(homeserverBaseUrl, register ? 'create' : undefined)}
|
||||||
disabled={ongoing}
|
disabled={ongoing}
|
||||||
before={ongoing ? <Spinner size="200" variant="Secondary" /> : undefined}
|
before={ongoing ? <Spinner size="200" variant="Secondary" /> : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,43 +1,45 @@
|
|||||||
import { Box, Button, Icon, Icons, Spinner, Text, color, config } from 'folds';
|
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 { createClient } from 'matrix-js-sdk';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { setFallbackSession } from '../../../state/sessions';
|
import { setFallbackSession } from '../../../state/sessions';
|
||||||
import { completeAuthorizationCodeGrant } from './oidcLoginUtil';
|
import { completeOidcCodeGrant } from './oidcLoginUtil';
|
||||||
import { getOidcCallbackUrl } from './oidcConfig';
|
|
||||||
import {
|
import {
|
||||||
invalidateCachedClient,
|
invalidateCachedClient,
|
||||||
isStaleClientError,
|
isStaleClientError,
|
||||||
parseOidcCallbackParams,
|
parseOidcCallbackParams,
|
||||||
readStoredOidcIssuer,
|
readStoredOidcIssuer,
|
||||||
|
takePendingOidcLogin,
|
||||||
} from './oidcState';
|
} from './oidcState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange the authorization code for a Matrix session and persist it. The SDK
|
* Exchange the authorization code for a Matrix session and persist it. The
|
||||||
* (oidc-client-ts) reads the transient PKCE/state it stored in sessionStorage
|
* pending sign-in saved before the redirect must exist for this `state`
|
||||||
* before the redirect and validates `state` (CSRF). `redirectUri` is the
|
* (CSRF check) and is consumed here.
|
||||||
* deterministic callback URL, recomputed here.
|
|
||||||
*/
|
*/
|
||||||
const completeOidcLogin = async (code: string, state: string): Promise<void> => {
|
const completeOidcLogin = async (code: string, state: string): Promise<void> => {
|
||||||
const { tokenResponse, homeserverUrl, oidcClientSettings, idTokenClaims } =
|
const pending = takePendingOidcLogin(state);
|
||||||
await completeAuthorizationCodeGrant(code, 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
|
// Derive Matrix user_id + device_id from the freshly-issued access token (the
|
||||||
// device is owned by the authentication service under MSC3861).
|
// 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();
|
const { user_id: userId, device_id: deviceId } = await tmp.whoami();
|
||||||
if (!userId || !deviceId) {
|
if (!userId || !deviceId) {
|
||||||
throw new Error('Homeserver did not return a user/device for the OIDC session.');
|
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,
|
refreshToken: tokenResponse.refresh_token,
|
||||||
expiresInMs: tokenResponse.expires_in ? tokenResponse.expires_in * 1000 : undefined,
|
expiresInMs: tokenResponse.expires_in ? tokenResponse.expires_in * 1000 : undefined,
|
||||||
oidc: {
|
oidc: {
|
||||||
issuer: oidcClientSettings.issuer,
|
issuer: pending.issuer,
|
||||||
clientId: oidcClientSettings.clientId,
|
clientId: pending.clientId,
|
||||||
redirectUri: getOidcCallbackUrl(),
|
redirectUri: pending.redirectUri,
|
||||||
idTokenClaims: idTokenClaims as unknown as Record<string, unknown>,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -71,14 +73,19 @@ function CallbackError({ message }: { message: string }) {
|
|||||||
* router, since the redirect_uri must be a real non-hash path).
|
* router, since the redirect_uri must be a real non-hash path).
|
||||||
*/
|
*/
|
||||||
export function OidcCallback() {
|
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]>(
|
const [state, complete] = useAsyncCallback<void, unknown, [string, string]>(
|
||||||
useCallback(completeOidcLogin, []),
|
useCallback(completeOidcLogin, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
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]);
|
}, [params, complete]);
|
||||||
|
|
||||||
useEffect(() => {
|
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 { OIDC_CALLBACK_PATH } from '../../paths';
|
||||||
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
|
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
|
||||||
|
|
||||||
@@ -17,22 +17,21 @@ export const getOidcCallbackUrl = (): string =>
|
|||||||
withOriginBaseUrl(getOriginBaseUrl(), OIDC_CALLBACK_PATH);
|
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
|
* The SDK drops any URI that doesn't share `client_uri` as a common base, so
|
||||||
* base, so every URI here lives under our origin base.
|
* 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.
|
// `${origin}${BASE_URL}` (with trailing slash) — the common base for all URIs.
|
||||||
const clientUri = getOriginBaseUrl();
|
const clientUri = getOriginBaseUrl();
|
||||||
return {
|
return {
|
||||||
clientName: 'Lotus Chat',
|
client_name: 'Lotus Chat',
|
||||||
clientUri,
|
client_uri: clientUri,
|
||||||
logoUri: new URL(LotusLogo, window.location.origin).href,
|
logo_uri: new URL(LotusLogo, window.location.origin).href,
|
||||||
applicationType: 'web',
|
application_type: 'web',
|
||||||
contacts: ['support@lotusguild.org'],
|
tos_uri: clientUri,
|
||||||
tosUri: clientUri,
|
policy_uri: clientUri,
|
||||||
policyUri: clientUri,
|
redirect_uris: [getOidcCallbackUrl()],
|
||||||
redirectUris: [getOidcCallbackUrl()],
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,57 +1,88 @@
|
|||||||
import {
|
import { createClient, OAuth2, ValidatedAuthMetadata } from 'matrix-js-sdk';
|
||||||
completeAuthorizationCodeGrant,
|
|
||||||
discoverAndValidateOIDCIssuerWellKnown,
|
|
||||||
generateOidcAuthorizationUrl,
|
|
||||||
OidcClientConfig,
|
|
||||||
registerOidcClient,
|
|
||||||
} from 'matrix-js-sdk';
|
|
||||||
import { getOidcCallbackUrl, getOidcClientMetadata } from './oidcConfig';
|
import { getOidcCallbackUrl, getOidcClientMetadata } from './oidcConfig';
|
||||||
import {
|
import {
|
||||||
cacheClientId,
|
cacheClientId,
|
||||||
getCachedClientId,
|
getCachedClientId,
|
||||||
invalidateCachedClient,
|
invalidateCachedClient,
|
||||||
isStaleClientError,
|
isStaleClientError,
|
||||||
|
PendingOidcLogin,
|
||||||
|
savePendingOidcLogin,
|
||||||
} from './oidcState';
|
} 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 (
|
export const getOrRegisterClientId = async (
|
||||||
config: OidcClientConfig,
|
metadata: ValidatedAuthMetadata,
|
||||||
issuer: string,
|
|
||||||
redirectUri: string,
|
redirectUri: string,
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const cached = getCachedClientId(issuer, redirectUri);
|
const cached = getCachedClientId(metadata.issuer, redirectUri);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const clientId = await registerOidcClient(config, getOidcClientMetadata());
|
const clientId = await OAuth2.registerClient(metadata, getOidcClientMetadata());
|
||||||
cacheClientId(issuer, clientId, redirectUri);
|
cacheClientId(metadata.issuer, clientId, redirectUri);
|
||||||
return clientId;
|
return clientId;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Begin the OIDC authorization-code flow: discover + validate the issuer,
|
* Begin the OAuth authorization-code flow: discover the auth server through
|
||||||
* (re)register a client, build the PKCE authorization URL (oidc-client-ts
|
* the homeserver, (re)register a client, remember the PKCE verifier + device
|
||||||
* persists the transient code_verifier/state/nonce/homeserverUrl into
|
* under a fresh `state` (oidcState), then redirect. `prompt: 'create'` opens
|
||||||
* sessionStorage for the callback), then redirect.
|
* 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();
|
const redirectUri = getOidcCallbackUrl();
|
||||||
|
let issuer: string | undefined;
|
||||||
try {
|
try {
|
||||||
const config = await discoverAndValidateOIDCIssuerWellKnown(issuer);
|
const metadata = await discoverAuthMetadata(homeserverBaseUrl);
|
||||||
const clientId = await getOrRegisterClientId(config, issuer, redirectUri);
|
issuer = metadata.issuer;
|
||||||
const url = await generateOidcAuthorizationUrl({
|
const clientId = await getOrRegisterClientId(metadata, redirectUri);
|
||||||
metadata: config,
|
const oauth = new OAuth2(metadata, { clientId });
|
||||||
redirectUri,
|
const state = crypto.randomUUID();
|
||||||
|
const pending: PendingOidcLogin = {
|
||||||
|
issuer,
|
||||||
clientId,
|
clientId,
|
||||||
|
deviceId: oauth.context.deviceId,
|
||||||
|
codeVerifier: oauth.context.codeVerifier,
|
||||||
homeserverUrl: homeserverBaseUrl,
|
homeserverUrl: homeserverBaseUrl,
|
||||||
nonce: crypto.randomUUID(),
|
redirectUri,
|
||||||
});
|
};
|
||||||
window.location.assign(url);
|
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) {
|
} catch (e) {
|
||||||
// #67 — drop the cached client so the next attempt re-registers, but ONLY
|
// #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
|
// when the failure says the client id itself was rejected. Network and
|
||||||
// discovery failures keep the cache, otherwise each retry would register
|
// discovery failures keep the cache, otherwise each retry would register
|
||||||
// yet another throwaway dynamic client at the provider.
|
// yet another throwaway dynamic client at the provider.
|
||||||
if (isStaleClientError(e)) invalidateCachedClient(issuer);
|
if (issuer && isStaleClientError(e)) invalidateCachedClient(issuer);
|
||||||
throw e;
|
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,
|
parseOidcCallbackParams,
|
||||||
isStaleClientError,
|
isStaleClientError,
|
||||||
readStoredOidcIssuer,
|
readStoredOidcIssuer,
|
||||||
|
savePendingOidcLogin,
|
||||||
|
takePendingOidcLogin,
|
||||||
} from './oidcState';
|
} from './oidcState';
|
||||||
|
|
||||||
const installStorage = (): Map<string, string> => {
|
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 pending = {
|
||||||
const store = installSessionStorage();
|
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
|
assert.equal(readStoredOidcIssuer('xyz'), undefined); // nothing stored yet
|
||||||
store.set(
|
savePendingOidcLogin('xyz', pending);
|
||||||
'mx_oidc_xyz',
|
assert.equal(readStoredOidcIssuer('xyz'), 'https://issuer.example/');
|
||||||
JSON.stringify({ authority: 'https://issuer.example', client_id: 'abc' }),
|
|
||||||
);
|
|
||||||
assert.equal(readStoredOidcIssuer('xyz'), 'https://issuer.example');
|
|
||||||
assert.equal(readStoredOidcIssuer('other-state'), undefined); // different state = miss
|
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();
|
const store = installSessionStorage();
|
||||||
store.set('mx_oidc_bad', '{ not json');
|
store.set('lotus_oidc_pending_bad', '{ not json');
|
||||||
assert.equal(readStoredOidcIssuer('bad'), undefined);
|
assert.equal(readStoredOidcIssuer('bad'), undefined);
|
||||||
store.set('mx_oidc_noauth', JSON.stringify({ client_id: 'abc' }));
|
store.set('lotus_oidc_pending_partial', JSON.stringify({ ...pending, codeVerifier: '' }));
|
||||||
assert.equal(readStoredOidcIssuer('noauth'), undefined);
|
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
|
* [Gitea #201, SDK 44] The app keeps a pending OAuth sign-in itself (the SDK's
|
||||||
* error branch (we bail out before `completeAuthorizationCodeGrant`, which is
|
* OAuth2 class no longer persists anything). Saved in sessionStorage right
|
||||||
* what would otherwise surface it). oidc-client-ts's `generateOidcAuthorizationUrl`
|
* before the redirect, keyed by the `state` we send, which the provider echoes
|
||||||
* persists the pending sign-in's `SigninState` (including `authority` and
|
* back: a callback whose state has no entry is rejected (CSRF), and an entry is
|
||||||
* `client_id`) into sessionStorage before redirecting, keyed by
|
* used once. Holds the PKCE code verifier, so it never goes to localStorage.
|
||||||
* `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.
|
|
||||||
*/
|
*/
|
||||||
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 {
|
try {
|
||||||
const raw = sessionStorage.getItem(`mx_oidc_${state}`);
|
const raw = sessionStorage.getItem(`${PENDING_PREFIX}${state}`);
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
const { authority } = JSON.parse(raw) as { authority?: unknown };
|
const parsed: unknown = JSON.parse(raw);
|
||||||
return typeof authority === 'string' ? authority : undefined;
|
return isPending(parsed) ? parsed : undefined;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
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;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user