Compare commits
125
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37b3ae1ad7 | ||
|
|
5ecf4132f5 | ||
|
|
d412f9c56c | ||
|
|
ad5631819d | ||
|
|
83933fb5ac | ||
|
|
a7c9d15824 | ||
|
|
e6e52cdc37 | ||
|
|
4ab5a8fd06 | ||
|
|
4e30ae763e | ||
|
|
f9265d2d90 | ||
|
|
82c5728a96 | ||
|
|
dd6cf156ba | ||
|
|
e94cdfe5dc | ||
|
|
bb2ace82eb | ||
|
|
906a034e26 | ||
|
|
0a2f18e3d5 | ||
|
|
0d08ba0a55 | ||
|
|
6ae4d7adc0 | ||
|
|
b855eb505d | ||
|
|
f7bdd2855a | ||
|
|
26cf1204b5 | ||
|
|
e16c923bea | ||
|
|
cc8e7444f6 | ||
|
|
f9172cc339 | ||
|
|
8bbb436ca7 | ||
|
|
4ddcda122a | ||
|
|
9c433d9d77 | ||
|
|
e8fd0a1112 | ||
|
|
00e27a24e6 | ||
|
|
46d6570df3 | ||
|
|
d7ea408fd0 | ||
|
|
d3f01b5116 | ||
|
|
f84c0d6e9e | ||
|
|
1c62fb4722 | ||
|
|
a6c7333ea3 | ||
|
|
a425aa3572 | ||
|
|
f0b7400d1b | ||
|
|
d1270956ea | ||
|
|
3878a85339 | ||
|
|
9edb76835c | ||
|
|
1784d40ba1 | ||
|
|
76ef8a52c7 | ||
|
|
a3ef2b4098 | ||
|
|
0c5cf4109a | ||
|
|
39f74d51e6 | ||
|
|
36e697613b | ||
|
|
2d31312a73 | ||
|
|
57036bb6a4 | ||
|
|
c6419044cb | ||
|
|
29f6a35bfd | ||
|
|
78691f1fff | ||
|
|
a86947be48 | ||
|
|
37372b215a | ||
|
|
b6f9af3f58 | ||
|
|
5b5fbdb7b7 | ||
|
|
8b2322f751 | ||
|
|
02950499ea | ||
|
|
05d7e87d26 | ||
|
|
bbb2034080 | ||
|
|
400ee76b63 | ||
|
|
47c1e435b7 | ||
|
|
4650796120 | ||
|
|
d55a8ba8a5 | ||
|
|
4b8c3219e9 | ||
|
|
47328d6fee | ||
|
|
b0eb3da830 | ||
|
|
f22f9f07a3 | ||
|
|
2700aad209 | ||
|
|
a8a7e639a9 | ||
|
|
508a123150 | ||
|
|
fe61352d88 | ||
|
|
0bec28b651 | ||
|
|
75089ecd3f | ||
|
|
a4a163df3d | ||
|
|
70f1d640ed | ||
|
|
85a2abc46a | ||
|
|
5130dfc9c8 | ||
|
|
e57af60466 | ||
|
|
1f10866b60 | ||
|
|
d9c797f3f5 | ||
|
|
9b2ec91dd6 | ||
|
|
1618d71e53 | ||
|
|
4736e23400 | ||
|
|
06294f5269 | ||
|
|
fea9484a03 | ||
|
|
cf22fd8501 | ||
|
|
ddf9da1a55 | ||
|
|
861293243c | ||
|
|
e50bd677d6 | ||
|
|
022e5e6987 | ||
|
|
5c765925d2 | ||
|
|
747714b2f3 | ||
|
|
c9d276d2a5 | ||
|
|
68f44f0f1c | ||
|
|
30e2dbcde9 | ||
|
|
d1993f2084 | ||
|
|
6a7627fa26 | ||
|
|
8e30c73e2f | ||
|
|
6acdd439d5 | ||
|
|
9aec3691ac | ||
|
|
4c36c03066 | ||
|
|
fbdac30d18 | ||
|
|
27d659118f | ||
|
|
747400ea25 | ||
|
|
6ae0087213 | ||
|
|
d6548c56fd | ||
|
|
23f059d9a4 | ||
|
|
9b9f33b270 | ||
|
|
7d7a379ce0 | ||
|
|
91f82d60e3 | ||
|
|
f0865115a4 | ||
|
|
899e160aed | ||
|
|
3e5fdd0dab | ||
|
|
bb569d69a2 | ||
|
|
02d86caeb0 | ||
|
|
c0c93213c1 | ||
|
|
1ea6987083 | ||
|
|
be8e49a2bb | ||
|
|
1e217592fc | ||
|
|
ffe1d5a81e | ||
|
|
fff583fca5 | ||
|
|
e2b23397bd | ||
|
|
b929cbf74b | ||
|
|
8520de232d | ||
|
|
773e41311e |
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"matrix.lotusguild.org"
|
||||
],
|
||||
"allowCustomHomeservers": false,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
},
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,6 @@ test-results/
|
||||
# local dev homeserver (scripts/dev-homeserver.sh)
|
||||
.dev-homeserver/
|
||||
__pycache__/
|
||||
|
||||
# Local config backups
|
||||
*.bak
|
||||
|
||||
+12
-1
@@ -12,5 +12,16 @@
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
},
|
||||
"gifApiKey": ""
|
||||
"gifApiKey": "",
|
||||
"statusPages": {
|
||||
"matrix.lotusguild.org": {
|
||||
"url": "https://isitup.lotusguild.org",
|
||||
"slug": "matrix",
|
||||
"groups": {
|
||||
"homeserver": "Homeserver",
|
||||
"calls": "Voice calls",
|
||||
"login": "Login"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -42,7 +42,18 @@ export function collectConsole(page: Page): ConsoleCollector {
|
||||
const errors: string[] = [];
|
||||
const pageErrors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
if (msg.type() !== 'error') return;
|
||||
// The login page's registration probe (POST /_matrix/client/v3/register)
|
||||
// answers 403 "Registration has been disabled" on a homeserver with sign-up
|
||||
// closed (matrix.lotusguild.org since 2026-10-16: accounts come from SSO).
|
||||
// Allowed only for that URL so any other 403 still fails the test.
|
||||
if (
|
||||
/status of 403/.test(msg.text()) &&
|
||||
/\/_matrix\/client\/v3\/register/.test(msg.location().url)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
errors.push(msg.text());
|
||||
});
|
||||
page.on('pageerror', (err) => {
|
||||
pageErrors.push(err.message);
|
||||
|
||||
@@ -55,6 +55,52 @@ test.describe('local homeserver regression', () => {
|
||||
expect(console_.unexpected()).toEqual([]);
|
||||
});
|
||||
|
||||
test('you can vote on a poll you just created, without reloading (#284)', async ({ page }) => {
|
||||
const console_ = collectConsole(page);
|
||||
const room = await createRoom(alice, 'Poll Room');
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
const question = uniq('Lunch? ');
|
||||
await page.getByRole('button', { name: 'Create poll' }).first().click();
|
||||
await page.getByPlaceholder('Ask a question…').fill(question);
|
||||
await page.getByLabel('Option 1', { exact: true }).fill('Pizza');
|
||||
await page.getByLabel('Option 2', { exact: true }).fill('Tacos');
|
||||
await page.getByRole('button', { name: 'Create Poll' }).last().click();
|
||||
const poll = page.locator('[data-message-id]', { hasText: question }).last();
|
||||
await expect(poll).toBeVisible();
|
||||
type Ev = {
|
||||
type: string;
|
||||
event_id: string;
|
||||
content: { 'm.relates_to'?: { event_id?: string } };
|
||||
};
|
||||
const events = async () =>
|
||||
(
|
||||
await api<{ chunk: Ev[] }>(
|
||||
'GET',
|
||||
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=10`,
|
||||
alice.token,
|
||||
)
|
||||
).chunk;
|
||||
// Vote once the poll has reached the server (its row may still carry the local id).
|
||||
await expect
|
||||
.poll(async () => (await events()).some((e) => /poll\.start/.test(e.type)))
|
||||
.toBe(true);
|
||||
await poll.getByText('Tacos', { exact: true }).first().click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const chunk = await events();
|
||||
const start = chunk.find((e) => /poll\.start/.test(e.type));
|
||||
return chunk.some(
|
||||
(e) =>
|
||||
/poll\.response/.test(e.type) &&
|
||||
e.content['m.relates_to']?.event_id === start?.event_id &&
|
||||
start?.event_id.startsWith('$'),
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
expect(console_.unexpected()).toEqual([]);
|
||||
});
|
||||
|
||||
test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => {
|
||||
const room = await createRoom(alice, 'Scroll Room');
|
||||
await Array.from({ length: 40 }).reduce<Promise<unknown>>(
|
||||
@@ -257,12 +303,21 @@ test.describe('local homeserver regression', () => {
|
||||
await page.clock.install({ time: Date.now() + 14 * 60 * 1000 });
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await sendText(bob, room, `tick ${i}`);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
const ticks = async (from: number, n: number) => {
|
||||
for (let i = from; i < from + n; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await sendText(bob, room, `tick ${i}`);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
};
|
||||
await ticks(0, 4);
|
||||
// "Ahead" could be a late delivery (a stalled server), so it is only
|
||||
// reported once it has held for a minute of fresh samples.
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.getByText(/clock is .*ahead of the server/)).toHaveCount(0);
|
||||
await page.clock.fastForward('01:05');
|
||||
await ticks(4, 2);
|
||||
await expect(page.getByText(/clock is .*14 minutes ahead of the server/)).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Dismiss for 24 h' }).click();
|
||||
await expect(page.getByText(/clock is .*ahead of the server/)).toHaveCount(0);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { HS, ensureUser, hsReachable, loginUI, TestUser, uniq } from './localHs';
|
||||
|
||||
// [Gitea #110, #123] The device-security nudge. A fresh account has no key
|
||||
// backup and no cross-signing → "Set up key backup", but only after the 24 h
|
||||
// grace period on this device; "Not now" snoozes it.
|
||||
const strip = (page: Page) => page.getByRole('status').filter({ hasText: /encryption keys/ });
|
||||
|
||||
/** Back-date this device's first-seen record past the 24 h grace period, then reload. */
|
||||
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();
|
||||
}
|
||||
|
||||
/** The nudge waits until sync has settled (after "Connecting…"). */
|
||||
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 });
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
test.describe('security nudge (#110, #123)', () => {
|
||||
let user: TestUser;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!(await hsReachable()), `no local homeserver at ${HS} (set E2E_LOCAL_HS)`);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
user = await ensureUser(uniq('e2e_nudge_'));
|
||||
});
|
||||
|
||||
test('nothing during the first 24 h on a device', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
await loginUI(page, user);
|
||||
await settled(page);
|
||||
// Past the point where the nudge shows once the grace period is over (~5 s).
|
||||
await page.waitForTimeout(8000);
|
||||
await expect(strip(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('no key backup: "Set up key backup" opens Settings → Devices', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
await loginUI(page, user);
|
||||
await pastGrace(page);
|
||||
await settled(page);
|
||||
const nudge = strip(page);
|
||||
await expect(nudge).toContainText("Your encryption keys aren't backed up");
|
||||
await nudge.getByRole('button', { name: 'Set up' }).click();
|
||||
await expect(page.getByText('Device Verification').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('"Not now" snoozes it across a reload', async ({ page }) => {
|
||||
test.setTimeout(200_000);
|
||||
await loginUI(page, user);
|
||||
await pastGrace(page);
|
||||
await settled(page);
|
||||
await strip(page).getByRole('button', { name: 'Not now' }).click();
|
||||
await expect(strip(page)).toHaveCount(0);
|
||||
await page.reload();
|
||||
await settled(page);
|
||||
await expect(strip(page)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import {
|
||||
HS,
|
||||
createRoom,
|
||||
ensureUser,
|
||||
hsReachable,
|
||||
loginUI,
|
||||
openRoom,
|
||||
uniq,
|
||||
TestUser,
|
||||
} from './localHs';
|
||||
|
||||
// [Gitea #124] Status banner from the homeserver's Uptime Kuma page. Kuma is
|
||||
// answered by fixtures here; nothing contacts a real Kuma.
|
||||
const KUMA = 'https://isitup.lotusguild.org';
|
||||
const PAGE_CFG = { url: KUMA, slug: 'matrix' };
|
||||
const UP = 1;
|
||||
const DOWN = 0;
|
||||
|
||||
type Fixture = { hs?: number; calls?: number; staleMinutes?: number; fail?: boolean };
|
||||
|
||||
const kumaTime = (ms: number) => new Date(ms).toISOString().replace('T', ' ').slice(0, 23);
|
||||
|
||||
/** Serve config.json with a status page for `serverName` and answer Kuma from `fx()`. */
|
||||
async function mockKuma(page: Page, serverName: string | null, fx: () => Fixture) {
|
||||
const kumaRequests: string[] = [];
|
||||
await page.route(/\/config\.json(\?.*)?$/, async (route) => {
|
||||
const res = await route.fetch();
|
||||
const cfg = await res.json();
|
||||
cfg.statusPages = serverName ? { [serverName]: PAGE_CFG } : {};
|
||||
await route.fulfill({ response: res, json: cfg });
|
||||
});
|
||||
await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => {
|
||||
const url = route.request().url();
|
||||
kumaRequests.push(url);
|
||||
const f = fx();
|
||||
if (f.fail) {
|
||||
await route.fulfill({ status: 500, body: 'oops' });
|
||||
return;
|
||||
}
|
||||
const cors = { 'access-control-allow-origin': '*' };
|
||||
if (url.includes('/heartbeat/')) {
|
||||
// Two checks, the latest `staleMinutes` ago (0 = just now).
|
||||
const last = Date.now() - (f.staleMinutes ?? 0) * 60_000;
|
||||
const beats = (s: number) => [
|
||||
{ status: s, time: kumaTime(last - 60_000) },
|
||||
{ status: s, time: kumaTime(last) },
|
||||
];
|
||||
await route.fulfill({
|
||||
headers: cors,
|
||||
json: { heartbeatList: { 1: beats(f.hs ?? UP), 2: beats(f.calls ?? UP) }, uptimeList: {} },
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
headers: cors,
|
||||
json: {
|
||||
config: { slug: 'matrix' },
|
||||
incidents: [],
|
||||
maintenanceList: [],
|
||||
publicGroupList: [
|
||||
{ name: 'Homeserver', monitorList: [{ id: 1 }] },
|
||||
{ name: 'Voice calls', monitorList: [{ id: 2 }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
return kumaRequests;
|
||||
}
|
||||
|
||||
const serverOf = (u: TestUser) => u.userId.split(':').slice(1).join(':');
|
||||
const strip = (page: Page, text: RegExp) =>
|
||||
page.locator('[role="status"], [role="alert"]').filter({ hasText: text });
|
||||
const cutSync = (page: Page) =>
|
||||
page.route(/\/_matrix\/client\/v3\/sync/, (route) => route.abort('connectionfailed'));
|
||||
|
||||
test.describe('server status banner (#124)', () => {
|
||||
let alice: TestUser;
|
||||
let room: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!(await hsReachable()), `no local homeserver at ${HS} (set E2E_LOCAL_HS)`);
|
||||
alice = await ensureUser(uniq('e2e_status_'));
|
||||
room = await createRoom(alice, 'Status Room');
|
||||
});
|
||||
|
||||
test('calls down: amber strip, dismiss sticks across a reload', async ({ page }) => {
|
||||
await mockKuma(page, serverOf(alice), () => ({ calls: DOWN }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
const calls = strip(page, /Voice calls are down right now\. Messages still work\./);
|
||||
await expect(calls).toBeVisible();
|
||||
await calls.getByRole('button', { name: 'Dismiss' }).click();
|
||||
await expect(calls).toHaveCount(0);
|
||||
await page.reload();
|
||||
await openRoom(page, room);
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(calls).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('other homeservers never contact Kuma', async ({ page }) => {
|
||||
const requests = await mockKuma(page, 'some.other.server', () => ({ hs: DOWN }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await page.waitForTimeout(3000);
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
test('Kuma failing shows nothing (fail quiet)', async ({ page }) => {
|
||||
const requests = await mockKuma(page, serverOf(alice), () => ({ fail: true }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await expect.poll(() => requests.length).toBeGreaterThan(0);
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(strip(page, /server|Voice calls|Maintenance/i)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('connection lost + Kuma says the server is down: critical strip, not "Connection Lost"', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
await mockKuma(page, serverOf(alice), () => ({ hs: DOWN }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
// Connected: the server is "having problems" for others, not for us.
|
||||
await expect(strip(page, /server is having problems/)).toBeVisible();
|
||||
await cutSync(page);
|
||||
const down = page.getByRole('alert').filter({ hasText: /server is down\. We're on it/ });
|
||||
await expect(down).toBeVisible({ timeout: 120_000 });
|
||||
await expect(page.getByText('Connection Lost! Reconnecting...')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('connection lost + Kuma checked since and the server is up: "may be your connection"', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
await mockKuma(page, serverOf(alice), () => ({ hs: UP }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await cutSync(page);
|
||||
await expect(page.getByText(/server is up, so it may be your internet connection/)).toBeVisible(
|
||||
{
|
||||
timeout: 120_000,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('connection lost + only a stale "up" from Kuma: plain "Connection Lost"', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
await mockKuma(page, serverOf(alice), () => ({ hs: UP, staleMinutes: 10 }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await cutSync(page);
|
||||
await expect(page.getByText('Connection Lost! Reconnecting...')).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page.waitForTimeout(3000);
|
||||
await expect(page.getByText(/may be your internet connection/)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
Generated
+25
-23
@@ -45,12 +45,12 @@
|
||||
"html-react-parser": "6.1.2",
|
||||
"i18next": "26.2.0",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-http-backend": "4.0.0",
|
||||
"i18next-http-backend": "4.0.2",
|
||||
"immer": "11.1.8",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.20.0",
|
||||
"jsqr": "1.4.0",
|
||||
"katex": "0.16.47",
|
||||
"katex": "0.18.11",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"matrix-js-sdk": "41.7.0",
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.21",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
@@ -2695,9 +2695,9 @@
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||
},
|
||||
"node_modules/@lotusguild/element-call-embedded": {
|
||||
"version": "0.25.0-lotus.21",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.21/element-call-embedded-0.25.0-lotus.21.tgz",
|
||||
"integrity": "sha512-Phtjop2qdKRrCLqAhzipRaJaVnU3MjK9CV4QwHkwU/Xlj8oguZBfjOlV7zTqOTzAr57OteOfLcyxLsmv5+u9tg==",
|
||||
"version": "0.25.0-lotus.23",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.23/element-call-embedded-0.25.0-lotus.23.tgz",
|
||||
"integrity": "sha512-vRmcWeJcU0Accyl2x3mqNEzrIe8djE5BekyYbYOSCsMflC98rZgD1mO7KOe7bA1cX7gparRf6sAt1fotHoU+bA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
@@ -5026,9 +5026,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -8168,9 +8168,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.0.tgz",
|
||||
"integrity": "sha512-EgSjO3Q1G6f2Q5oy7u9mmxuesE0oSfzAD97NFBjC8EmkK4guBSYLljM0Fng3DarMWIIkU70jfo4+mUzmyVISTA==",
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.2.tgz",
|
||||
"integrity": "sha512-oay62dIB2kL7+WHzoUXBjWfL3+mwijD3pQkQkIEaRLhy6kjXxUhrRenV0gInwlCASAaJaDy94+XvYizK+cAGdA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -9162,28 +9162,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.47",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
|
||||
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
|
||||
"version": "0.18.11",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.11.tgz",
|
||||
"integrity": "sha512-yvyz/2VMKqoho/35Hat5w37wIEdWJ5ElVMidTAlWrxr5uW4aBGsEpveTmKh18/zw08v/7lGswCPon4OhqmqrTA==",
|
||||
"deprecated": "Accidentally published with breaking changes. Use 0.19.0 instead.",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^8.3.0"
|
||||
"commander": "^15.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/katex/node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
|
||||
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
@@ -12164,9 +12165,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
||||
+3
-3
@@ -72,12 +72,12 @@
|
||||
"html-react-parser": "6.1.2",
|
||||
"i18next": "26.2.0",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-http-backend": "4.0.0",
|
||||
"i18next-http-backend": "4.0.2",
|
||||
"immer": "11.1.8",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.20.0",
|
||||
"jsqr": "1.4.0",
|
||||
"katex": "0.16.47",
|
||||
"katex": "0.18.11",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"matrix-js-sdk": "41.7.0",
|
||||
@@ -108,7 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.21",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
|
||||
@@ -297,7 +297,7 @@ export function AccountDataEditor({
|
||||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
@@ -18,9 +18,10 @@ import {
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import {
|
||||
ClientEvent,
|
||||
EventTimelineSetHandlerMap,
|
||||
EventType,
|
||||
JoinRule,
|
||||
SyncState,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
@@ -38,7 +39,7 @@ import {
|
||||
} from '../hooks/useCallEmbed';
|
||||
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||
import { toastQueueAtom } from '../state/toast';
|
||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||
import { CallEmbed, useCallControlState, useScreensharePromptOpen } from '../plugins/call';
|
||||
import { hangupCallAndWait } from '../plugins/call/hangup';
|
||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
@@ -72,7 +73,8 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
|
||||
import { getStateEvent, getMemberName, isFromFilteredTimelineSet } from '../utils/room';
|
||||
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||
@@ -82,6 +84,7 @@ import { useRoomCallPolicy } from '../hooks/useRoomCallPolicy';
|
||||
import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet';
|
||||
import { CallAvatarAnimation } from '../styles/Animations.css';
|
||||
import { webRTCSupported } from '../utils/rtc';
|
||||
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
|
||||
import { zIndices } from '../styles/zIndex';
|
||||
|
||||
const PIP_MIN_W = 200;
|
||||
@@ -108,6 +111,8 @@ type IncomingCallInfo = {
|
||||
intent?: string;
|
||||
notificationType: RTCNotificationType;
|
||||
refEventId: string;
|
||||
/** [Gitea #313] The rtc.notification event itself: what a decline must reference (MSC4310). */
|
||||
notificationEventId?: string;
|
||||
};
|
||||
type IncomingCallProps = {
|
||||
dm: boolean;
|
||||
@@ -231,13 +236,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
</Text>
|
||||
)}
|
||||
{!webRTCSupported() && (
|
||||
<Text
|
||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||
size="L400"
|
||||
align="Center"
|
||||
>
|
||||
Your browser does not support WebRTC, which is required for calling.
|
||||
</Text>
|
||||
<CallsUnavailableMessage roomId={room.roomId} actionLabel="Answer in browser" />
|
||||
)}
|
||||
<Box direction="Column" gap="300">
|
||||
{willAnswerWithCamera && (
|
||||
@@ -286,7 +285,9 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
fill="Soft"
|
||||
size="400"
|
||||
radii="400"
|
||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||
onClick={() =>
|
||||
dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore()
|
||||
}
|
||||
before={<Icon size="200" src={Icons.Cross} filled />}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
@@ -458,7 +459,9 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||
onClick={() =>
|
||||
dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore()
|
||||
}
|
||||
before={<Icon size="100" src={Icons.Cross} filled />}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
@@ -499,6 +502,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
|
||||
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
||||
async (event, room, toStartOfTimeline, removed, data) => {
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
// only process rtc notification reference events.
|
||||
// we do not want to wait to decrypt all events.
|
||||
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
|
||||
@@ -586,12 +590,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
|
||||
// with no space parent. Persistent voice rooms (call rooms), space channels,
|
||||
// restricted rooms, and public rooms must never trigger ringing.
|
||||
if (room.isCallRoom()) return;
|
||||
const isDirect = directs.has(room.roomId);
|
||||
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
||||
const joinRule = room.getJoinRule();
|
||||
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
|
||||
if (!isDirect && !isPrivateInviteGroup) return;
|
||||
if (!isRingableRoom(room, directs.has(room.roomId))) return;
|
||||
|
||||
const info: IncomingCallInfo = {
|
||||
room,
|
||||
@@ -604,13 +603,59 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
: undefined,
|
||||
notificationType,
|
||||
refEventId,
|
||||
notificationEventId: event.getId(),
|
||||
};
|
||||
|
||||
setCallInfo(info);
|
||||
// [Gitea #312] Don't ring for a call that is already over or handled: after
|
||||
// a reload the first sync replays the ring together with the caller's
|
||||
// hang-up and our own decline. Check the room STATE (not the possibly
|
||||
// stale RTC session). The caller's membership can arrive just after the
|
||||
// ring, so an absent one gets a short grace period before giving up.
|
||||
const myUserId = mx.getSafeUserId();
|
||||
if (
|
||||
declinedRing(room, [refEventId, event.getId()], myUserId) ||
|
||||
hasActiveCallMembership(room, myUserId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (hasActiveCallMembership(room, sender)) {
|
||||
setCallInfo(info);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (Date.now() >= senderTs + lifetime) return;
|
||||
if (
|
||||
declinedRing(room, [refEventId, event.getId()], myUserId) ||
|
||||
hasActiveCallMembership(room, myUserId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (hasActiveCallMembership(room, sender)) setCallInfo(info);
|
||||
}, 4000);
|
||||
},
|
||||
[mx, directs, callEmbed, setToast],
|
||||
);
|
||||
|
||||
// [Gitea #312] A catch-up sync (after a reload or reconnect) can replay a
|
||||
// call's join and leave in one batch, and the RTC session may keep the join
|
||||
// (stale "1 Live", call screen instead of chat). Once sync is live again,
|
||||
// recompute every active session from the room's current state.
|
||||
useEffect(() => {
|
||||
const onSync = (state: SyncState, prev: SyncState | null) => {
|
||||
if (state !== SyncState.Syncing || prev === SyncState.Syncing) return;
|
||||
mx.getRooms().forEach((r) => {
|
||||
mx.matrixRTC
|
||||
.getActiveRoomSession(r)
|
||||
?._onRTCSessionMemberUpdate()
|
||||
.catch(() => undefined);
|
||||
});
|
||||
};
|
||||
mx.on(ClientEvent.Sync, onSync);
|
||||
return () => {
|
||||
mx.removeListener(ClientEvent.Sync, onSync);
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
useEffect(() => {
|
||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||
return () => {
|
||||
@@ -639,7 +684,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
const { memberships } = session;
|
||||
if (memberships.some((m) => m.sender === myUserId))
|
||||
dismiss(); // answered elsewhere
|
||||
else if (memberships.length === 0) dismiss(); // caller gone
|
||||
else if (memberships.length === 0)
|
||||
dismiss(); // caller gone
|
||||
// [Gitea #312] The session can be stale after a reload; the room state isn't.
|
||||
else if (!hasActiveCallMembership(room, callInfo.sender)) dismiss();
|
||||
};
|
||||
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
||||
event,
|
||||
@@ -649,9 +697,13 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
data,
|
||||
) => {
|
||||
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
|
||||
const related = event.getRelation()?.event_id;
|
||||
if (!related || related === refEventId) dismiss(); // declined elsewhere
|
||||
// [Gitea #313] Correct clients reference the notification; older Lotus
|
||||
// builds referenced the call membership. Accept both.
|
||||
if (!related || related === refEventId || related === callInfo.notificationEventId)
|
||||
dismiss(); // declined elsewhere
|
||||
}
|
||||
};
|
||||
session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
|
||||
@@ -882,6 +934,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
const callActive = callEmbed && joined;
|
||||
const callVisible = inCallRoom && callActive && !chatOnlyView;
|
||||
const pipMode = callActive && !inCallRoom;
|
||||
const pipPromptOpen = useScreensharePromptOpen(callEmbed?.control);
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control);
|
||||
@@ -1363,6 +1416,9 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
zIndex: 1,
|
||||
background: 'transparent',
|
||||
cursor: 'grab',
|
||||
// [Gitea #43] The call's "Share your screen?" prompt is drawn
|
||||
// inside the frame: let the click reach its Share button.
|
||||
pointerEvents: pipPromptOpen ? 'none' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
|
||||
@@ -26,6 +26,10 @@ import { useModalStyle } from '../hooks/useModalStyle';
|
||||
export function CloseBehaviorPrompt() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [launchOnLogin, setLaunchOnLogin] = useState(false);
|
||||
// [cinny-desktop #37] Stock GNOME has no tray host: "keep running in the
|
||||
// tray" would hide the window with no icon to bring it back. Older desktop
|
||||
// builds lack the command; assume a tray then (the old behaviour).
|
||||
const [hasTray, setHasTray] = useState(true);
|
||||
const modalStyle = useModalStyle(420);
|
||||
|
||||
// Tells the native side we're listening (see get_close_behavior).
|
||||
@@ -34,7 +38,17 @@ export function CloseBehaviorPrompt() {
|
||||
tauriInvoke()?.('get_close_behavior').catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useTauriEvent('lotus-close-requested', () => setOpen(true));
|
||||
useTauriEvent('lotus-close-requested', () => {
|
||||
const ask = tauriInvoke()?.('has_tray_host');
|
||||
if (!ask) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
ask
|
||||
.then((v) => setHasTray(v !== false))
|
||||
.catch(() => setHasTray(true))
|
||||
.finally(() => setOpen(true));
|
||||
});
|
||||
|
||||
const choose = useCallback(
|
||||
(value: 'tray' | 'quit') => {
|
||||
@@ -51,7 +65,7 @@ export function CloseBehaviorPrompt() {
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: '#close-behavior-keep',
|
||||
initialFocus: hasTray ? '#close-behavior-keep' : '#close-behavior-quit',
|
||||
// Dismissing without choosing keeps the window open; the next close
|
||||
// asks again.
|
||||
onDeactivate: () => setOpen(false),
|
||||
@@ -80,8 +94,9 @@ export function CloseBehaviorPrompt() {
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Text priority="400" id="close-behavior-body">
|
||||
Lotus Chat can keep running in the system tray when you close this window, so
|
||||
messages, notifications and calls still reach you. Open it again from the tray icon.
|
||||
{hasTray
|
||||
? 'Lotus Chat can keep running in the system tray when you close this window, so messages, notifications and calls still reach you. Open it again from the tray icon.'
|
||||
: "Your desktop doesn't show tray icons, so if Lotus Chat keeps running after you close this window, the only way back is to launch it again."}
|
||||
</Text>
|
||||
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||
<Checkbox
|
||||
@@ -95,12 +110,43 @@ export function CloseBehaviorPrompt() {
|
||||
<Text size="T300">Start Lotus Chat when I sign in to my computer</Text>
|
||||
</Box>
|
||||
<Box direction="Column" gap="200">
|
||||
<Button id="close-behavior-keep" variant="Primary" onClick={() => choose('tray')}>
|
||||
<Text size="B400">Keep running in the tray</Text>
|
||||
</Button>
|
||||
<Button variant="Secondary" fill="Soft" onClick={() => choose('quit')}>
|
||||
<Text size="B400">Quit when I close the window</Text>
|
||||
</Button>
|
||||
{hasTray ? (
|
||||
<>
|
||||
<Button
|
||||
id="close-behavior-keep"
|
||||
variant="Primary"
|
||||
onClick={() => choose('tray')}
|
||||
>
|
||||
<Text size="B400">Keep running in the tray</Text>
|
||||
</Button>
|
||||
<Button
|
||||
id="close-behavior-quit"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
onClick={() => choose('quit')}
|
||||
>
|
||||
<Text size="B400">Quit when I close the window</Text>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
id="close-behavior-quit"
|
||||
variant="Primary"
|
||||
onClick={() => choose('quit')}
|
||||
>
|
||||
<Text size="B400">Quit when I close the window</Text>
|
||||
</Button>
|
||||
<Button
|
||||
id="close-behavior-keep"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
onClick={() => choose('tray')}
|
||||
>
|
||||
<Text size="B400">Keep running in the background</Text>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<Text size="T200" priority="300">
|
||||
You can change this in Settings → General.
|
||||
|
||||
@@ -315,7 +315,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
|
||||
Setup Device Verification
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
@@ -361,7 +361,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
|
||||
Reset Device Verification
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { forwardRef, useCallback } from 'react';
|
||||
import React, { forwardRef, useCallback, useEffect, useState } from 'react';
|
||||
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
|
||||
import { useAtom } from 'jotai';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
@@ -9,6 +9,8 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useModalStyle } from '../hooks/useModalStyle';
|
||||
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
||||
import { InfoCard } from './info-card';
|
||||
import { ScheduledMessage } from '../state/scheduledMessages';
|
||||
import { cancelScheduledMessage, fetchScheduledMessages } from '../utils/scheduledMessages';
|
||||
import {
|
||||
useDeviceVerificationStatus,
|
||||
VerificationStatus,
|
||||
@@ -36,17 +38,36 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||||
|
||||
const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom);
|
||||
|
||||
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
|
||||
useCallback(async () => {
|
||||
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
|
||||
// membership (expires: 4 h) stays behind as a ghost participant and
|
||||
// everyone else sees you "in call" until it times out.
|
||||
if (callEmbed && callEmbed.joined && !callEmbed.disposed) {
|
||||
await hangupCallAndWait(mx, callEmbed);
|
||||
setCallEmbed(undefined);
|
||||
}
|
||||
await logoutClient(mx);
|
||||
}, [mx, callEmbed, setCallEmbed]),
|
||||
// [Gitea #322] Scheduled messages live on the server and still send after
|
||||
// logout, where no client would show them. Say so, and offer to cancel.
|
||||
const [scheduled, setScheduled] = useState<ScheduledMessage[]>([]);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetchScheduledMessages(mx)
|
||||
.then((list) => alive && setScheduled(list))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
const [logoutState, logout] = useAsyncCallback<void, Error, [boolean?]>(
|
||||
useCallback(
|
||||
async (cancelScheduled?: boolean) => {
|
||||
if (cancelScheduled) {
|
||||
await Promise.all(scheduled.map((m) => cancelScheduledMessage(mx, m.delayId)));
|
||||
}
|
||||
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
|
||||
// membership (expires: 4 h) stays behind as a ghost participant and
|
||||
// everyone else sees you "in call" until it times out.
|
||||
if (callEmbed && callEmbed.joined && !callEmbed.disposed) {
|
||||
await hangupCallAndWait(mx, callEmbed);
|
||||
setCallEmbed(undefined);
|
||||
}
|
||||
await logoutClient(mx);
|
||||
},
|
||||
[mx, callEmbed, setCallEmbed, scheduled],
|
||||
),
|
||||
);
|
||||
|
||||
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
||||
@@ -93,6 +114,13 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||||
description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages."
|
||||
/>
|
||||
))}
|
||||
{scheduled.length > 0 && (
|
||||
<InfoCard
|
||||
variant="Warning"
|
||||
title={`${scheduled.length} scheduled message${scheduled.length === 1 ? '' : 's'}`}
|
||||
description="Scheduled messages still send after you log out. Cancel them now if you don't want them sent."
|
||||
/>
|
||||
)}
|
||||
<Text priority="400">You’re about to log out. Are you sure?</Text>
|
||||
{logoutState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
@@ -102,12 +130,24 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||||
<Box direction="Column" gap="200">
|
||||
<Button
|
||||
variant="Critical"
|
||||
onClick={logout}
|
||||
onClick={() => logout()}
|
||||
disabled={ongoingLogout}
|
||||
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
|
||||
>
|
||||
<Text size="B400">Logout</Text>
|
||||
<Text size="B400">
|
||||
{scheduled.length > 0 ? 'Logout and keep scheduled messages' : 'Logout'}
|
||||
</Text>
|
||||
</Button>
|
||||
{scheduled.length > 0 && (
|
||||
<Button
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
onClick={() => logout(true)}
|
||||
disabled={ongoingLogout}
|
||||
>
|
||||
<Text size="B400">Cancel scheduled messages and logout</Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
|
||||
@@ -139,8 +139,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
||||
data.allowFederation,
|
||||
data.additionalCreators,
|
||||
),
|
||||
power_level_content_override:
|
||||
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
|
||||
// [Gitea #328] Every non-space room gets call.member at user level, not just
|
||||
// voice rooms: the call button is shown in private group rooms too, and with
|
||||
// Synapse's state_default (50) only admins could start or join a call there
|
||||
// ("You don't have permission to join!").
|
||||
power_level_content_override: createVoiceRoomPowerLevelsOverride(),
|
||||
initial_state: initialState,
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
|
||||
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
||||
import { preventScrollWithArrowKey } from '../../utils/keyboard';
|
||||
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
||||
@@ -612,7 +612,13 @@ export function EmojiBoard({
|
||||
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
!editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt),
|
||||
escapeDeactivates: stopPropagation,
|
||||
// [Gitea #304] The board opens with focus in its search field, where
|
||||
// the shared stopPropagation helper ignores Escape. Always close here,
|
||||
// without letting the key also close the room/modal underneath.
|
||||
escapeDeactivates: (evt: KeyboardEvent) => {
|
||||
evt.stopPropagation();
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<EmojiBoardLayout
|
||||
|
||||
@@ -51,9 +51,10 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { BreakWord } from '../../styles/Text.css';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { copyToClipboard } from '../../utils/dom';
|
||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { useLotusShareBase } from '../../hooks/useLotusLinkBase';
|
||||
import { getLotusRoomPermalink, getLotusSpacePermalink } from '../../plugins/lotus-permalink';
|
||||
|
||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||
limit: 1000,
|
||||
@@ -75,10 +76,14 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
const [showQr, setShowQr] = useState(false);
|
||||
|
||||
// [Gitea #285] A link into this deployment; matrix.to can't open Lotus Chat.
|
||||
const lotusBase = useLotusShareBase();
|
||||
const inviteUrl = (() => {
|
||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
||||
return getMatrixToRoom(roomIdOrAlias, viaServers);
|
||||
return room.isSpaceRoom()
|
||||
? getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers)
|
||||
: getLotusRoomPermalink(lotusBase, roomIdOrAlias, undefined, viaServers);
|
||||
})();
|
||||
|
||||
const handleCopyLink = () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import React, { CSSProperties, ReactNode, useEffect, useId, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds';
|
||||
import { IContent, MatrixEvent } from 'matrix-js-sdk';
|
||||
@@ -37,10 +37,14 @@ import {
|
||||
} from '../../../types/matrix/common';
|
||||
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
|
||||
import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
||||
import { formatAccuracy } from '../../utils/geoShare';
|
||||
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
||||
import { FileHeader, FileDownloadButton } from './FileHeader';
|
||||
|
||||
const COLLAPSE_MAX_HEIGHT = 320; // px ≈ 20 lines
|
||||
// ≈ 20 lines; in rem so a larger font setting doesn't show fewer lines (Gitea #343).
|
||||
const COLLAPSE_MAX_HEIGHT_REM = 20;
|
||||
const collapseMaxHeightPx = (): number =>
|
||||
COLLAPSE_MAX_HEIGHT_REM * (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16);
|
||||
|
||||
type CollapsibleBodyProps = {
|
||||
eventId?: string;
|
||||
@@ -78,7 +82,7 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
||||
const el = bodyRef.current;
|
||||
if (!el) return undefined;
|
||||
const observer = new ResizeObserver(() => {
|
||||
setNeedsCollapse(el.scrollHeight > COLLAPSE_MAX_HEIGHT);
|
||||
setNeedsCollapse(el.scrollHeight > collapseMaxHeightPx());
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
@@ -88,16 +92,36 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
||||
// (Gitea #85); useReducedMotion subscribes to the change event instead.
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
const bodyId = useId();
|
||||
const isClipped = needsCollapse && collapsed;
|
||||
// [Gitea #343] The clipped part stays in the DOM, so Tab can reach a link in
|
||||
// it; the browser would then scroll the overflow:hidden box itself (top cut
|
||||
// off, and it stayed that way). Expand instead when focus lands inside.
|
||||
const handleFocus = () => {
|
||||
if (isClipped) setCollapsed(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isClipped && bodyRef.current) bodyRef.current.scrollTop = 0;
|
||||
}, [isClipped]);
|
||||
|
||||
// Fade the content itself rather than painting a fixed-colour gradient over
|
||||
// it, so it blends on any background (bubbles, highlights, chat backgrounds).
|
||||
const fadeMask = 'linear-gradient(#000 calc(100% - 3rem), transparent)';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={bodyRef}
|
||||
id={bodyId}
|
||||
onFocus={handleFocus}
|
||||
style={{
|
||||
position: 'relative',
|
||||
...(needsCollapse && collapsed
|
||||
...(isClipped
|
||||
? {
|
||||
maxHeight: `${COLLAPSE_MAX_HEIGHT}px`,
|
||||
maxHeight: `${COLLAPSE_MAX_HEIGHT_REM}rem`,
|
||||
overflow: 'hidden',
|
||||
maskImage: fadeMask,
|
||||
WebkitMaskImage: fadeMask,
|
||||
transition: prefersReducedMotion ? undefined : 'max-height 0.2s ease',
|
||||
}
|
||||
: {
|
||||
@@ -106,23 +130,12 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{needsCollapse && collapsed && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3rem',
|
||||
background: `linear-gradient(transparent, ${color.Surface.Container})`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{needsCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={bodyId}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
@@ -133,7 +146,8 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
||||
}}
|
||||
>
|
||||
<Text as="span" size="T200" style={{ color: color.Primary.Main }}>
|
||||
{collapsed ? 'Read more ↓' : 'Show less ↑'}
|
||||
{collapsed ? 'Read more' : 'Show less'}
|
||||
<span aria-hidden="true">{collapsed ? ' ↓' : ' ↑'}</span>
|
||||
</Text>
|
||||
</button>
|
||||
)}
|
||||
@@ -732,6 +746,8 @@ export function MLocation({ content }: MLocationProps) {
|
||||
const lon = parseFloat(location.longitude);
|
||||
if (!isFinite(lat) || !isFinite(lon)) return <BrokenContent />;
|
||||
const description = typeof msc3488?.description === 'string' ? msc3488.description : undefined;
|
||||
// RFC 5870 `;u=` uncertainty (meters), sent by Lotus since Gitea #340.
|
||||
const accuracyLabel = formatAccuracy(Number(/;u=([\d.]+)/.exec(geoUri)?.[1]));
|
||||
const mapSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.007},${
|
||||
lat - 0.004
|
||||
},${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`;
|
||||
@@ -772,6 +788,7 @@ export function MLocation({ content }: MLocationProps) {
|
||||
)}
|
||||
<Text size="T300" priority="300">
|
||||
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
|
||||
{accuracyLabel && ` (${accuracyLabel})`}
|
||||
</Text>
|
||||
<Button
|
||||
as="a"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||
import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
|
||||
import { MatrixEvent, MatrixEventEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getMemberName, sendRoomEvent } from '../../../utils/room';
|
||||
import {
|
||||
@@ -99,7 +99,21 @@ export function PollContent({
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const roomId = room.roomId;
|
||||
const eventId = mEvent.getId();
|
||||
// [Gitea #284] A poll you just created starts with its local-echo id
|
||||
// (`~!room:txn`); the SDK swaps in the real `$event` id once it's sent but
|
||||
// doesn't re-render us. Track the id so votes and the tally use the real one.
|
||||
const [eventId, setEventId] = useState(() => mEvent.getId());
|
||||
useEffect(() => {
|
||||
const sync = () => setEventId(mEvent.getId());
|
||||
sync();
|
||||
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, sync);
|
||||
mEvent.on(MatrixEventEvent.Status, sync);
|
||||
return () => {
|
||||
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, sync);
|
||||
mEvent.off(MatrixEventEvent.Status, sync);
|
||||
};
|
||||
}, [mEvent]);
|
||||
const isLocalEcho = !!eventId?.startsWith('~');
|
||||
const senderId = mEvent.getSender();
|
||||
|
||||
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
|
||||
@@ -192,8 +206,9 @@ export function PollContent({
|
||||
const isMultiple = maxSelections > 1;
|
||||
|
||||
const showResults = resultsVisible(isUndisclosed, isEnded);
|
||||
const canVote = !!roomId && !!eventId && !isEnded;
|
||||
const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact);
|
||||
const canVote = !!roomId && !!eventId && !isLocalEcho && !isEnded;
|
||||
const canEnd =
|
||||
!!eventId && !isLocalEcho && !isEnded && (senderId === mx.getUserId() || canRedact);
|
||||
|
||||
const myVotes = pending ?? tally.myVotes;
|
||||
const { counts, total, voters } = tally;
|
||||
@@ -219,25 +234,34 @@ export function PollContent({
|
||||
setPending(next);
|
||||
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
|
||||
// accepts both namespaces).
|
||||
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.selections': Array.from(next),
|
||||
}).catch(() => setPending(null));
|
||||
// The SDK can throw synchronously (e.g. a relation to a pending event).
|
||||
try {
|
||||
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.selections': Array.from(next),
|
||||
}).catch(() => setPending(null));
|
||||
} catch {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndPoll = () => {
|
||||
if (!roomId || !eventId || ending) return;
|
||||
setEnding(true);
|
||||
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.poll.end': {},
|
||||
'm.text': 'The poll has ended.',
|
||||
})
|
||||
.then(() => {
|
||||
setConfirmEnd(false);
|
||||
setEnding(false);
|
||||
try {
|
||||
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.poll.end': {},
|
||||
'm.text': 'The poll has ended.',
|
||||
})
|
||||
.catch(() => setEnding(false));
|
||||
.then(() => {
|
||||
setConfirmEnd(false);
|
||||
setEnding(false);
|
||||
})
|
||||
.catch(() => setEnding(false));
|
||||
} catch {
|
||||
setEnding(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Radiogroup keyboard model (single-choice only): arrows move focus + selection.
|
||||
@@ -261,52 +285,69 @@ export function PollContent({
|
||||
handleVote(answers[idx].id);
|
||||
};
|
||||
|
||||
const headerLabel = isEnded
|
||||
? 'Poll closed · Final results'
|
||||
: `Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`;
|
||||
// [Gitea #246] Type line under the "Poll" label.
|
||||
const typeLabel = (() => {
|
||||
if (isEnded) return 'Final results';
|
||||
if (isUndisclosed) return 'Results hidden until the end';
|
||||
return isMultiple ? `Pick up to ${maxSelections}` : 'Single choice';
|
||||
})();
|
||||
|
||||
// Multiple choice counts people, not answers ("1 voter" who picked two).
|
||||
const unit = isMultiple ? 'voter' : 'vote';
|
||||
const footerNote = (() => {
|
||||
const totalPart = total > 0 ? `${total} ${unit}${total === 1 ? '' : 's'}` : `No ${unit}s yet`;
|
||||
if (isUndisclosed && !isEnded) {
|
||||
return canVote
|
||||
? 'Voting open · Results hidden until the poll ends'
|
||||
: 'Results hidden until the poll ends';
|
||||
? `${myVotes.size > 0 ? 'You voted' : 'Voting open'} · results shown when the poll ends`
|
||||
: 'Results shown when the poll ends';
|
||||
}
|
||||
const votesPart = total > 0 ? `${total} vote${total === 1 ? '' : 's'}` : 'No votes yet';
|
||||
if (isEnded) return `${votesPart} · Poll closed`;
|
||||
if (!canVote) return votesPart;
|
||||
if (isMultiple) return `${votesPart} · Select up to ${maxSelections}`;
|
||||
return `${votesPart} · ${myVotes.size > 0 ? 'Click to change' : 'Click to vote'}`;
|
||||
if (isEnded) return `${totalPart} · Poll closed`;
|
||||
if (!canVote) return totalPart;
|
||||
return `${totalPart} · ${myVotes.size > 0 ? 'Select another answer to change your vote' : 'Select an answer to vote'}`;
|
||||
})();
|
||||
|
||||
// Neutral tones derived from the theme's text colour, so they have contrast
|
||||
// in every theme (the Container/ContainerLine tokens were near-invisible).
|
||||
const muted = (pct: number) =>
|
||||
`color-mix(in srgb, ${color.SurfaceVariant.OnContainer} ${pct}%, transparent)`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-poll-content
|
||||
direction="Column"
|
||||
gap="200"
|
||||
style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }}
|
||||
gap="300"
|
||||
style={{
|
||||
// Same width for every poll (the message body shrink-wraps its content).
|
||||
width: toRem(460),
|
||||
maxWidth: '100%',
|
||||
margin: `${config.space.S100} 0`,
|
||||
padding: config.space.S300,
|
||||
borderRadius: config.radii.R400,
|
||||
border: `${config.borderWidth.B300} solid ${muted(18)}`,
|
||||
background: muted(4),
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
as="div"
|
||||
size="T200"
|
||||
priority="300"
|
||||
data-poll-content-label
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: config.space.S100,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>◉ </span>
|
||||
{headerLabel}
|
||||
</Text>
|
||||
<Text size="T400" style={{ fontWeight: 600 }}>
|
||||
{question}
|
||||
</Text>
|
||||
<Box direction="Column" gap="100">
|
||||
<Box alignItems="Center" gap="200" wrap="Wrap" data-poll-content-label>
|
||||
<Icon size="100" src={Icons.OrderList} style={{ opacity: 0.8 }} />
|
||||
<Text as="span" size="L400" style={{ fontWeight: 600 }}>
|
||||
Poll
|
||||
</Text>
|
||||
<Text as="span" size="T200" priority="300">
|
||||
·
|
||||
</Text>
|
||||
<Text as="span" size="T200" priority="300">
|
||||
{typeLabel}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="T400" style={{ fontWeight: 600, overflowWrap: 'anywhere' }}>
|
||||
{question}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{ marginTop: '2px' }}
|
||||
gap="200"
|
||||
role={isMultiple ? 'group' : 'radiogroup'}
|
||||
aria-label={question}
|
||||
onKeyDown={handleRadioKeyDown}
|
||||
@@ -316,20 +357,32 @@ export function PollContent({
|
||||
const text = answer.text;
|
||||
const selected = myVotes.has(id);
|
||||
const voteCount = counts.get(id) ?? 0;
|
||||
const pct = showResults && total > 0 ? Math.round((voteCount / total) * 100) : 0;
|
||||
const showBar = showResults && total > 0;
|
||||
const pct = showBar ? Math.round((voteCount / total) * 100) : 0;
|
||||
const isWinner = winners.has(id);
|
||||
const resultText = `${voteCount} ${voteCount === 1 ? 'vote' : 'votes'} · ${pct}%`;
|
||||
// Roving tabindex for the single-choice radiogroup; checkboxes stay tabbable.
|
||||
const tabIndex = isMultiple ? 0 : selected || (myVotes.size === 0 && i === 0) ? 0 : -1;
|
||||
let barColor = muted(40);
|
||||
if (isWinner) barColor = color.Success.Main;
|
||||
else if (selected) barColor = color.Primary.Main;
|
||||
let rowBorder = muted(22);
|
||||
if (isWinner) rowBorder = color.Success.Main;
|
||||
else if (selected) rowBorder = color.Primary.Main;
|
||||
const labelParts = [text];
|
||||
if (showBar) labelParts.push(resultText);
|
||||
if (isWinner) labelParts.push('winning answer');
|
||||
return (
|
||||
<React.Fragment key={id}>
|
||||
<button
|
||||
type="button"
|
||||
data-poll-answer
|
||||
data-selected={selected}
|
||||
data-winner={isWinner || undefined}
|
||||
role={isMultiple ? 'checkbox' : 'radio'}
|
||||
aria-checked={selected}
|
||||
aria-disabled={!canVote}
|
||||
aria-label={isWinner ? `${text}, winning answer` : undefined}
|
||||
aria-label={labelParts.join(', ')}
|
||||
aria-describedby={
|
||||
showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0
|
||||
? `poll-voters-${eventId}-${id}`
|
||||
@@ -340,60 +393,36 @@ export function PollContent({
|
||||
style={{
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
borderRadius: config.radii.R300,
|
||||
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
|
||||
border: `${config.borderWidth.B300} solid ${
|
||||
isWinner
|
||||
? color.Success.Main
|
||||
: selected
|
||||
? color.Primary.Main
|
||||
: color.SurfaceVariant.ContainerLine
|
||||
}`,
|
||||
background: selected ? muted(8) : 'transparent',
|
||||
border: `${config.borderWidth.B300} solid ${rowBorder}`,
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'left',
|
||||
cursor: canVote ? 'pointer' : 'default',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S100,
|
||||
gap: config.space.S200,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
{showResults && total > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
right: 'auto',
|
||||
width: `${pct}%`,
|
||||
background: selected
|
||||
? color.Primary.ContainerActive
|
||||
: color.SurfaceVariant.ContainerActive,
|
||||
pointerEvents: 'none',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: toRem(14),
|
||||
height: toRem(14),
|
||||
border: `${config.borderWidth.B300} solid ${
|
||||
selected ? color.Primary.Main : color.Primary.ContainerLine
|
||||
}`,
|
||||
boxSizing: 'border-box',
|
||||
width: toRem(18),
|
||||
height: toRem(18),
|
||||
border: `2px solid ${selected ? color.Primary.Main : muted(55)}`,
|
||||
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
|
||||
background: selected ? color.Primary.Main : 'transparent',
|
||||
display: 'flex',
|
||||
@@ -405,29 +434,72 @@ export function PollContent({
|
||||
>
|
||||
{selected ? <Icon size="50" src={Icons.Check} /> : null}
|
||||
</span>
|
||||
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
|
||||
<Text
|
||||
as="span"
|
||||
size="T300"
|
||||
style={{ flexGrow: 1, minWidth: 0, overflowWrap: 'anywhere' }}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
{isWinner && (
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Check}
|
||||
style={{ flexShrink: 0, color: color.Success.Main }}
|
||||
/>
|
||||
<Text
|
||||
as="span"
|
||||
size="T200"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S100,
|
||||
color: color.Success.Main,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<Icon size="50" src={Icons.Star} filled />
|
||||
Winner
|
||||
</Text>
|
||||
)}
|
||||
{showResults && total > 0 && (
|
||||
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||
{pct}%
|
||||
{showBar && (
|
||||
<Text
|
||||
as="span"
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{resultText}
|
||||
</Text>
|
||||
)}
|
||||
</span>
|
||||
{showBar && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: toRem(6),
|
||||
borderRadius: config.radii.Pill,
|
||||
background: muted(12),
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
height: '100%',
|
||||
width: `${pct}%`,
|
||||
borderRadius: config.radii.Pill,
|
||||
background: barColor,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0 && (
|
||||
<Text
|
||||
id={`poll-voters-${eventId}-${id}`}
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={{ padding: `0 ${config.space.S300} ${config.space.S100}` }}
|
||||
style={{ padding: `0 ${config.space.S300}`, marginTop: `-${config.space.S100}` }}
|
||||
>
|
||||
{`Voted by ${(voters.get(id) ?? []).map((s) => getMemberName(room, s)).join(', ')}`}
|
||||
</Text>
|
||||
@@ -436,48 +508,56 @@ export function PollContent({
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{canShowVoters && (
|
||||
<Box>
|
||||
<Chip
|
||||
variant={showVoters ? 'Primary' : 'SurfaceVariant'}
|
||||
radii="Pill"
|
||||
aria-pressed={showVoters}
|
||||
onClick={() => setShowVoters((v) => !v)}
|
||||
before={<Icon size="50" src={Icons.User} />}
|
||||
>
|
||||
<Text size="T200">{showVoters ? 'Hide voters' : 'Show who voted'}</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
)}
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="T200" priority="300" style={{ minWidth: 0 }}>
|
||||
<i>{footerNote}</i>
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200" wrap="Wrap">
|
||||
<Text size="T200" priority="300" style={{ minWidth: 0, flex: '1 1 12rem' }}>
|
||||
{footerNote}
|
||||
</Text>
|
||||
{canEnd &&
|
||||
(confirmEnd ? (
|
||||
<Box gap="100" shrink="No" alignItems="Center">
|
||||
<Chip
|
||||
variant="Critical"
|
||||
radii="Pill"
|
||||
aria-disabled={ending}
|
||||
onClick={ending ? undefined : handleEndPoll}
|
||||
>
|
||||
<Text size="T200">{ending ? 'Ending…' : 'End poll'}</Text>
|
||||
</Chip>
|
||||
<Chip variant="Secondary" radii="Pill" onClick={() => setConfirmEnd(false)}>
|
||||
<Text size="T200">Cancel</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
) : (
|
||||
<Box gap="100" shrink="No" alignItems="Center" wrap="Wrap">
|
||||
{canShowVoters && (
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
variant={showVoters ? 'Primary' : 'SurfaceVariant'}
|
||||
radii="Pill"
|
||||
onClick={() => setConfirmEnd(true)}
|
||||
before={<Icon size="50" src={Icons.Cross} />}
|
||||
aria-pressed={showVoters}
|
||||
onClick={() => setShowVoters((v) => !v)}
|
||||
before={<Icon size="50" src={Icons.User} />}
|
||||
>
|
||||
<Text size="T200">End poll</Text>
|
||||
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
|
||||
{showVoters ? 'Hide voters' : 'Who voted'}
|
||||
</Text>
|
||||
</Chip>
|
||||
))}
|
||||
)}
|
||||
{canEnd &&
|
||||
(confirmEnd ? (
|
||||
<>
|
||||
<Chip
|
||||
variant="Critical"
|
||||
radii="Pill"
|
||||
aria-disabled={ending}
|
||||
onClick={ending ? undefined : handleEndPoll}
|
||||
>
|
||||
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
|
||||
{ending ? 'Ending…' : 'End poll'}
|
||||
</Text>
|
||||
</Chip>
|
||||
<Chip variant="Secondary" radii="Pill" onClick={() => setConfirmEnd(false)}>
|
||||
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
|
||||
Cancel
|
||||
</Text>
|
||||
</Chip>
|
||||
</>
|
||||
) : (
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
onClick={() => setConfirmEnd(true)}
|
||||
before={<Icon size="50" src={Icons.Cross} />}
|
||||
>
|
||||
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
|
||||
End poll
|
||||
</Text>
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
import { GIF_PREVIEW_MAX_HEIGHT } from '../../utils/gifPreviewSize';
|
||||
|
||||
export const UrlPreview = style([
|
||||
DefaultReset,
|
||||
@@ -955,12 +956,14 @@ export const PortraitSideLayout = style([
|
||||
// GIF card (Giphy / Tenor)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Full card width; a GIF narrower than the card (portrait, square, small) is
|
||||
// centred on the surface colour instead of being stretched and cropped.
|
||||
export const GifThumbnailWrapper = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
maxHeight: toRem(200),
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
backgroundColor: color.Surface.Container,
|
||||
@@ -972,14 +975,26 @@ export const GifThumbnailWrapper = style([
|
||||
},
|
||||
]);
|
||||
|
||||
// Wraps just the GIF so the "GIF" badge sits on the GIF's own corner.
|
||||
export const GifFrame = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
// The inline width/aspect-ratio (from og:image:width/height) sets the size;
|
||||
// max-width lets a phone's narrower card shrink it, keeping the ratio.
|
||||
export const GifThumbnailImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: '100%',
|
||||
maxHeight: toRem(200),
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center',
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: toRem(GIF_PREVIEW_MAX_HEIGHT),
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
as,
|
||||
color,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { ImageOverlay } from '../ImageOverlay';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
@@ -48,6 +49,11 @@ import {
|
||||
tiktokOembedUrl,
|
||||
tiktokPlayerEmbedUrl,
|
||||
} from '../../utils/videoEmbed';
|
||||
import {
|
||||
GIF_PREVIEW_MAX_HEIGHT,
|
||||
GIF_PREVIEW_MAX_WIDTH,
|
||||
gifPreviewBox,
|
||||
} from '../../utils/gifPreviewSize';
|
||||
|
||||
const linkStyles = { color: color.Success.Main };
|
||||
|
||||
@@ -2277,6 +2283,9 @@ function GifCard({
|
||||
}) {
|
||||
const title = (prev['og:title'] as string | undefined) ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
// Size the GIF from its own dimensions (Giphy and Tenor publish them) rather
|
||||
// than forcing every GIF into one cropped band.
|
||||
const box = gifPreviewBox(prev['og:image:width'], prev['og:image:height']);
|
||||
|
||||
// A GIF card exists to show a moving GIF, so request the original rather than
|
||||
// a thumbnail — the thumbnail endpoint would return a frozen first frame.
|
||||
@@ -2284,7 +2293,15 @@ function GifCard({
|
||||
const thumbSrc = mxcImage
|
||||
? shouldServeGifOriginal(url, prev)
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication)
|
||||
: mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
|
||||
: mxcUrlToHttp(
|
||||
mx,
|
||||
mxcImage,
|
||||
useAuthentication,
|
||||
GIF_PREVIEW_MAX_WIDTH * 2,
|
||||
GIF_PREVIEW_MAX_HEIGHT * 2,
|
||||
'scale',
|
||||
false,
|
||||
)
|
||||
: null;
|
||||
|
||||
// If there's no image, fall back to a generic-style layout
|
||||
@@ -2316,7 +2333,9 @@ function GifCard({
|
||||
|
||||
return (
|
||||
<Box direction="Column" style={{ width: '100%' }}>
|
||||
{/* GIF thumbnail — full width */}
|
||||
{/* The whole GIF at its own aspect ratio, centred; never cropped. With a
|
||||
known size the box is reserved before it loads (no layout jump);
|
||||
without one it shows at its natural size, capped by the card. */}
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
@@ -2324,8 +2343,22 @@ function GifCard({
|
||||
className={previewCss.GifThumbnailWrapper}
|
||||
aria-label={`View GIF on ${siteBadgeLabel}: ${title}`}
|
||||
>
|
||||
<img className={previewCss.GifThumbnailImg} src={thumbSrc} alt={title} loading="lazy" />
|
||||
<span className={previewCss.GifBadge}>GIF</span>
|
||||
<span className={previewCss.GifFrame}>
|
||||
<img
|
||||
className={previewCss.GifThumbnailImg}
|
||||
src={thumbSrc}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
width={box?.width}
|
||||
height={box?.height}
|
||||
style={
|
||||
box
|
||||
? { width: toRem(box.width), aspectRatio: `${box.width} / ${box.height}` }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<span className={previewCss.GifBadge}>GIF</span>
|
||||
</span>
|
||||
</a>
|
||||
{/* Footer row */}
|
||||
<UrlPreviewContent>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
CallEmbed,
|
||||
useCallControlState,
|
||||
useCallMicLevel,
|
||||
useFrameScreenshare,
|
||||
useScreenshareMode,
|
||||
} from '../../plugins/call';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
@@ -197,10 +197,16 @@ export function CallControl({
|
||||
// Keep a forbidden control visible while its track is still live (so the user
|
||||
// can stop it); otherwise hide it entirely.
|
||||
const showCamera = allowCamera || video;
|
||||
// [Gitea #43] Hidden where EC shows its own screenshare button in the frame.
|
||||
const frameScreenshare = useFrameScreenshare(callEmbed.control);
|
||||
const showScreenshare = !frameScreenshare && (allowScreenshare || screenshare);
|
||||
// [Gitea #43] Hidden where EC shows its own screenshare button in the frame;
|
||||
// where the click can't be delegated, starting asks for the in-frame prompt.
|
||||
const screenshareMode = useScreenshareMode(callEmbed.control);
|
||||
const showScreenshare = screenshareMode !== 'hidden' && (allowScreenshare || screenshare);
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
const handleScreenshareToggle = () => {
|
||||
if (screenshare) callEmbed.control.toggleScreenshare();
|
||||
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
|
||||
else setShareConfirm(true);
|
||||
};
|
||||
|
||||
const handleMicrophoneToggle = useCallback(
|
||||
() => callEmbed.control.toggleMicrophone(),
|
||||
@@ -255,9 +261,7 @@ export function CallControl({
|
||||
{!compact && showScreenshare && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() =>
|
||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||
}
|
||||
onToggle={() => handleScreenshareToggle()}
|
||||
disabled={!callJoined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -72,9 +72,9 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
|
||||
size="T200"
|
||||
truncate
|
||||
style={{ color: color.Warning.Main }}
|
||||
title="Fix your computer's clock — calls and encryption depend on it"
|
||||
title="This device's clock is off. Calls and encryption depend on it: turn on automatic time in your system settings."
|
||||
>
|
||||
Clock {describeSkewVsServer(clockSkew.skewMs)} — calls will fail
|
||||
Device clock {describeSkewVsServer(clockSkew.skewMs)}: calls may fail
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
CallEmbed,
|
||||
useCallControlState,
|
||||
useCallMicLevel,
|
||||
useFrameScreenshare,
|
||||
useScreenshareMode,
|
||||
} from '../../plugins/call';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
@@ -112,9 +112,14 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
// can stop it); otherwise hide it entirely.
|
||||
const showCamera = allowCamera || video;
|
||||
// [Gitea #43] Where EC shows its own screenshare button in the frame, ours
|
||||
// is hidden (this engine can't start a share from the host); the
|
||||
// screenshare-audio mute stays.
|
||||
const frameScreenshare = useFrameScreenshare(callEmbed.control);
|
||||
// is hidden (the screenshare-audio mute stays); where the click can't be
|
||||
// delegated, starting asks for the fork's in-frame "Share your screen?".
|
||||
const screenshareMode = useScreenshareMode(callEmbed.control);
|
||||
const handleScreenshareToggle = () => {
|
||||
if (screenshare) callEmbed.control.toggleScreenshare();
|
||||
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
|
||||
else setShareConfirm(true);
|
||||
};
|
||||
const showScreenshare = allowScreenshare || screenshare;
|
||||
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
@@ -225,12 +230,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||
{showScreenshare && (
|
||||
<>
|
||||
{!frameScreenshare && (
|
||||
{screenshareMode !== 'hidden' && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() =>
|
||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||
}
|
||||
onToggle={() => handleScreenshareToggle()}
|
||||
/>
|
||||
)}
|
||||
{/* Mute-screenshare-audio sits directly next to the screenshare
|
||||
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
useCallJoined,
|
||||
useCallEmbedPlacementSync,
|
||||
useCallLoadError,
|
||||
useCallStart,
|
||||
} from '../../hooks/useCallEmbed';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { PrescreenControls } from './PrescreenControls';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
@@ -23,6 +24,7 @@ import { CallMemberRenderer } from './CallMemberCard';
|
||||
import * as css from './styles.css';
|
||||
import { CallControls } from './CallControls';
|
||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { CallsUnavailableMessage } from './CallsUnavailable';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
|
||||
function LivekitServerMissingMessage() {
|
||||
@@ -33,25 +35,19 @@ function LivekitServerMissingMessage() {
|
||||
);
|
||||
}
|
||||
|
||||
function WebRTCMissingError() {
|
||||
return (
|
||||
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||||
Your browser does not support WebRTC, which is required for calling.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function JoinMessage({
|
||||
roomId,
|
||||
hasParticipant,
|
||||
livekitSupported,
|
||||
rtcSupported,
|
||||
}: {
|
||||
roomId: string;
|
||||
hasParticipant?: boolean;
|
||||
livekitSupported?: boolean;
|
||||
rtcSupported?: boolean;
|
||||
}) {
|
||||
if (rtcSupported === false) {
|
||||
return <WebRTCMissingError />;
|
||||
return <CallsUnavailableMessage roomId={roomId} />;
|
||||
}
|
||||
|
||||
if (livekitSupported === false) {
|
||||
@@ -144,6 +140,7 @@ function CallPrescreen() {
|
||||
)}
|
||||
{!inOtherCall && hasPermission && !channelFull && (
|
||||
<JoinMessage
|
||||
roomId={room.roomId}
|
||||
hasParticipant={hasParticipant}
|
||||
livekitSupported={livekitSupported}
|
||||
rtcSupported={rtcSupported}
|
||||
@@ -164,6 +161,16 @@ function CallLoadErrorMessage() {
|
||||
// prescreen, where they can choose to join again.
|
||||
const dismiss = () => setCallEmbed(undefined);
|
||||
|
||||
// [Gitea #301] Try again: start a fresh embed for the same room with the
|
||||
// same mic/camera/sound choices (starting one disposes the hung one).
|
||||
const callEmbed = useCallEmbed();
|
||||
const startCall = useCallStart(useIsDirectRoom());
|
||||
const retry = () => {
|
||||
if (!callEmbed) return;
|
||||
const { room, control } = callEmbed;
|
||||
startCall(room, { microphone: control.microphone, video: control.video, sound: control.sound });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={css.CallViewContent}
|
||||
@@ -177,6 +184,9 @@ function CallLoadErrorMessage() {
|
||||
The call failed to load. Check your connection and try again.
|
||||
</Text>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Button variant="Primary" size="300" radii="400" onClick={retry} disabled={!callEmbed}>
|
||||
<Text size="B400">Try again</Text>
|
||||
</Button>
|
||||
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
|
||||
<Text size="B400">Back</Text>
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Button, Icon, Icons, Text, color } from 'folds';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { isTauri } from '../../hooks/useTauri';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
import { getCallInBrowserUrl, resolveWebAppUrl } from '../../utils/callInBrowser';
|
||||
|
||||
/**
|
||||
* [cinny-desktop: Linux calls] The desktop app on Linux runs on WebKitGTK,
|
||||
* which ships without WebRTC (disabled in 2.54; a libwebrtc backend is due
|
||||
* around 2.56). Where calls can't work here, send the user to the room in the
|
||||
* web app instead of a dead end. Only in the desktop app, and only when
|
||||
* config.json sets `webAppUrl`.
|
||||
*/
|
||||
export const useCallInBrowserUrl = (roomId: string): string | undefined => {
|
||||
const { webAppUrl } = useClientConfig();
|
||||
return useMemo(
|
||||
() =>
|
||||
isTauri() && !webRTCSupported()
|
||||
? getCallInBrowserUrl(resolveWebAppUrl(webAppUrl), roomId)
|
||||
: undefined,
|
||||
[webAppUrl, roomId],
|
||||
);
|
||||
};
|
||||
|
||||
/** Opens through the desktop's new-window handler → the system browser. */
|
||||
export const openCallInBrowser = (url: string): void => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const desktopUnavailableText = (): string =>
|
||||
typeof navigator !== 'undefined' && /Linux/i.test(navigator.userAgent)
|
||||
? 'Calls aren’t available in the desktop app on Linux yet: its web engine has no WebRTC.'
|
||||
: 'Calls aren’t available in this desktop app: its web engine has no WebRTC.';
|
||||
|
||||
type CallsUnavailableMessageProps = {
|
||||
roomId: string;
|
||||
/** Button label, e.g. "Answer in browser" for an incoming call. */
|
||||
actionLabel?: string;
|
||||
};
|
||||
|
||||
export function CallsUnavailableMessage({
|
||||
roomId,
|
||||
actionLabel = 'Open in browser',
|
||||
}: CallsUnavailableMessageProps) {
|
||||
const url = useCallInBrowserUrl(roomId);
|
||||
|
||||
if (!isTauri()) {
|
||||
return (
|
||||
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||||
Your browser does not support WebRTC, which is required for calling.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box direction="Column" alignItems="Center" gap="200" style={{ margin: 'auto' }}>
|
||||
<Text style={{ color: color.Warning.Main }} size="L400" align="Center">
|
||||
{desktopUnavailableText()}
|
||||
{url ? ' You can join from the web app in your browser.' : ''}
|
||||
</Text>
|
||||
{url && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
before={<Icon size="100" src={Icons.External} />}
|
||||
onClick={() => openCallInBrowser(url)}
|
||||
>
|
||||
<Text size="B300">{actionLabel}</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEm
|
||||
import { useCallPreferences } from '../../state/hooks/callPreferences';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useCallsDown } from '../server-status/ServerStatusBanner';
|
||||
|
||||
type MediaPermState = 'granted' | 'denied' | 'prompt' | 'unknown';
|
||||
|
||||
@@ -61,6 +62,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
||||
|
||||
const micPermission = useMediaPermissions();
|
||||
const micDenied = micPermission === 'denied';
|
||||
const callsDown = useCallsDown();
|
||||
|
||||
const disabled = inOtherCall || !canJoin || micDenied;
|
||||
|
||||
@@ -91,6 +93,11 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
||||
<ChatButton />
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="200">
|
||||
{callsDown && (
|
||||
<Text size="T200" style={{ color: color.Warning.Main, textAlign: 'center' }}>
|
||||
Voice calls are down right now, so joining may fail.
|
||||
</Text>
|
||||
)}
|
||||
{micDenied && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main, textAlign: 'center' }}>
|
||||
Microphone access is blocked. Enable it in your browser settings to join.
|
||||
|
||||
@@ -275,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
|
||||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
Binary file not shown.
@@ -40,6 +40,7 @@ import { creatorsSupported } from '../../../utils/matrix';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { BreakWord } from '../../../styles/Text.css';
|
||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
import { stateToCarry } from '../../../utils/roomUpgradeState';
|
||||
|
||||
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
||||
const mx = useMatrixClient();
|
||||
@@ -63,10 +64,41 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
||||
const [upgradeState, upgrade] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (version: string, newAdditionalCreators?: string[]) => {
|
||||
await mx.http.authedRequest(Method.Post, `/rooms/${room.roomId}/upgrade`, undefined, {
|
||||
new_version: version,
|
||||
additional_creators: newAdditionalCreators,
|
||||
});
|
||||
// [Gitea #327] Synapse doesn't copy Lotus room state (voice limit, call
|
||||
// policy, soundboard, retention, role tags, emoji packs) into the
|
||||
// replacement room: read it first, re-create it after the upgrade.
|
||||
const carried = stateToCarry(
|
||||
Array.from(room.currentState.events.values()).flatMap((byKey) =>
|
||||
Array.from(byKey.values()),
|
||||
),
|
||||
);
|
||||
const res = await mx.http.authedRequest<{ replacement_room?: string }>(
|
||||
Method.Post,
|
||||
`/rooms/${room.roomId}/upgrade`,
|
||||
undefined,
|
||||
{
|
||||
new_version: version,
|
||||
additional_creators: newAdditionalCreators,
|
||||
},
|
||||
);
|
||||
const newRoomId = res?.replacement_room;
|
||||
if (!newRoomId || carried.length === 0) return;
|
||||
const results = await Promise.allSettled(
|
||||
carried.map((st) =>
|
||||
mx.http.authedRequest(
|
||||
Method.Put,
|
||||
`/rooms/${encodeURIComponent(newRoomId)}/state/${encodeURIComponent(st.type)}/${encodeURIComponent(st.stateKey)}`,
|
||||
undefined,
|
||||
st.content,
|
||||
),
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
if (failed > 0) {
|
||||
throw new Error(
|
||||
`Room upgraded, but ${failed} of ${carried.length} Lotus settings (voice limit, call policy, soundboard, retention…) could not be copied to the new room.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[mx, room],
|
||||
),
|
||||
|
||||
@@ -51,7 +51,7 @@ const dark: CSSProperties = {
|
||||
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
||||
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
animation: `${auroraFlow} 60s ease-in-out infinite`,
|
||||
animation: `${auroraFlow} 60s steps(180) infinite`,
|
||||
};
|
||||
|
||||
const light: CSSProperties = {
|
||||
@@ -75,7 +75,7 @@ const light: CSSProperties = {
|
||||
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
||||
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
animation: `${auroraFlow} 60s ease-in-out infinite`,
|
||||
animation: `${auroraFlow} 60s steps(180) infinite`,
|
||||
};
|
||||
|
||||
export const animAurora: ChatBgVariants = { dark, light };
|
||||
|
||||
@@ -55,7 +55,7 @@ export const animFireflies: ChatBgVariants = {
|
||||
'0 0', // wash A (static)
|
||||
'0 0', // wash B (static)
|
||||
].join(','),
|
||||
animation: `${firefliesDrift} 44s linear infinite`,
|
||||
animation: `${firefliesDrift} 44s steps(528) infinite`,
|
||||
},
|
||||
|
||||
// Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with
|
||||
@@ -93,6 +93,6 @@ export const animFireflies: ChatBgVariants = {
|
||||
'0 0', // wash A (static)
|
||||
'0 0', // wash B (static)
|
||||
].join(','),
|
||||
animation: `${firefliesDrift} 44s linear infinite`,
|
||||
animation: `${firefliesDrift} 44s steps(528) infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -76,7 +76,7 @@ export const animPulse: ChatBgVariants = {
|
||||
'0 0', // base wash
|
||||
'0 0', // vignette
|
||||
].join(','),
|
||||
animation: `${gridPulse} 22s ease-in-out infinite`,
|
||||
animation: `${gridPulse} 22s steps(528) infinite`,
|
||||
},
|
||||
|
||||
// Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe.
|
||||
@@ -116,6 +116,6 @@ export const animPulse: ChatBgVariants = {
|
||||
'0 0', // base wash
|
||||
'0 0', // vignette
|
||||
].join(','),
|
||||
animation: `${gridPulse} 22s ease-in-out infinite`,
|
||||
animation: `${gridPulse} 22s steps(528) infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -97,7 +97,7 @@ export const animRain: ChatBgVariants = {
|
||||
].join(','),
|
||||
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||||
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||||
animation: `${rainFall} 12s linear infinite`,
|
||||
animation: `${rainFall} 12s steps(288) infinite`,
|
||||
},
|
||||
|
||||
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
|
||||
@@ -118,6 +118,6 @@ export const animRain: ChatBgVariants = {
|
||||
].join(','),
|
||||
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||||
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||||
animation: `${rainFall} 12s linear infinite`,
|
||||
animation: `${rainFall} 12s steps(288) infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ export const animStars: ChatBgVariants = {
|
||||
'0 0', // nebula A
|
||||
'0 0', // nebula B
|
||||
].join(','),
|
||||
animation: `${starDrift} 90s linear infinite`,
|
||||
animation: `${starDrift} 90s steps(1080) infinite`,
|
||||
},
|
||||
|
||||
// Light: an airy pre-dawn sky. No literal white-on-white stars — instead very
|
||||
@@ -112,6 +112,6 @@ export const animStars: ChatBgVariants = {
|
||||
'0 0', // wash A
|
||||
'0 0', // wash B
|
||||
].join(','),
|
||||
animation: `${starDrift} 100s linear infinite`,
|
||||
animation: `${starDrift} 100s steps(1200) infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -149,6 +149,11 @@ export const getChatBg = (
|
||||
}
|
||||
// For animated backgrounds, promote the element to its own compositor layer so
|
||||
// background-position keyframes don't trigger repaints on descendant elements.
|
||||
// [Gitea #337] background-position can't run on the compositor, so every
|
||||
// animation frame repaints the whole chat area (45 to 70% main-thread busy
|
||||
// at 60 fps). The drifts are slow (3–22 px/s), so each animation uses steps():
|
||||
// ~12 updates/s for the slow ones, ~24/s for rain and pulse: visually the
|
||||
// same, a fraction of the repaints.
|
||||
if (style.animation) {
|
||||
return { ...style, willChange: 'background-position', contain: 'paint' };
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
|
||||
import { VirtualTile } from '../../components/virtualizer';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { MsgTypeFilter } from './useMessageSearch';
|
||||
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
||||
|
||||
const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [
|
||||
{ msgType: 'm.image', label: 'Images', icon: Icons.Photo },
|
||||
@@ -137,14 +138,19 @@ type SelectRoomButtonProps = {
|
||||
onChange: (rooms?: string[]) => void;
|
||||
};
|
||||
function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [localSelected, setLocalSelected] = useState(selectedRooms);
|
||||
|
||||
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
|
||||
(rId) => mx.getRoom(rId)?.name ?? rId,
|
||||
[mx],
|
||||
(rId) => {
|
||||
const name = mx.getRoom(rId)?.name ?? rId;
|
||||
// [Gitea #336] match the personal "Rename for me" name too
|
||||
return localNames[rId] ? [localNames[rId], name] : name;
|
||||
},
|
||||
[mx, localNames],
|
||||
);
|
||||
|
||||
const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
|
||||
@@ -292,7 +298,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
||||
}
|
||||
>
|
||||
<Text truncate size="T300">
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</VirtualTile>
|
||||
@@ -701,6 +707,7 @@ export function SearchFilters({
|
||||
pinnedOnly,
|
||||
onTogglePinnedOnly,
|
||||
}: SearchFiltersProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
|
||||
return (
|
||||
@@ -748,7 +755,7 @@ export function SearchFilters({
|
||||
}
|
||||
after={<Icon size="50" src={Icons.Cross} />}
|
||||
>
|
||||
<Text size="T200">{room.name}</Text>
|
||||
<Text size="T200">{localNames[room.roomId] ?? room.name}</Text>
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from '../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
||||
|
||||
type SearchResultGroupProps = {
|
||||
room: Room;
|
||||
@@ -73,6 +74,7 @@ export function SearchResultGroup({
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
}: SearchResultGroupProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
|
||||
@@ -212,7 +214,7 @@ export function SearchResultGroup({
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="H4" truncate>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
foldEdits,
|
||||
filterGroupsByDateRange,
|
||||
filterGroupsByMsgType,
|
||||
filterGroupsByPinned,
|
||||
@@ -109,3 +110,47 @@ test('filterGroupsByPinned: keeps pinned items and drops empty groups', () => {
|
||||
['$2'],
|
||||
);
|
||||
});
|
||||
|
||||
const sr = (eventId: string, ts: number, content: Record<string, unknown>) =>
|
||||
({
|
||||
rank: 1,
|
||||
result: {
|
||||
event_id: eventId,
|
||||
room_id: '!r:x',
|
||||
origin_server_ts: ts,
|
||||
type: 'm.room.message',
|
||||
content,
|
||||
},
|
||||
}) as unknown as Parameters<typeof foldEdits>[0][number];
|
||||
|
||||
test('foldEdits: one row per message, showing the latest edit (#288)', () => {
|
||||
const edit = (id: string, ts: number, body: string) =>
|
||||
sr(id, ts, {
|
||||
body: `* ${body}`,
|
||||
'm.new_content': { body },
|
||||
'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' },
|
||||
});
|
||||
const out = foldEdits([
|
||||
edit('$e2', 30, 'Tuesday 11am room B'),
|
||||
sr('$orig', 10, { body: 'Monday 10am room A' }),
|
||||
edit('$e1', 20, 'Tuesday 10am room A'),
|
||||
sr('$other', 5, { body: 'meeting notes' }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
out.map((r) => r.result.event_id),
|
||||
['$orig', '$other'],
|
||||
);
|
||||
assert.deepEqual(out[0].result.content['m.new_content'], { body: 'Tuesday 11am room B' });
|
||||
assert.equal(out[0].result.content.body, 'Monday 10am room A');
|
||||
});
|
||||
|
||||
test('foldEdits: an edit whose original did not match stands in for it', () => {
|
||||
const out = foldEdits([
|
||||
sr('$e1', 20, {
|
||||
'm.new_content': { body: 'x' },
|
||||
'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' },
|
||||
}),
|
||||
]);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].result.event_id, '$orig');
|
||||
});
|
||||
|
||||
@@ -96,10 +96,58 @@ export const filterGroupsByDateRange = (
|
||||
.filter((group) => group.items.length > 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #288] Server search also returns `m.replace` events, so an edited
|
||||
* message showed once per version. Fold each edit into its message: one row,
|
||||
* at the first position any version ranked, showing the latest text and
|
||||
* pointing at the original event (so "jump" lands on the real message).
|
||||
*/
|
||||
export const foldEdits = (results: ISearchResult[]): ISearchResult[] => {
|
||||
const targetOf = (r: ISearchResult): string => {
|
||||
const rel = r.result.content?.['m.relates_to'];
|
||||
return rel?.rel_type === 'm.replace' && typeof rel.event_id === 'string'
|
||||
? rel.event_id
|
||||
: r.result.event_id;
|
||||
};
|
||||
const isEdit = (r: ISearchResult) => targetOf(r) !== r.result.event_id;
|
||||
const order: string[] = [];
|
||||
const originals = new Map<string, ISearchResult>();
|
||||
const latestEdit = new Map<string, ISearchResult>();
|
||||
results.forEach((r) => {
|
||||
const target = targetOf(r);
|
||||
if (!originals.has(target) && !latestEdit.has(target)) order.push(target);
|
||||
if (!isEdit(r)) {
|
||||
originals.set(target, r);
|
||||
return;
|
||||
}
|
||||
const prev = latestEdit.get(target);
|
||||
if (!prev || r.result.origin_server_ts > prev.result.origin_server_ts)
|
||||
latestEdit.set(target, r);
|
||||
});
|
||||
return order.map((target) => {
|
||||
const original = originals.get(target);
|
||||
const edit = latestEdit.get(target);
|
||||
const newContent = edit?.result.content?.['m.new_content'];
|
||||
if (original && !newContent) return original;
|
||||
if (original) {
|
||||
return {
|
||||
...original,
|
||||
result: {
|
||||
...original.result,
|
||||
content: { ...original.result.content, 'm.new_content': newContent },
|
||||
},
|
||||
};
|
||||
}
|
||||
// Only an edit matched: show it as its message.
|
||||
const only = edit as ISearchResult;
|
||||
return { ...only, result: { ...only.result, event_id: target } };
|
||||
});
|
||||
};
|
||||
|
||||
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||
const groups: ResultGroup[] = [];
|
||||
|
||||
results.forEach((item) => {
|
||||
foldEdits(results).forEach((item) => {
|
||||
const roomId = item.result.room_id;
|
||||
const resultItem: ResultItem = {
|
||||
rank: item.rank,
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
ClientEvent,
|
||||
ClientEventHandlerMap,
|
||||
ConnectionError,
|
||||
EventStatus,
|
||||
KnownMembership,
|
||||
MatrixClient,
|
||||
MatrixError,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomEventHandlerMap,
|
||||
SyncState,
|
||||
} from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { sendOfflineAtom } from '../../state/sendOffline';
|
||||
import {
|
||||
OUTBOX_MAX_AUTO_RETRIES,
|
||||
OutboxEntry,
|
||||
isRetryableSendError,
|
||||
loadOutbox,
|
||||
outboxRetryDelayMs,
|
||||
planRestore,
|
||||
saveOutbox,
|
||||
shouldKeepInOutbox,
|
||||
withEntry,
|
||||
withoutEntry,
|
||||
} from '../../utils/outbox';
|
||||
|
||||
const PENDING: ReadonlySet<EventStatus | null> = new Set([
|
||||
EventStatus.SENDING,
|
||||
EventStatus.ENCRYPTING,
|
||||
EventStatus.QUEUED,
|
||||
EventStatus.NOT_SENT,
|
||||
]);
|
||||
const ONLINE: ReadonlySet<SyncState | null> = new Set([
|
||||
SyncState.Prepared,
|
||||
SyncState.Syncing,
|
||||
SyncState.Catchup,
|
||||
]);
|
||||
const OFFLINE: ReadonlySet<SyncState | null> = new Set([SyncState.Reconnecting, SyncState.Error]);
|
||||
|
||||
/** The server already has it: its transaction id came back down /sync. */
|
||||
const isDelivered = (room: Room, txnId: string): boolean => {
|
||||
const hasTxn = (events: MatrixEvent[]) =>
|
||||
events.some((e) => e.getUnsigned().transaction_id === txnId);
|
||||
return (
|
||||
hasTxn(room.getLiveTimeline().getEvents()) ||
|
||||
room.getThreads().some((t) => hasTxn(t.liveTimeline.getEvents()))
|
||||
);
|
||||
};
|
||||
|
||||
type OutboxSession = {
|
||||
/** Own pending events of this session, by txnId. */
|
||||
live: Map<string, { event: MatrixEvent; room: Room }>;
|
||||
autoRetries: Map<string, number>;
|
||||
restored: boolean;
|
||||
retrying: boolean;
|
||||
};
|
||||
|
||||
// Per client, not per mount: a remount must neither restore twice nor forget
|
||||
// the events it is tracking.
|
||||
const sessions = new WeakMap<MatrixClient, OutboxSession>();
|
||||
const getSession = (mx: MatrixClient): OutboxSession => {
|
||||
let session = sessions.get(mx);
|
||||
if (!session) {
|
||||
session = { live: new Map(), autoRetries: new Map(), restored: false, retrying: false };
|
||||
sessions.set(mx, session);
|
||||
}
|
||||
return session;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #112] Offline outbox (see utils/outbox.ts): mirrors own message sends
|
||||
* into localStorage until the server confirms them, puts unsent ones back
|
||||
* after a reload, and re-sends network failures when the connection returns.
|
||||
*/
|
||||
export function OutboxFeature() {
|
||||
const mx = useMatrixClient();
|
||||
const setOffline = useSetAtom(sendOfflineAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const userId = mx.getSafeUserId();
|
||||
let entries = loadOutbox(userId);
|
||||
const session = getSession(mx);
|
||||
const { live, autoRetries } = session;
|
||||
let disposed = false;
|
||||
let offlineNow = false;
|
||||
const timers = new Set<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const isOffline = () =>
|
||||
OFFLINE.has(mx.getSyncState()) ||
|
||||
(typeof navigator !== 'undefined' && navigator.onLine === false);
|
||||
|
||||
const persist = (next: OutboxEntry[]) => {
|
||||
if (next === entries) return;
|
||||
entries = next;
|
||||
saveOutbox(userId, entries);
|
||||
};
|
||||
|
||||
/**
|
||||
* A network failure while we think we're online (a blip neither sync nor
|
||||
* the browser noticed): try again shortly, backing off. Real outages are
|
||||
* handled by the reconnect / back-online triggers below.
|
||||
*/
|
||||
const scheduleBlipRetry = (event: MatrixEvent) => {
|
||||
const attempts = autoRetries.get(event.getTxnId() ?? '') ?? 0;
|
||||
if (!isRetryableSendError(event.error) || attempts >= OUTBOX_MAX_AUTO_RETRIES || isOffline())
|
||||
return;
|
||||
const timer = setTimeout(() => {
|
||||
timers.delete(timer);
|
||||
retryQueued();
|
||||
}, outboxRetryDelayMs(attempts));
|
||||
timers.add(timer);
|
||||
};
|
||||
|
||||
const onLocalEcho: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (event, room) => {
|
||||
const txnId = event.getTxnId();
|
||||
if (!txnId || event.getSender() !== userId) return;
|
||||
if (PENDING.has(event.status)) {
|
||||
if (!shouldKeepInOutbox(event.getType(), event.getContent())) return;
|
||||
live.set(txnId, { event, room });
|
||||
if (event.status === EventStatus.NOT_SENT) scheduleBlipRetry(event);
|
||||
persist(
|
||||
withEntry(entries, {
|
||||
txnId,
|
||||
roomId: room.roomId,
|
||||
threadId: event.threadRootId ?? null,
|
||||
type: event.getType(),
|
||||
content: event.getContent(),
|
||||
ts: event.getTs(),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// SENT, CANCELLED, or the remote echo replaced it (status null).
|
||||
live.delete(txnId);
|
||||
autoRetries.delete(txnId);
|
||||
persist(withoutEntry(entries, txnId));
|
||||
};
|
||||
|
||||
/** Re-send network failures, oldest first; a room stops at its first failure. */
|
||||
const retryQueued = async () => {
|
||||
if (session.retrying || disposed) return;
|
||||
session.retrying = true;
|
||||
try {
|
||||
const byRoom = new Map<Room, MatrixEvent[]>();
|
||||
Array.from(live.values())
|
||||
.filter(
|
||||
({ event }) =>
|
||||
event.status === EventStatus.NOT_SENT &&
|
||||
isRetryableSendError(event.error) &&
|
||||
(autoRetries.get(event.getTxnId() ?? '') ?? 0) < OUTBOX_MAX_AUTO_RETRIES,
|
||||
)
|
||||
.sort((a, b) => a.event.getTs() - b.event.getTs())
|
||||
.forEach(({ event, room }) => {
|
||||
byRoom.set(room, [...(byRoom.get(room) ?? []), event]);
|
||||
});
|
||||
await Promise.all(
|
||||
Array.from(byRoom.entries()).map(async ([room, events]) => {
|
||||
for (const event of events) {
|
||||
if (disposed || event.status !== EventStatus.NOT_SENT) continue;
|
||||
const txnId = event.getTxnId() ?? '';
|
||||
autoRetries.set(txnId, (autoRetries.get(txnId) ?? 0) + 1);
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await mx.resendEvent(event, room);
|
||||
} catch (e) {
|
||||
// Still offline: keep the rest of this room in order for the next try.
|
||||
if (isRetryableSendError(e)) break;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
session.retrying = false;
|
||||
}
|
||||
};
|
||||
|
||||
const restore = () => {
|
||||
if (session.restored) return;
|
||||
session.restored = true;
|
||||
const plan = planRestore(
|
||||
entries,
|
||||
Date.now(),
|
||||
(roomId) => mx.getRoom(roomId)?.getMyMembership() === KnownMembership.Join,
|
||||
(entry) => {
|
||||
const room = mx.getRoom(entry.roomId);
|
||||
return !!room && isDelivered(room, entry.txnId);
|
||||
},
|
||||
);
|
||||
plan.drop.forEach((entry) => persist(withoutEntry(entries, entry.txnId)));
|
||||
plan.restore.forEach((entry) => {
|
||||
const room = mx.getRoom(entry.roomId);
|
||||
if (!room || live.has(entry.txnId)) return;
|
||||
// Same shape as the SDK's own local echo (client.sendCompleteEvent).
|
||||
const event = new MatrixEvent({
|
||||
type: entry.type,
|
||||
content: entry.content,
|
||||
event_id: `~${entry.roomId}:${entry.txnId}`,
|
||||
sender: userId,
|
||||
room_id: entry.roomId,
|
||||
origin_server_ts: entry.ts,
|
||||
});
|
||||
const thread = entry.threadId ? room.getThread(entry.threadId) : undefined;
|
||||
if (thread) event.setThread(thread);
|
||||
event.setTxnId(entry.txnId);
|
||||
event.setStatus(EventStatus.NOT_SENT);
|
||||
if (plan.autoSend.has(entry.txnId)) {
|
||||
// Recent: treat like a send that lost the network (shown as Queued
|
||||
// while offline, re-sent below and on reconnect).
|
||||
// (The SDK types `error` as MatrixError but stores any send error there.)
|
||||
event.error = new ConnectionError(
|
||||
'not sent before the app was closed',
|
||||
) as unknown as MatrixError;
|
||||
} else {
|
||||
// Old: back as "Failed to send"; the user decides (Retry / Cancel).
|
||||
autoRetries.set(entry.txnId, OUTBOX_MAX_AUTO_RETRIES);
|
||||
}
|
||||
try {
|
||||
room.addPendingEvent(event, entry.txnId); // emits LocalEchoUpdated → `live`
|
||||
} catch {
|
||||
// Already pending under this txnId: nothing to restore.
|
||||
}
|
||||
});
|
||||
if (!isOffline()) retryQueued();
|
||||
};
|
||||
|
||||
const updateOffline = () => {
|
||||
const offline = isOffline();
|
||||
// Back online (sync recovered or the browser says so): send what's queued.
|
||||
if (offlineNow && !offline && session.restored) retryQueued();
|
||||
offlineNow = offline;
|
||||
setOffline(offline);
|
||||
};
|
||||
|
||||
const onSync: ClientEventHandlerMap[ClientEvent.Sync] = (state, prevState) => {
|
||||
updateOffline();
|
||||
if (!ONLINE.has(state)) return;
|
||||
if (!session.restored) {
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
if (OFFLINE.has(prevState) || prevState === SyncState.Catchup) retryQueued();
|
||||
};
|
||||
const onBrowserOnline = () => updateOffline();
|
||||
|
||||
mx.on(RoomEvent.LocalEchoUpdated, onLocalEcho);
|
||||
mx.on(ClientEvent.Sync, onSync);
|
||||
window.addEventListener('online', onBrowserOnline);
|
||||
window.addEventListener('offline', onBrowserOnline);
|
||||
updateOffline();
|
||||
if (ONLINE.has(mx.getSyncState())) restore();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
timers.forEach((t) => clearTimeout(t));
|
||||
mx.off(RoomEvent.LocalEchoUpdated, onLocalEcho);
|
||||
mx.off(ClientEvent.Sync, onSync);
|
||||
window.removeEventListener('online', onBrowserOnline);
|
||||
window.removeEventListener('offline', onBrowserOnline);
|
||||
};
|
||||
}, [mx, setOffline]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -143,7 +143,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
|
||||
Create Poll
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -118,7 +118,7 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
|
||||
Report Room
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -124,7 +124,7 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
|
||||
Report User
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -153,10 +153,14 @@ import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationS
|
||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
||||
import { DraftIndicator } from './DraftIndicator';
|
||||
import { PendingLocation, ShareLocationDialog } from './ShareLocationDialog';
|
||||
import { buildGeoUri } from '../../utils/geoShare';
|
||||
import { useLocalRoomName } from '../../hooks/useRoomMeta';
|
||||
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
||||
import { getThreadDraftKey } from '../../state/room/thread';
|
||||
import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../utils/urlTracking';
|
||||
import { plaintextCachesWiped } from '../../state/plaintextCaches';
|
||||
|
||||
const GifPicker = React.lazy(() =>
|
||||
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
||||
@@ -356,6 +360,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
||||
[composerToolbarButtons?.order],
|
||||
);
|
||||
const displayRoomName = useLocalRoomName(room);
|
||||
const [locating, setLocating] = React.useState(false);
|
||||
const [locationError, setLocationError] = React.useState<string | null>(null);
|
||||
const handleShareLocation = useCallback(() => {
|
||||
@@ -368,21 +373,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setLocating(false);
|
||||
const { latitude, longitude } = pos.coords;
|
||||
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
|
||||
const ts = Date.now();
|
||||
// MSC3488 extensible location: send the geo_uri (legacy) alongside the
|
||||
// m.location/m.asset/m.ts blocks so Element and other clients render a
|
||||
// proper "shared location" pin instead of falling back to plain text.
|
||||
sendRoomMessage(mx, roomId, threadRootId ?? null, {
|
||||
msgtype: 'm.location',
|
||||
body: `Shared a location: ${geoUri}`,
|
||||
geo_uri: geoUri,
|
||||
'org.matrix.msc3488.location': { uri: geoUri },
|
||||
'org.matrix.msc3488.asset': { type: 'm.self' },
|
||||
'org.matrix.msc3488.ts': ts,
|
||||
'm.ts': ts,
|
||||
});
|
||||
// [Gitea #340] Don't send straight away: confirm first (ShareLocationDialog).
|
||||
const { latitude, longitude, accuracy } = pos.coords;
|
||||
setPendingLocation({ latitude, longitude, accuracy });
|
||||
},
|
||||
(err) => {
|
||||
setLocating(false);
|
||||
@@ -397,7 +390,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}, [mx, roomId, threadRootId]);
|
||||
}, []);
|
||||
const [pendingLocation, setPendingLocation] = React.useState<PendingLocation | null>(null);
|
||||
const handleSendLocation = useCallback(
|
||||
(approximate: boolean) => {
|
||||
if (!pendingLocation) return;
|
||||
const { latitude, longitude, accuracy } = pendingLocation;
|
||||
setPendingLocation(null);
|
||||
const geoUri = buildGeoUri(latitude, longitude, accuracy, approximate);
|
||||
const ts = Date.now();
|
||||
// MSC3488 extensible location: send the geo_uri (legacy) alongside the
|
||||
// m.location/m.asset/m.ts blocks so Element and other clients render a
|
||||
// proper "shared location" pin instead of falling back to plain text.
|
||||
sendRoomMessage(mx, roomId, threadRootId ?? null, {
|
||||
msgtype: 'm.location',
|
||||
body: `Shared ${approximate ? 'an approximate' : 'a'} location: ${geoUri}`,
|
||||
geo_uri: geoUri,
|
||||
'org.matrix.msc3488.location': { uri: geoUri },
|
||||
'org.matrix.msc3488.asset': { type: 'm.self' },
|
||||
'org.matrix.msc3488.ts': ts,
|
||||
'm.ts': ts,
|
||||
});
|
||||
},
|
||||
[pendingLocation, mx, roomId, threadRootId],
|
||||
);
|
||||
|
||||
const handleVoiceSend = useCallback(
|
||||
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
|
||||
@@ -547,6 +563,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
// Persist the current editor state to the draft atom + localStorage; an empty
|
||||
// editor clears both so the draft indicators never claim a draft that isn't there.
|
||||
const persistDraft = useCallback(() => {
|
||||
// [Gitea #319] Logout already wiped the drafts; don't write this one back.
|
||||
if (plaintextCachesWiped()) return;
|
||||
if (!isEmptyEditor(editor)) {
|
||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||
setMsgDraft(parsedDraft);
|
||||
@@ -1538,11 +1556,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
style={touchTarget}
|
||||
className={MobileTouchTarget}
|
||||
>
|
||||
{locating ? (
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
) : (
|
||||
<Icon src={Icons.SpaceGlobe} />
|
||||
)}
|
||||
{locating ? <Spinner variant="Secondary" size="400" /> : <Icon src={Icons.Pin} />}
|
||||
</IconButton>
|
||||
) : null;
|
||||
|
||||
@@ -1751,6 +1765,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
onClose={() => setPollOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{pendingLocation && (
|
||||
<ShareLocationDialog
|
||||
location={pendingLocation}
|
||||
roomName={displayRoomName}
|
||||
onSend={handleSendLocation}
|
||||
requestClose={() => setPendingLocation(null)}
|
||||
/>
|
||||
)}
|
||||
{scheduleOpen && (
|
||||
<ScheduleMessageModal
|
||||
roomId={roomId}
|
||||
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
isMembershipChanged,
|
||||
reactionOrEditEvent,
|
||||
sendRoomEvent,
|
||||
isFromFilteredTimelineSet,
|
||||
} from '../../utils/room';
|
||||
import { getLastEditDiff } from '../../utils/editDiff';
|
||||
import { tick } from '../../utils/haptics';
|
||||
@@ -121,7 +122,7 @@ import { ThreadSummary } from './thread/ThreadSummary';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { RetentionContent, isExpired } from '../../utils/retention';
|
||||
import { RetentionContent, effectiveRetentionMs, isExpired } from '../../utils/retention';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
@@ -138,6 +139,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||
import { declinedRing, isRingableRoom } from '../../utils/callRing';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
@@ -409,6 +411,9 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
|
||||
data,
|
||||
) => {
|
||||
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
||||
// [Gitea #259] The media gallery / activity log register filtered timeline
|
||||
// sets whose events the room re-emits as live: don't count them twice.
|
||||
if (isFromFilteredTimelineSet(data)) return;
|
||||
onArrive(mEvent);
|
||||
};
|
||||
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
|
||||
@@ -493,7 +498,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
// timeline (unless "show hidden events" is on). Reactive so a policy change
|
||||
// re-renders. `undefined` = no policy.
|
||||
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
|
||||
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime;
|
||||
// [Gitea #298] Same floor as everywhere else: a tiny value must not blank the room.
|
||||
const retentionMs = effectiveRetentionMs(
|
||||
retentionEvent?.getContent<RetentionContent>().max_lifetime,
|
||||
);
|
||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
@@ -2030,6 +2038,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
[EventType.RTCNotification]: (mEventId, mEvent, item) => {
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
if (!senderId || senderId === mx.getUserId()) return null;
|
||||
// Same rule as ringing: no "Missed call" in voice rooms or space
|
||||
// channels, where starting a call is just joining the channel.
|
||||
if (!isRingableRoom(room, direct)) return null;
|
||||
// [Gitea #313] A call we declined isn't a missed call.
|
||||
const ringRef = mEvent.getRelation()?.event_id;
|
||||
if (declinedRing(room, [mEventId, ringRef], mx.getSafeUserId())) return null;
|
||||
const content = mEvent.getContent();
|
||||
const mention =
|
||||
content['m.mentions']?.room ||
|
||||
|
||||
@@ -80,6 +80,7 @@ import { widgetsPanelAtom } from '../../state/widgetsPanel';
|
||||
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
|
||||
import { threadsListAtom } from '../../state/threadsList';
|
||||
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
|
||||
import { openCallInBrowser, useCallInBrowserUrl } from '../call/CallsUnavailable';
|
||||
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
|
||||
|
||||
type RoomMenuProps = {
|
||||
@@ -465,6 +466,36 @@ function CallRulesChip({ room }: { room: Room }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* [cinny-desktop: Linux calls] Stand-in for CallButton where this app can't
|
||||
* make calls (desktop on WebKitGTK): opens the room in the web app instead.
|
||||
*/
|
||||
function CallInBrowserButton({ url }: { url: string }) {
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Call in your browser (this desktop app can’t make calls here)</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
aria-label="Start call in browser"
|
||||
onClick={() => openCallInBrowser(url)}
|
||||
>
|
||||
<Icon size="400" src={Icons.VideoCamera} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function CallButton() {
|
||||
const room = useRoom();
|
||||
const direct = useIsDirectRoom();
|
||||
@@ -555,6 +586,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
);
|
||||
const livekitSupported = useLivekitSupport();
|
||||
const rtcSupported = webRTCSupported();
|
||||
const callInBrowserUrl = useCallInBrowserUrl(room.roomId);
|
||||
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||
@@ -782,11 +814,16 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
{screenSize === ScreenSize.Desktop && <CallRulesChip room={room} />}
|
||||
{!room.isCallRoom() &&
|
||||
livekitSupported &&
|
||||
rtcSupported &&
|
||||
(rtcSupported || callInBrowserUrl) &&
|
||||
hasCallPermission &&
|
||||
(direct ||
|
||||
(room.getJoinRule() === 'invite' &&
|
||||
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && <CallButton />}
|
||||
getStateEvents(room, StateEvent.SpaceParent).length === 0)) &&
|
||||
(rtcSupported ? (
|
||||
<CallButton />
|
||||
) : (
|
||||
callInBrowserUrl && <CallInBrowserButton url={callInBrowserUrl} />
|
||||
))}
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
|
||||
@@ -202,7 +202,7 @@ export function ScheduleMessageModal({
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -4,7 +4,12 @@ import { IContent } from 'matrix-js-sdk';
|
||||
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
|
||||
import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages';
|
||||
import {
|
||||
cancelScheduledMessage,
|
||||
fetchScheduledMessages,
|
||||
groupScheduledByRoom,
|
||||
sendScheduledMessageNow,
|
||||
} from '../../utils/scheduledMessages';
|
||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatFriendlyDateTime } from '../../utils/datetimeInput';
|
||||
@@ -26,6 +31,26 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
|
||||
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
|
||||
|
||||
// [Gitea #322] The server is the source of truth: messages scheduled on
|
||||
// another device (or before a logout) show up here and can be cancelled.
|
||||
// localStorage is only a cache for the first paint; on error we keep it.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => {
|
||||
fetchScheduledMessages(mx)
|
||||
.then((list) => {
|
||||
if (alive) setScheduledMessages(groupScheduledByRoom(list));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
load();
|
||||
window.addEventListener('focus', load);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.removeEventListener('focus', load);
|
||||
};
|
||||
}, [mx, roomId, setScheduledMessages]);
|
||||
|
||||
// Remove scheduled messages whose time has passed
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return undefined;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
config,
|
||||
Dialog,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { formatAccuracy } from '../../utils/geoShare';
|
||||
|
||||
export type PendingLocation = { latitude: number; longitude: number; accuracy?: number };
|
||||
|
||||
type ShareLocationDialogProps = {
|
||||
location: PendingLocation;
|
||||
roomName: string;
|
||||
onSend: (approximate: boolean) => void;
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #340] Confirm step before an m.location is posted: the toolbar button
|
||||
* used to send the exact position from a single click. Shows what will be
|
||||
* shared (coordinates + the fix's accuracy) and offers an approximate share.
|
||||
*/
|
||||
export function ShareLocationDialog({
|
||||
location,
|
||||
roomName,
|
||||
onSend,
|
||||
requestClose,
|
||||
}: ShareLocationDialogProps) {
|
||||
const modalStyle = useModalStyle(420);
|
||||
const { latitude, longitude, accuracy } = location;
|
||||
const accuracyLabel = formatAccuracy(accuracy);
|
||||
const mapUrl = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`;
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: '#share-location-cancel',
|
||||
onDeactivate: requestClose,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="share-location-title"
|
||||
variant="Surface"
|
||||
style={modalStyle}
|
||||
>
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text id="share-location-title" as="h2" size="H4">
|
||||
Share your location?
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={requestClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Text>
|
||||
Everyone in <b>{roomName}</b> will see this position, and it can't be taken
|
||||
back from their devices once sent.
|
||||
</Text>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T400">
|
||||
<b>
|
||||
{latitude.toFixed(5)}, {longitude.toFixed(5)}
|
||||
</b>
|
||||
{accuracyLabel && ` (${accuracyLabel})`}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{accuracyLabel
|
||||
? 'The ± is how sure your device is of this position.'
|
||||
: 'Your device did not say how accurate this position is.'}{' '}
|
||||
<a href={mapUrl} target="_blank" rel="noreferrer noopener">
|
||||
Check on OpenStreetMap
|
||||
</a>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box gap="200" justifyContent="End" wrap="Wrap">
|
||||
<Button
|
||||
id="share-location-cancel"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
onClick={requestClose}
|
||||
>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
<Button variant="Secondary" fill="Soft" onClick={() => onSend(true)}>
|
||||
<Text size="B400">Send approximate (~1 km)</Text>
|
||||
</Button>
|
||||
<Button variant="Primary" onClick={() => onSend(false)}>
|
||||
<Text size="B400">Send</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
addRecentForwardTarget,
|
||||
} from '../../../state/recentForwardTargets';
|
||||
import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent';
|
||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||
|
||||
type RoomRowProps = {
|
||||
room: Room;
|
||||
@@ -58,6 +59,7 @@ type RoomRowProps = {
|
||||
sending: boolean;
|
||||
};
|
||||
function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: RoomRowProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const avatarMxc = room.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxc
|
||||
@@ -97,7 +99,7 @@ function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: R
|
||||
>
|
||||
<Box direction="Column">
|
||||
<Text size="T300" truncate>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
{dm && (
|
||||
<Text size="T200" priority="300" truncate>
|
||||
@@ -212,6 +214,7 @@ function RecentChip({
|
||||
onToggle: () => void;
|
||||
sending: boolean;
|
||||
}) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const avatarMxc = room.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxc
|
||||
@@ -240,7 +243,7 @@ function RecentChip({
|
||||
}
|
||||
>
|
||||
<Text size="B300" truncate style={{ maxWidth: toRem(120) }}>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</Chip>
|
||||
);
|
||||
@@ -252,6 +255,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const modalStyle = useModalStyle(400);
|
||||
const directs = useAtomValue(mDirectAtom);
|
||||
@@ -296,8 +300,10 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
const filtered = useMemo(() => {
|
||||
if (!query) return allRooms;
|
||||
const q = query.toLowerCase();
|
||||
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||
}, [allRooms, query]);
|
||||
return allRooms.filter((r) =>
|
||||
[r.name, localNames[r.roomId]].some((nm) => nm?.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [allRooms, query, localNames]);
|
||||
|
||||
// Resolve recent target ids to still-joined rooms (drop ones left/gone).
|
||||
const recentRooms = useMemo(
|
||||
|
||||
@@ -26,19 +26,21 @@ import {
|
||||
config,
|
||||
} from 'folds';
|
||||
import React, {
|
||||
CSSProperties,
|
||||
FormEventHandler,
|
||||
MouseEventHandler,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useHover, useFocusWithin } from 'react-aria';
|
||||
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
|
||||
import { MatrixEvent, MatrixEventEvent, Room, EventStatus } from 'matrix-js-sdk';
|
||||
import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
||||
import classNames from 'classnames';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import {
|
||||
AvatarBase,
|
||||
@@ -83,8 +85,6 @@ import { copyToClipboard } from '../../../utils/dom';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
|
||||
import { getLotusRoomPermalink } from '../../../plugins/lotus-permalink';
|
||||
import { getOriginBaseUrl } from '../../../pages/pathUtils';
|
||||
import { useClientConfig } from '../../../hooks/useClientConfig';
|
||||
import { getViaServers } from '../../../plugins/via-servers';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
|
||||
@@ -98,28 +98,55 @@ import { useLongPress } from '../../../hooks/useLongPress';
|
||||
import { ActionSheet } from '../../../components/action-sheet';
|
||||
import { useBookmarks } from '../../../hooks/useBookmarks';
|
||||
import { PresenceRingAvatar } from '../../../components/presence';
|
||||
import { useLotusShareBase } from '../../../hooks/useLotusLinkBase';
|
||||
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
||||
import { sendOfflineAtom } from '../../../state/sendOffline';
|
||||
import { isRetryableSendError } from '../../../utils/outbox';
|
||||
|
||||
// Delivery status indicator for own messages
|
||||
function DeliveryStatus({
|
||||
status,
|
||||
mEvent,
|
||||
room,
|
||||
lotusTerminal,
|
||||
}: {
|
||||
status: string | null;
|
||||
mEvent: MatrixEvent;
|
||||
room: Room;
|
||||
lotusTerminal: boolean;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const offline = useAtomValue(sendOfflineAtom);
|
||||
// [Gitea #306] A local echo's status changes (SENDING → SENT → confirmed,
|
||||
// NOT_SENT → retried) don't re-render the timeline, so the last message in a
|
||||
// quiet room kept "Sending..." (or a stale "Failed to send") indefinitely.
|
||||
// Re-render on the event's own status updates.
|
||||
const [, setStatusTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const bump = () => setStatusTick((t) => t + 1);
|
||||
mEvent.on(MatrixEventEvent.Status, bump);
|
||||
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, bump);
|
||||
return () => {
|
||||
mEvent.off(MatrixEventEvent.Status, bump);
|
||||
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, bump);
|
||||
};
|
||||
}, [mEvent]);
|
||||
const { status } = mEvent;
|
||||
if (status === null) return null; // confirmed by server — read receipts take over
|
||||
let iconSrc: IconSrc;
|
||||
let label: string;
|
||||
let colorStyle: string;
|
||||
const isSending = status === EventStatus.SENDING || status === EventStatus.ENCRYPTING;
|
||||
if (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED) {
|
||||
// [Gitea #112] A network failure while offline is queued, not failed: the
|
||||
// outbox sends it again when the connection is back.
|
||||
const queued = status === EventStatus.NOT_SENT && offline && isRetryableSendError(mEvent.error);
|
||||
const failed = !queued && (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED);
|
||||
if (failed) {
|
||||
iconSrc = Icons.Cross;
|
||||
label = 'Failed to send';
|
||||
label = status === EventStatus.NOT_SENT ? 'Failed to send. Click to retry' : 'Failed to send';
|
||||
colorStyle = lotusTerminal ? 'var(--lt-accent-red)' : color.Critical.Main;
|
||||
} else if (status === EventStatus.QUEUED || isSending) {
|
||||
iconSrc = Icons.Send;
|
||||
label = isSending ? 'Sending...' : 'Queued';
|
||||
} else if (queued || status === EventStatus.QUEUED || isSending) {
|
||||
iconSrc = queued ? Icons.RecentClock : Icons.Send;
|
||||
if (queued) label = "Queued. Will send when you're back online";
|
||||
else label = isSending ? 'Sending...' : 'Queued';
|
||||
colorStyle = lotusTerminal
|
||||
? 'color-mix(in srgb, var(--lt-accent-cyan) 60%, transparent)'
|
||||
: color.Secondary.Main;
|
||||
@@ -130,27 +157,49 @@ function DeliveryStatus({
|
||||
? 'color-mix(in srgb, var(--lt-accent-cyan) 70%, transparent)'
|
||||
: color.Secondary.Main;
|
||||
}
|
||||
const retryable = failed && status === EventStatus.NOT_SENT;
|
||||
const handleRetry: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
evt.stopPropagation();
|
||||
if (mEvent.status === EventStatus.NOT_SENT) mx.resendEvent(mEvent, room).catch(() => undefined);
|
||||
};
|
||||
const style: CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
marginTop: '2px',
|
||||
lineHeight: 1,
|
||||
color: colorStyle,
|
||||
opacity: 0.85,
|
||||
userSelect: 'none',
|
||||
...(lotusTerminal && failed ? { textShadow: 'var(--lt-glow-red)' } : {}),
|
||||
};
|
||||
const icon = (
|
||||
<span className={isSending ? SendingSpinClass : undefined}>
|
||||
<Icon size="100" src={iconSrc} />
|
||||
</span>
|
||||
);
|
||||
if (retryable) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
style={{
|
||||
...style,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
as="span"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
marginTop: '2px',
|
||||
lineHeight: 1,
|
||||
color: colorStyle,
|
||||
opacity: 0.85,
|
||||
userSelect: 'none',
|
||||
...(lotusTerminal && status === EventStatus.NOT_SENT
|
||||
? { textShadow: 'var(--lt-glow-red)' }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<span className={isSending ? SendingSpinClass : undefined}>
|
||||
<Icon size="100" src={iconSrc} />
|
||||
</span>
|
||||
<Box as="span" aria-label={label} title={label} style={style}>
|
||||
{icon}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -460,18 +509,11 @@ export const MessageCopyLotusLinkItem = as<
|
||||
onClose?: () => void;
|
||||
}
|
||||
>(({ room, mEvent, onClose, ...props }, ref) => {
|
||||
const { hashRouter } = useClientConfig();
|
||||
const lotusBase = useLotusShareBase();
|
||||
const handleCopy = () => {
|
||||
const eventId = mEvent.getId();
|
||||
if (!eventId) return;
|
||||
copyToClipboard(
|
||||
getLotusRoomPermalink(
|
||||
getOriginBaseUrl(hashRouter),
|
||||
room.roomId,
|
||||
eventId,
|
||||
getViaServers(room),
|
||||
),
|
||||
);
|
||||
copyToClipboard(getLotusRoomPermalink(lotusBase, room.roomId, eventId, getViaServers(room)));
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
@@ -683,7 +725,13 @@ export const MessageDeleteItem = as<
|
||||
Delete Message
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
onClick={handleClose}
|
||||
radii="300"
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
@@ -823,7 +871,13 @@ export const MessageReportItem = as<
|
||||
Report Message
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
onClick={handleClose}
|
||||
radii="300"
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
@@ -1110,7 +1164,7 @@ export const Message = React.memo(
|
||||
/>
|
||||
)}
|
||||
{isMine && !mEvent.isState() && readReceiptUsers.length === 0 && (
|
||||
<DeliveryStatus status={mEvent.status} lotusTerminal={!!lotusTerminal} />
|
||||
<DeliveryStatus mEvent={mEvent} room={room} lotusTerminal={!!lotusTerminal} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -69,8 +69,15 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
const measure = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rowWidth = container.getBoundingClientRect().width;
|
||||
const gap = parseFloat(getComputedStyle(container).columnGap) || 8;
|
||||
// [Gitea #305] The width the chips actually get (content box). The border
|
||||
// box grows by 4 px while collapsed (margin -2 / padding 2), and measuring
|
||||
// it made a row within 4 px of fitting flip collapsed↔expanded every frame.
|
||||
const cs = getComputedStyle(container);
|
||||
const rowWidth =
|
||||
container.clientWidth -
|
||||
(parseFloat(cs.paddingLeft) || 0) -
|
||||
(parseFloat(cs.paddingRight) || 0);
|
||||
const gap = parseFloat(cs.columnGap) || 8;
|
||||
const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
|
||||
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
|
||||
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
|
||||
@@ -110,8 +117,21 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
style={{
|
||||
...props.style,
|
||||
...(collapsed && rowHeight
|
||||
? // 2px breathing room so row-1 focus outlines are not clipped.
|
||||
{ maxHeight: rowHeight + 4, overflow: 'hidden', padding: 2, margin: -2 }
|
||||
? // 2px breathing room so row-1 focus outlines are not clipped. Longhand
|
||||
// margins so the timeline's marginTop survives (Gitea #305: the
|
||||
// `margin` shorthand overrode it and, once removed, cleared it).
|
||||
{
|
||||
maxHeight: rowHeight + 4,
|
||||
overflow: 'hidden',
|
||||
padding: 2,
|
||||
marginLeft: -2,
|
||||
marginRight: -2,
|
||||
marginBottom: -2,
|
||||
marginTop:
|
||||
props.style?.marginTop !== undefined
|
||||
? `calc(${typeof props.style.marginTop === 'number' ? `${props.style.marginTop}px` : props.style.marginTop} - 2px)`
|
||||
: -2,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
ref={(el) => {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications
|
||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { getMemberName } from '../../../utils/room';
|
||||
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||
|
||||
type ThreadPanelHeaderProps = {
|
||||
room: Room;
|
||||
@@ -39,6 +40,7 @@ type ThreadPanelHeaderProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mode = useThreadNotificationMode(room.roomId, threadId);
|
||||
const { format } = useTimestampFormatter();
|
||||
// [Gitea #165] Who started the thread and when — the first thing you want
|
||||
@@ -55,7 +57,7 @@ function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderPr
|
||||
Thread
|
||||
</Text>
|
||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -33,6 +33,8 @@ import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, conf
|
||||
import classNames from 'classnames';
|
||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { isRetryableSendError } from '../../../utils/outbox';
|
||||
import { sendOfflineAtom } from '../../../state/sendOffline';
|
||||
import { eventWithShortcode, factoryEventSentBy } from '../../../utils/matrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
|
||||
@@ -253,6 +255,7 @@ export type ThreadTimelineProps = {
|
||||
|
||||
export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
const mx = useMatrixClient();
|
||||
const sendOffline = useAtomValue(sendOfflineAtom);
|
||||
const alive = useAlive();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
@@ -992,8 +995,12 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
const showEmptyReplies = ready && thread.length === 0;
|
||||
|
||||
const renderPendingEvent = (mEvent: MatrixEvent) => {
|
||||
// [Gitea #112] Network failures while offline are queued, not failed.
|
||||
const queued =
|
||||
mEvent.status === EventStatus.NOT_SENT && sendOffline && isRetryableSendError(mEvent.error);
|
||||
const failed =
|
||||
mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED;
|
||||
!queued &&
|
||||
(mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED);
|
||||
return (
|
||||
<div
|
||||
key={mEvent.getId() ?? mEvent.getTxnId()}
|
||||
@@ -1007,6 +1014,13 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{queued && (
|
||||
<Box style={{ padding: `0 ${config.space.S400}` }}>
|
||||
<Text size="T200" priority="300">
|
||||
Queued. Will send when you're back online
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { formatRelativeAge } from '../../../utils/formatTimestamp';
|
||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||
|
||||
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
|
||||
// localStorage synchronously so the chosen filter/sort apply on first render.
|
||||
@@ -235,6 +236,7 @@ export type ThreadsListPanelProps = {
|
||||
onOpenThread: (threadId: string) => void;
|
||||
};
|
||||
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const handleJump = useCallback(
|
||||
@@ -361,7 +363,7 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
|
||||
Threads
|
||||
</Text>
|
||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{totalUnread > 0 && (
|
||||
|
||||
@@ -14,6 +14,7 @@ import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
||||
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
|
||||
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
||||
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
||||
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
||||
|
||||
type PendingAsk = {
|
||||
requests: WidgetPermissionRequest[];
|
||||
@@ -111,6 +112,7 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||
};
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
useAsyncSearch,
|
||||
UseAsyncSearchOptions,
|
||||
} from '../../hooks/useAsyncSearch';
|
||||
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
||||
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import {
|
||||
@@ -157,17 +158,20 @@ export function Search({ requestClose }: SearchProps) {
|
||||
const topActiveRooms = useTopActiveRooms(searchRoomType, rooms, directs, spaces);
|
||||
const targetRooms = useSearchTargetRooms(searchRoomType, rooms, directs, spaces);
|
||||
|
||||
const localNames = useLocalRoomNames();
|
||||
const getTargetStr: SearchItemStrGetter<string> = useCallback(
|
||||
(roomId: string) => {
|
||||
const roomName = getRoom(roomId)?.name ?? roomId;
|
||||
// [Gitea #336] Match the user's own "Rename for me" name as well.
|
||||
const names = localNames[roomId] ? [localNames[roomId], roomName] : [roomName];
|
||||
if (mDirects.has(roomId)) {
|
||||
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
|
||||
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
|
||||
if (targetUsername) return [roomName, targetUsername];
|
||||
if (targetUsername) return [...names, targetUsername];
|
||||
}
|
||||
return roomName;
|
||||
return names.length === 1 ? names[0] : names;
|
||||
},
|
||||
[getRoom, mDirects, mx],
|
||||
[getRoom, mDirects, mx, localNames],
|
||||
);
|
||||
|
||||
const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS);
|
||||
@@ -388,8 +392,10 @@ export function Search({ requestClose }: SearchProps) {
|
||||
<Box grow="Yes" alignItems="Center" gap="100">
|
||||
<Text size="T400" truncate>
|
||||
{queryHighlighRegex
|
||||
? highlightText(queryHighlighRegex, [room.name])
|
||||
: room.name}
|
||||
? highlightText(queryHighlighRegex, [
|
||||
localNames[room.roomId] ?? room.name,
|
||||
])
|
||||
: (localNames[room.roomId] ?? room.name)}
|
||||
</Text>
|
||||
{dmUsername && (
|
||||
<Text as="span" size="T200" priority="300" truncate>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { SyncState } from 'matrix-js-sdk';
|
||||
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import { Icons } from 'folds';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
|
||||
import {
|
||||
useDeviceVerificationStatus,
|
||||
VerificationStatus,
|
||||
} from '../../hooks/useDeviceVerificationStatus';
|
||||
import { useKeyBackupInfo, useKeyBackupStatusChange } from '../../hooks/useKeyBackup';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { useSyncState } from '../../hooks/useSyncState';
|
||||
import { settingsRequestAtom } from '../../state/settingsRequest';
|
||||
import { serverStatusAtom } from '../../state/serverStatus';
|
||||
import { pickBanner } from '../../utils/kumaStatus';
|
||||
import {
|
||||
dismissNudge,
|
||||
NUDGE_COPY,
|
||||
NUDGE_KEY_PREFIX,
|
||||
nudgeFor,
|
||||
NudgeRecord,
|
||||
parseNudgeRecord,
|
||||
shouldShowNudge,
|
||||
} from '../../utils/securityNudge';
|
||||
import { StatusStrip } from '../server-status/ServerStatusBanner';
|
||||
|
||||
/** This device's backup connection: undefined until known, then true/false. */
|
||||
const useBackupActive = (crypto: CryptoApi): boolean | undefined => {
|
||||
const alive = useAlive();
|
||||
const [active, setActive] = useState<boolean>();
|
||||
useEffect(() => {
|
||||
crypto.getActiveSessionBackupVersion().then((v) => {
|
||||
if (alive()) setActive(typeof v === 'string');
|
||||
});
|
||||
}, [crypto, alive]);
|
||||
useKeyBackupStatusChange(useCallback((enabled: boolean) => setActive(enabled), []));
|
||||
return active;
|
||||
};
|
||||
|
||||
const readRecord = (key: string): NudgeRecord => {
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
raw = localStorage.getItem(key);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
const record = parseNudgeRecord(raw, Date.now());
|
||||
if (!raw) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(record));
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
return record;
|
||||
};
|
||||
|
||||
function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
const mx = useMatrixClient();
|
||||
const deviceId = mx.getDeviceId() ?? undefined;
|
||||
const crossSigning = useCrossSigningActive();
|
||||
const status = useDeviceVerificationStatus(crypto, mx.getSafeUserId(), deviceId);
|
||||
const backupInfo = useKeyBackupInfo(crypto);
|
||||
const backupActive = useBackupActive(crypto);
|
||||
const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom);
|
||||
const requestSettings = useSetAtom(settingsRequestAtom);
|
||||
// Not urgent: wait until sync has settled (two SYNCING in a row), so it
|
||||
// never stacks under the "Connecting…" strip at startup.
|
||||
const [settled, setSettled] = useState(false);
|
||||
useSyncState(
|
||||
mx,
|
||||
useCallback((state: SyncState | null, prev?: SyncState | null) => {
|
||||
setSettled(state === SyncState.Syncing && prev === SyncState.Syncing);
|
||||
}, []),
|
||||
);
|
||||
|
||||
const key = `${NUDGE_KEY_PREFIX}${deviceId ?? 'unknown'}`;
|
||||
const [record, setRecord] = useState(() => readRecord(key));
|
||||
|
||||
let deviceVerified: boolean | undefined;
|
||||
if (status === VerificationStatus.Verified) deviceVerified = true;
|
||||
else if (status === VerificationStatus.Unverified) deviceVerified = false;
|
||||
const kind = nudgeFor({
|
||||
crossSigning,
|
||||
deviceVerified,
|
||||
backupOnServer: backupInfo === undefined ? undefined : backupInfo !== null,
|
||||
backupActive,
|
||||
});
|
||||
|
||||
// One strip at a time: outages, maintenance and a lost connection win.
|
||||
const otherStrip = syncLost || !!pickBanner(serverStatus, { syncLost, where: 'client' });
|
||||
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
|
||||
return null;
|
||||
|
||||
const copy = NUDGE_COPY[kind];
|
||||
const dismiss = () => {
|
||||
const next = dismissNudge(kind, record, Date.now());
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(next));
|
||||
} catch {
|
||||
/* dismissed for this session only */
|
||||
}
|
||||
setRecord(next);
|
||||
};
|
||||
return (
|
||||
<StatusStrip
|
||||
banner={{ key: kind, tone: 'Primary', text: copy.text, dismissable: true }}
|
||||
icon={Icons.ShieldUser}
|
||||
action={{ label: copy.action, onClick: () => requestSettings('devices') }}
|
||||
dismissLabel="Not now"
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* [Gitea #110, #123] The device-security nudge: verify this device, connect
|
||||
* it to the key backup, or set up key backup. See utils/securityNudge.ts.
|
||||
*/
|
||||
function SecurityBannerInner() {
|
||||
const mx = useMatrixClient();
|
||||
const crypto = mx.getCrypto();
|
||||
if (!crypto) return null;
|
||||
return <SecurityBannerFor crypto={crypto} />;
|
||||
}
|
||||
|
||||
// A nudge must never take the app down: any error just hides it.
|
||||
export function SecurityBanner() {
|
||||
return (
|
||||
<ErrorBoundary fallback={null}>
|
||||
<SecurityBannerInner />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||
import { Box, Button, config, Icon, IconButton, Icons, IconSrc, Line, Text } from 'folds';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { useSyncState } from '../../hooks/useSyncState';
|
||||
import { useServerStatus } from '../../hooks/useServerStatus';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { serverStatusAtom } from '../../state/serverStatus';
|
||||
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
|
||||
import { homeserverStalledAtom, useHomeserverStall } from '../../hooks/useHomeserverStall';
|
||||
|
||||
const DISMISS_KEY = 'lotus-status-banner-dismissed';
|
||||
const DISMISS_MAX = 50;
|
||||
|
||||
const readDismissed = (): string[] => {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
|
||||
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const bannerIcon = (b: StripContent): IconSrc => {
|
||||
if (b.kind === 'maintenance') return Icons.Setting;
|
||||
if (b.tone === 'Primary') return Icons.Info;
|
||||
return Icons.Warning;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #124] One status strip, in the same slot and style as the sync and
|
||||
* clock banners. Presentational: the caller picks the banner.
|
||||
*/
|
||||
export type StripContent = Pick<
|
||||
StatusBanner,
|
||||
'key' | 'tone' | 'text' | 'detail' | 'dismissable'
|
||||
> & {
|
||||
kind?: StatusBanner['kind'];
|
||||
};
|
||||
|
||||
export function StatusStrip({
|
||||
banner,
|
||||
onDismiss,
|
||||
icon,
|
||||
action,
|
||||
dismissLabel,
|
||||
}: {
|
||||
banner: StripContent;
|
||||
onDismiss?: () => void;
|
||||
/** Overrides the icon picked from the banner's tone/kind. */
|
||||
icon?: IconSrc;
|
||||
/** A primary action button (e.g. "Verify"). */
|
||||
action?: { label: string; onClick: () => void };
|
||||
/** A text button instead of the ✕ for dismissing (e.g. "Not now"). */
|
||||
dismissLabel?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
useEffect(() => setOpen(false), [banner.key]);
|
||||
const critical = banner.tone === 'Critical';
|
||||
return (
|
||||
<Box direction="Column" shrink="No">
|
||||
<Box
|
||||
className={ContainerColor({ variant: banner.tone })}
|
||||
style={{ padding: `${config.space.S100} ${config.space.S300}` }}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
role={critical ? 'alert' : 'status'}
|
||||
aria-live={critical ? 'assertive' : 'polite'}
|
||||
>
|
||||
<Box
|
||||
alignItems="Center"
|
||||
justifyContent={mobile ? 'Start' : 'Center'}
|
||||
gap="200"
|
||||
style={{ minHeight: config.size.X300 }}
|
||||
>
|
||||
<Icon size="100" src={icon ?? bannerIcon(banner)} />
|
||||
<Text size="L400" truncate={mobile && !!banner.detail && !open} style={{ minWidth: 0 }}>
|
||||
{banner.text}
|
||||
</Text>
|
||||
{banner.detail && (
|
||||
<Button
|
||||
size="300"
|
||||
variant={banner.tone}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Text size="B300" style={{ textDecoration: 'underline' }}>
|
||||
{open ? 'Hide' : 'Details'}
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
{action && (
|
||||
<Button
|
||||
size="300"
|
||||
variant={banner.tone}
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={action.onClick}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Text size="B300">{action.label}</Text>
|
||||
</Button>
|
||||
)}
|
||||
{banner.dismissable && onDismiss && dismissLabel && (
|
||||
<Button
|
||||
size="300"
|
||||
variant={banner.tone}
|
||||
fill="None"
|
||||
radii="300"
|
||||
onClick={onDismiss}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Text size="B300">{dismissLabel}</Text>
|
||||
</Button>
|
||||
)}
|
||||
{banner.dismissable && onDismiss && !dismissLabel && (
|
||||
<IconButton
|
||||
size="300"
|
||||
variant={banner.tone}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-label="Dismiss"
|
||||
onClick={onDismiss}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
{banner.detail && open && (
|
||||
<Box justifyContent={mobile ? 'Start' : 'Center'}>
|
||||
<Text size="T200" style={{ maxWidth: 680, whiteSpace: 'pre-line' }}>
|
||||
{banner.detail}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Line variant={banner.tone} size="300" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Runs the Kuma poller for the logged-in homeserver and publishes it. */
|
||||
export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
|
||||
const { statusPages } = useClientConfig();
|
||||
const page = useMemo(
|
||||
() => resolveStatusPage(statusPages, mx.getDomain() ?? undefined),
|
||||
[statusPages, mx],
|
||||
);
|
||||
const [lostAt, setLostAt] = useState<number | null>(null);
|
||||
useSyncState(
|
||||
mx,
|
||||
useCallback((state: SyncState | null) => {
|
||||
const lost = state === SyncState.Reconnecting || state === SyncState.Error;
|
||||
setLostAt((prev) => {
|
||||
if (!lost) return null;
|
||||
return prev ?? Date.now();
|
||||
});
|
||||
}, []),
|
||||
);
|
||||
// [Gitea #317] A hung server keeps sync "SYNCING" for up to 110 s; the stall
|
||||
// watchdog flags it sooner, and Kuma is asked at once.
|
||||
const stalled = useHomeserverStall(mx);
|
||||
const setStalled = useSetAtom(homeserverStalledAtom);
|
||||
useEffect(() => setStalled(stalled), [setStalled, stalled]);
|
||||
const syncLost = lostAt !== null || stalled;
|
||||
const status = useServerStatus(page, syncLost);
|
||||
const setAtom = useSetAtom(serverStatusAtom);
|
||||
useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The logged-in app's status strip. */
|
||||
export function ServerStatusBanner() {
|
||||
const { status, syncLost } = useAtomValue(serverStatusAtom);
|
||||
const [dismissed, setDismissed] = useState(readDismissed);
|
||||
const banner = pickBanner(status, {
|
||||
syncLost,
|
||||
where: 'client',
|
||||
dismissed: new Set(dismissed),
|
||||
});
|
||||
if (!banner) return null;
|
||||
const dismiss = () => {
|
||||
const next = [...dismissed.filter((k) => k !== banner.key), banner.key].slice(-DISMISS_MAX);
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// storage unavailable — dismissed for this session only
|
||||
}
|
||||
setDismissed(next);
|
||||
};
|
||||
return <StatusStrip banner={banner} onDismiss={dismiss} />;
|
||||
}
|
||||
|
||||
/** The sign-in screen's strip, for the homeserver being signed in to. */
|
||||
export function LoginStatusBanner({ serverName }: { serverName: string }) {
|
||||
const { statusPages } = useClientConfig();
|
||||
const page = useMemo(() => resolveStatusPage(statusPages, serverName), [statusPages, serverName]);
|
||||
const status = useServerStatus(page);
|
||||
const banner = pickBanner(status, { syncLost: false, where: 'login' });
|
||||
return banner ? <StatusStrip banner={banner} /> : null;
|
||||
}
|
||||
|
||||
/** Calls-down note for the call Join controls (null when calls are fine). */
|
||||
export function useCallsDown(): boolean {
|
||||
const { status } = useAtomValue(serverStatusAtom);
|
||||
return status?.calls === 'down' && status.homeserver !== 'down';
|
||||
}
|
||||
@@ -57,7 +57,7 @@ import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||
import { useCapabilities } from '../../../hooks/useCapabilities';
|
||||
import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
|
||||
import { noteLocalStatusWrite } from '../../../utils/ownStatusSync';
|
||||
import { DND_STATUS_SENTINEL, noteLocalStatusWrite } from '../../../utils/ownStatusSync';
|
||||
import { ProfileDecoration } from './ProfileDecoration';
|
||||
import { EmojiBoard } from '../../../components/emoji-board';
|
||||
import { useStatusPresets } from '../../../hooks/useStatusPresets';
|
||||
@@ -396,7 +396,8 @@ function ProfileStatus() {
|
||||
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
|
||||
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
|
||||
|
||||
const initialStatus = presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '';
|
||||
const serverStatus = presence?.status === DND_STATUS_SENTINEL ? undefined : presence?.status;
|
||||
const initialStatus = serverStatus ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '';
|
||||
const [statusMsg, setStatusMsg] = useState<string>(initialStatus);
|
||||
// True while the user has unsaved local edits — prevents a server presence
|
||||
// echo from overwriting what the user is currently typing/inserting.
|
||||
@@ -442,6 +443,9 @@ function ProfileStatus() {
|
||||
pendingAppliedRef.current = null; // window elapsed — accept external changes
|
||||
}
|
||||
}
|
||||
// [Gitea #283] "dnd" is the Do Not Disturb sentinel, not something the user
|
||||
// typed: don't show it in the status field or keep it as their status.
|
||||
if (remoteStatus === DND_STATUS_SENTINEL) return;
|
||||
if (remoteStatus) {
|
||||
setStatusMsg(remoteStatus);
|
||||
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
|
||||
|
||||
@@ -112,8 +112,15 @@ import {
|
||||
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||
import { describeUpdateError, manualDownloadUrl } from '../../../utils/updateErrors';
|
||||
import {
|
||||
describeUpdateError,
|
||||
isPackageManagedError,
|
||||
manualDownloadUrl,
|
||||
packageUpdateHelp,
|
||||
} from '../../../utils/updateErrors';
|
||||
import { copyToClipboard } from '../../../utils/dom';
|
||||
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
||||
import { useKeychainMirrorStatus } from '../../../state/keychainMirror';
|
||||
import { isSafeGlobalToggleKey } from '../../../hooks/useCallHotkeys';
|
||||
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||
@@ -238,6 +245,27 @@ function AutostartSetting() {
|
||||
);
|
||||
}
|
||||
|
||||
// [Gitea #105] Desktop: whether the login is also kept in the OS keychain.
|
||||
function KeychainMirrorSetting() {
|
||||
const status = useKeychainMirrorStatus();
|
||||
if (!isTauriEnv() || status.state === 'idle' || status.state === 'cleared') return null;
|
||||
let description: string;
|
||||
if (status.state === 'ok') {
|
||||
description =
|
||||
"A copy of your login is kept in the system keychain (Windows Credential Manager). A later update will keep it only there, out of the app's data folder.";
|
||||
} else if (status.state === 'unsupported') {
|
||||
description =
|
||||
"Not available on this system yet. Your login is saved in the app's data folder, as before.";
|
||||
} else {
|
||||
description = `Couldn't save a copy to the system keychain (${status.error}). You stay logged in; your login is saved in the app's data folder, as before.`;
|
||||
}
|
||||
return (
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Login in the system keychain" description={description} />
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
|
||||
type ThemeSelectorProps = {
|
||||
themeNames: Record<string, string>;
|
||||
themes: Theme[];
|
||||
@@ -570,6 +598,7 @@ function Appearance() {
|
||||
|
||||
<DesktopChromeSetting />
|
||||
<AutostartSetting />
|
||||
<KeychainMirrorSetting />
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
@@ -2771,12 +2800,76 @@ function updateProgressText(progress: UpdateProgress | undefined): string {
|
||||
return `Downloading update… ${pct}% (${formatMb(downloaded)} of ${formatMb(total)})${attempt}`;
|
||||
}
|
||||
|
||||
function AppUpdates() {
|
||||
const { isTauri, status, check, install } = useTauriUpdater();
|
||||
if (!isTauri) return null;
|
||||
/**
|
||||
* A Linux package install is updated with its package manager (the in-app
|
||||
* updater can't write to /usr/bin): the command, Copy and a download link.
|
||||
*/
|
||||
function PackageUpdate({ help }: { help: NonNullable<ReturnType<typeof packageUpdateHelp>> }) {
|
||||
const [copied, setCopied] = useState<'no' | 'yes' | 'failed'>('no');
|
||||
const copy = () => {
|
||||
const text = help.command ?? '';
|
||||
if (!navigator.clipboard) {
|
||||
copyToClipboard(text);
|
||||
setCopied('yes');
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => setCopied('yes'),
|
||||
() => setCopied('failed'),
|
||||
);
|
||||
};
|
||||
let copyLabel = 'Copy command';
|
||||
if (copied === 'yes') copyLabel = 'Copied';
|
||||
else if (copied === 'failed') copyLabel = 'Copy failed: select it above';
|
||||
return (
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="T200">
|
||||
Lotus Chat was installed as a system package, so update it the same way
|
||||
{help.command ? ' (your chats and settings are kept):' : '.'}
|
||||
</Text>
|
||||
{help.command && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ fontFamily: 'monospace', wordBreak: 'break-all', userSelect: 'all' }}
|
||||
>
|
||||
{help.command}
|
||||
</Text>
|
||||
)}
|
||||
<Box gap="200" wrap="Wrap">
|
||||
{help.command && (
|
||||
<Button size="300" radii="300" variant="Secondary" onClick={copy}>
|
||||
<Text size="B300">{copyLabel}</Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
outlined
|
||||
onClick={() => window.open(help.url, '_blank')}
|
||||
>
|
||||
<Text size="B300">{help.download}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const description =
|
||||
status.state === 'checking'
|
||||
function AppUpdates() {
|
||||
const { isTauri, status, check, install, installKind } = useTauriUpdater();
|
||||
if (!isTauri) return null;
|
||||
const packageHelp = packageUpdateHelp(installKind);
|
||||
const packageUpdate =
|
||||
!!packageHelp &&
|
||||
(status.state === 'available' ||
|
||||
(status.state === 'error' && isPackageManagedError(status.message)));
|
||||
|
||||
const description = packageUpdate
|
||||
? status.state === 'available'
|
||||
? `Update available: v${status.version}`
|
||||
: 'An update is available.'
|
||||
: status.state === 'checking'
|
||||
? 'Checking for updates...'
|
||||
: status.state === 'up-to-date'
|
||||
? 'Lotus Chat is up to date.'
|
||||
@@ -2793,43 +2886,43 @@ function AppUpdates() {
|
||||
else check();
|
||||
};
|
||||
|
||||
const after =
|
||||
status.state === 'available' ? (
|
||||
<Button size="300" radii="300" onClick={() => install()}>
|
||||
<Text size="B300">Install & Restart</Text>
|
||||
const after = packageUpdate ? undefined : status.state === 'available' ? (
|
||||
<Button size="300" radii="300" onClick={() => install()}>
|
||||
<Text size="B300">Install & Restart</Text>
|
||||
</Button>
|
||||
) : status.state === 'checking' || status.state === 'installing' ? (
|
||||
<Spinner variant="Secondary" size="200" />
|
||||
) : status.state === 'error' ? (
|
||||
<Box gap="200" wrap="Wrap" justifyContent="End">
|
||||
<Button size="300" radii="300" variant="Secondary" onClick={retry}>
|
||||
<Text size="B300">Try again</Text>
|
||||
</Button>
|
||||
) : status.state === 'checking' || status.state === 'installing' ? (
|
||||
<Spinner variant="Secondary" size="200" />
|
||||
) : status.state === 'error' ? (
|
||||
<Box gap="200" wrap="Wrap" justifyContent="End">
|
||||
<Button size="300" radii="300" variant="Secondary" onClick={retry}>
|
||||
<Text size="B300">Try again</Text>
|
||||
{status.phase !== 'check' && (
|
||||
<Button
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
outlined
|
||||
onClick={() => window.open(manualDownloadUrl(navigator.userAgent), '_blank')}
|
||||
>
|
||||
<Text size="B300">Download installer</Text>
|
||||
</Button>
|
||||
{status.phase !== 'check' && (
|
||||
<Button
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
outlined
|
||||
onClick={() => window.open(manualDownloadUrl(navigator.userAgent), '_blank')}
|
||||
>
|
||||
<Text size="B300">Download installer</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Button size="300" radii="300" variant="Secondary" onClick={check}>
|
||||
<Text size="B300">Check</Text>
|
||||
</Button>
|
||||
);
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Button size="300" radii="300" variant="Secondary" onClick={check}>
|
||||
<Text size="B300">Check</Text>
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">App Updates</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Check for Updates" description={description} after={after} />
|
||||
{status.state === 'error' && (
|
||||
{packageUpdate && packageHelp && <PackageUpdate help={packageHelp} />}
|
||||
{status.state === 'error' && !packageUpdate && (
|
||||
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
|
||||
Details: {status.message}
|
||||
</Text>
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
CALL_SESSION_HEARTBEAT_MS,
|
||||
clearCallSession,
|
||||
decideRejoin,
|
||||
othersInCall,
|
||||
rejoinToastBody,
|
||||
readCallSession,
|
||||
writeCallSession,
|
||||
} from '../utils/callRejoin';
|
||||
@@ -112,6 +114,27 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
||||
};
|
||||
dismissToast(TOAST_ID);
|
||||
clearCallSession();
|
||||
// [Gitea #302] A DM call the other person has since left is over;
|
||||
// starting it again would ring them, so just say so.
|
||||
const dm = l.directs.has(room.roomId);
|
||||
if (
|
||||
dm &&
|
||||
!othersInCall(
|
||||
session.memberships.map((m) => m.userId ?? ''),
|
||||
mx.getUserId() ?? '',
|
||||
)
|
||||
) {
|
||||
// Own id: clicking a toast removes it by id after its onClick runs.
|
||||
setToast({
|
||||
id: `${TOAST_ID}-ended`,
|
||||
iconSrc: Icons.Phone,
|
||||
displayName: 'Call ended',
|
||||
body: `${room.name ?? 'They'} already left the call.`,
|
||||
roomName: room.name ?? '',
|
||||
roomId: room.roomId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
(l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref);
|
||||
} catch {
|
||||
@@ -133,6 +156,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
||||
.filter((m) => m.userId === mx.getUserId())
|
||||
.map((m) => m.deviceId),
|
||||
memberCount: memberships.length,
|
||||
dm: latest.current.directs.has(room.roomId),
|
||||
});
|
||||
// The RTC session fills in a moment after sync; keep waiting while it is
|
||||
// empty unless this is the final (timed-out) look.
|
||||
@@ -152,7 +176,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
||||
id: TOAST_ID,
|
||||
iconSrc: Icons.Phone,
|
||||
displayName: 'Rejoin voice?',
|
||||
body: `You were in the call in ${room.name ?? 'a room'} before the restart. Tap to rejoin.`,
|
||||
body: rejoinToastBody(room.name, latest.current.directs.has(room.roomId)),
|
||||
roomName: room.name ?? '',
|
||||
roomId: room.roomId,
|
||||
sticky: true,
|
||||
|
||||
@@ -25,6 +25,28 @@ export type ClientConfig = {
|
||||
* Unset: the bundled copy on this origin. Ignored in the desktop app.
|
||||
*/
|
||||
elementCallUrl?: string;
|
||||
|
||||
/**
|
||||
* [Gitea #43] Desktop only: the loopback origin the desktop app's local
|
||||
* server also answers on (e.g. "http://127.0.0.1:44548"), to load the
|
||||
* bundled call page from a different origin than the app
|
||||
* ("http://localhost:44548"). Set by cinny-desktop together with the server
|
||||
* and CSP changes it needs; unset keeps the same-origin call page.
|
||||
*/
|
||||
desktopCallOrigin?: string;
|
||||
|
||||
/**
|
||||
* [Gitea #124] Uptime Kuma status page per homeserver, for the status banner:
|
||||
* { "<server name>": { url, slug, groups?: { homeserver, calls, login } } }.
|
||||
* Homeservers not listed never contact Kuma.
|
||||
*/
|
||||
statusPages?: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
webAppUrl?: string;
|
||||
};
|
||||
|
||||
const ClientConfigContext = createContext<ClientConfig | null>(null);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { SyncState } from 'matrix-js-sdk';
|
||||
import { STALL_AFTER_MS, syncOverdue } from './useHomeserverStall';
|
||||
|
||||
test('syncOverdue: only while SYNCING and past the stall threshold (#317)', () => {
|
||||
const now = 1_000_000;
|
||||
assert.equal(syncOverdue(now - STALL_AFTER_MS - 1, now, SyncState.Syncing), true);
|
||||
assert.equal(syncOverdue(now - 20_000, now, SyncState.Syncing), false, 'a normal long-poll');
|
||||
assert.equal(
|
||||
syncOverdue(now - 120_000, now, SyncState.Reconnecting),
|
||||
false,
|
||||
'SyncStatus already shows that',
|
||||
);
|
||||
assert.equal(syncOverdue(now - 120_000, now, null), false);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { atom } from 'jotai';
|
||||
import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||
|
||||
/**
|
||||
* [Gitea #317] A hung homeserver (accepts connections, never answers) went
|
||||
* unnoticed for up to 110 s: the SDK only gives up on a /sync long-poll after
|
||||
* pollTimeout (30 s) + an 80 s buffer, and until then the sync state stays
|
||||
* SYNCING, so no strip showed and Kuma wasn't asked. This watchdog notices a
|
||||
* /sync that's overdue and confirms with a short probe of /versions.
|
||||
*/
|
||||
export const STALL_AFTER_MS = 45_000; // a healthy long-poll returns within ~30 s
|
||||
const CHECK_EVERY_MS = 10_000;
|
||||
const PROBE_TIMEOUT_MS = 5_000;
|
||||
|
||||
export const homeserverStalledAtom = atom(false);
|
||||
|
||||
/** Pure: should we probe, given the last sync response time? */
|
||||
export const syncOverdue = (lastSyncAt: number, now: number, state: SyncState | null): boolean =>
|
||||
state === SyncState.Syncing && now - lastSyncAt > STALL_AFTER_MS;
|
||||
|
||||
export function useHomeserverStall(mx: MatrixClient): boolean {
|
||||
const [stalled, setStalled] = useState(false);
|
||||
useEffect(() => {
|
||||
let lastSyncAt = Date.now();
|
||||
let probing = false;
|
||||
const onSync = () => {
|
||||
lastSyncAt = Date.now();
|
||||
setStalled(false);
|
||||
};
|
||||
const check = async () => {
|
||||
if (probing || !syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) return;
|
||||
probing = true;
|
||||
let answered = false;
|
||||
try {
|
||||
const res = await fetch(`${mx.getHomeserverUrl()}/_matrix/client/versions`, {
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
||||
});
|
||||
answered = res.ok;
|
||||
} catch {
|
||||
answered = false;
|
||||
}
|
||||
probing = false;
|
||||
// Still overdue and the server didn't answer a trivial request: stalled.
|
||||
if (!answered && syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) setStalled(true);
|
||||
if (answered) setStalled(false);
|
||||
};
|
||||
mx.on(ClientEvent.Sync, onSync);
|
||||
const id = setInterval(check, CHECK_EVERY_MS);
|
||||
return () => {
|
||||
mx.removeListener(ClientEvent.Sync, onSync);
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [mx]);
|
||||
return stalled;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { lotusLinkBases, lotusShareBase } from './useLotusLinkBase';
|
||||
|
||||
const LOCAL = 'http://localhost:44548/#/';
|
||||
const WEB = 'https://chat.lotusguild.org';
|
||||
|
||||
test('web: shared links use the page origin', () => {
|
||||
assert.equal(lotusShareBase(WEB, false, WEB), WEB);
|
||||
assert.deepEqual(lotusLinkBases(WEB, false, undefined), [WEB]);
|
||||
});
|
||||
|
||||
test('desktop with webAppUrl: shared links use the public web app', () => {
|
||||
assert.equal(lotusShareBase(LOCAL, true, `${WEB}/`), WEB);
|
||||
assert.deepEqual(lotusLinkBases(LOCAL, true, WEB), [LOCAL, WEB]);
|
||||
});
|
||||
|
||||
test('desktop without a valid webAppUrl falls back to the origin', () => {
|
||||
[undefined, '', 'http://chat.lotusguild.org', 'nonsense'].forEach((v) => {
|
||||
assert.equal(lotusShareBase(LOCAL, true, v), LOCAL, String(v));
|
||||
assert.deepEqual(lotusLinkBases(LOCAL, true, v), [LOCAL]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useClientConfig } from './useClientConfig';
|
||||
import { isTauri } from './useTauri';
|
||||
import { getOriginBaseUrl } from '../pages/pathUtils';
|
||||
import { resolveWebAppUrl } from '../utils/callInBrowser';
|
||||
|
||||
/**
|
||||
* [Gitea #248] Base URL for Lotus links meant for other people.
|
||||
*
|
||||
* On the web that's this page's origin. In the desktop app the page is served
|
||||
* from a local address (and hash-routed), which nobody else can open, so use
|
||||
* the public web app from config `webAppUrl` (web path routing) instead.
|
||||
* Without a valid `webAppUrl` it falls back to the origin, as before.
|
||||
*/
|
||||
export const lotusShareBase = (originBase: string, desktop: boolean, webAppUrl: unknown): string =>
|
||||
(desktop && resolveWebAppUrl(webAppUrl)) || originBase;
|
||||
|
||||
/**
|
||||
* Every base a Lotus link to this deployment may start with: the origin, plus
|
||||
* the public web app in the desktop app, so a shared https link opens in-app.
|
||||
*/
|
||||
export const lotusLinkBases = (
|
||||
originBase: string,
|
||||
desktop: boolean,
|
||||
webAppUrl: unknown,
|
||||
): string[] => {
|
||||
const shared = lotusShareBase(originBase, desktop, webAppUrl);
|
||||
return shared === originBase ? [originBase] : [originBase, shared];
|
||||
};
|
||||
|
||||
export const useLotusShareBase = (): string => {
|
||||
const { hashRouter, webAppUrl } = useClientConfig();
|
||||
return useMemo(
|
||||
() => lotusShareBase(getOriginBaseUrl(hashRouter), isTauri(), webAppUrl),
|
||||
[hashRouter, webAppUrl],
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { focusAssistActiveAtom } from '../state/focusAssist';
|
||||
import { manualDndAtom } from '../state/manualDnd';
|
||||
@@ -6,6 +7,8 @@ import { isWithinTimeWindow } from '../utils/timeWindow';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
|
||||
const QUIET_TICK_MS = 30_000;
|
||||
|
||||
export type NotificationsQuietInputs = {
|
||||
focusAssistActive: boolean;
|
||||
manualDnd: boolean;
|
||||
@@ -40,6 +43,15 @@ export function isNotificationsQuiet({
|
||||
// Live-atoms/settings-backed version of `isNotificationsQuiet` for use in
|
||||
// components.
|
||||
export function useNotificationsQuiet(): boolean {
|
||||
// [Gitea #325] The quiet-hours window and the snooze expiry depend on the
|
||||
// clock, but the long-lived notifiers using this hook rarely re-render, so a
|
||||
// window starting (or a 30-minute pause ending) while the app stayed open was
|
||||
// never noticed. Re-evaluate on a tick, like the night-light overlay.
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), QUIET_TICK_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||
const manualDnd = useAtomValue(manualDndAtom);
|
||||
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { SetPresence } from 'matrix-js-sdk';
|
||||
import { PresenceDeduper, PresenceWrite } from '../utils/presenceWrite';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
|
||||
@@ -90,11 +91,21 @@ export function usePresenceUpdater() {
|
||||
return status;
|
||||
};
|
||||
|
||||
// [Gitea #282] A one-off PUT /presence doesn't stick: the SDK long-polls
|
||||
// /sync with set_presence=online by default and Synapse treats every sync
|
||||
// as "online", so Invisible / Idle / DND flipped back within seconds. Tell
|
||||
// the sync loop which presence to report too (as Element does).
|
||||
const syncPresence = (presence: SetPresence) => {
|
||||
mx.setSyncPresence(presence).catch(() => undefined);
|
||||
};
|
||||
|
||||
const setOnline = async () => {
|
||||
syncPresence(SetPresence.Online);
|
||||
const status = await resolveStatus();
|
||||
return send({ presence: 'online', ...(status ? { status_msg: status } : {}) }, 'online');
|
||||
};
|
||||
const setUnavailable = async (statusMsg?: string) => {
|
||||
syncPresence(SetPresence.Unavailable);
|
||||
const status = await resolveStatus();
|
||||
return send(
|
||||
{
|
||||
@@ -104,7 +115,10 @@ export function usePresenceUpdater() {
|
||||
'unavailable',
|
||||
);
|
||||
};
|
||||
const setOffline = () => send({ presence: 'offline', status_msg: '' }, 'offline');
|
||||
const setOffline = () => {
|
||||
syncPresence(SetPresence.Offline);
|
||||
return send({ presence: 'offline', status_msg: '' }, 'offline');
|
||||
};
|
||||
|
||||
// Manual presence overrides — no activity tracking needed.
|
||||
if (hidePresence || presenceStatus === 'invisible') {
|
||||
@@ -167,13 +181,17 @@ export function usePresenceUpdater() {
|
||||
// it isn't usable here. fetch(..., { keepalive: true }) lets the request outlive the
|
||||
// page and is the correct mechanism for an authed endpoint. (keepalive bodies are
|
||||
// capped at 64KB, which this tiny payload is well under.)
|
||||
// [Gitea #339] Synapse treats a missing status_msg as "clear it", so a bare
|
||||
// { presence: 'offline' } wiped the user's custom status for everyone whenever
|
||||
// any tab closed or reloaded. Send the current status along with it.
|
||||
const status = readStatus();
|
||||
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ presence: 'offline' }),
|
||||
body: JSON.stringify({ presence: 'offline', ...(status ? { status_msg: status } : {}) }),
|
||||
keepalive: true,
|
||||
}).catch((err) => warnPresenceFailure('offline (pagehide)', err));
|
||||
};
|
||||
|
||||
@@ -102,6 +102,22 @@ export const useLocalRoomName = (room: Room): string => {
|
||||
return localNames[room.roomId] ?? name;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #336] The whole "Rename for me" map, for lists that show many rooms
|
||||
* (quick switcher, forward/share pickers) so they use the name the user sees in
|
||||
* the sidebar, not the server name.
|
||||
*/
|
||||
export const useLocalRoomNames = (): Readonly<Record<string, string>> => {
|
||||
const mx = useMatrixClient();
|
||||
return roomNamesStore.useValue(mx);
|
||||
};
|
||||
|
||||
/** Non-hook read for notification paths (toast / OS notification titles). */
|
||||
export const getLocalRoomName = (
|
||||
mx: ReturnType<typeof useMatrixClient>,
|
||||
roomId: string,
|
||||
): string | undefined => roomNamesStore.getLatest(mx)[roomId];
|
||||
|
||||
export const useHasLocalRoomName = (roomId: string): boolean => {
|
||||
const mx = useMatrixClient();
|
||||
const localNames = roomNamesStore.useValue(mx);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { kumaUrls, parseKumaStatus, ServerStatus, StatusPageConfig } from '../utils/kumaStatus';
|
||||
|
||||
/** Normal poll, and while a problem is showing (so recovery clears quickly). */
|
||||
export const STATUS_POLL_MS = 5 * 60 * 1000;
|
||||
export const STATUS_POLL_PROBLEM_MS = 60 * 1000;
|
||||
const STATUS_FETCH_TIMEOUT_MS = 10 * 1000;
|
||||
const STATUS_BACKOFF_MAX_MS = 15 * 60 * 1000;
|
||||
|
||||
const fetchJson = async (url: string, signal: AbortSignal): Promise<unknown> => {
|
||||
const res = await fetch(url, { cache: 'no-store', credentials: 'omit', signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const hasProblem = (s: ServerStatus | null): boolean =>
|
||||
!!s &&
|
||||
(s.homeserver === 'down' || s.calls === 'down' || s.login === 'down' || s.maintenance.length > 0);
|
||||
|
||||
/**
|
||||
* [Gitea #124] Poll the homeserver's Kuma status page (none configured → no
|
||||
* requests, null). Only while the page is visible; every 5 min, every 60 s
|
||||
* while something is wrong, and at once when `urgent` turns true (this
|
||||
* client's connection dropped). Any failure → null ("no banner"), with
|
||||
* backoff: Kuma being unreachable must never look like Matrix being down.
|
||||
*/
|
||||
export const useServerStatus = (
|
||||
page: StatusPageConfig | undefined,
|
||||
urgent = false,
|
||||
): ServerStatus | null => {
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||
const pageKey = page ? `${page.url}/${page.slug}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!page) {
|
||||
setStatus(null);
|
||||
return undefined;
|
||||
}
|
||||
const urls = kumaUrls(page);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let controller: AbortController | undefined;
|
||||
let failures = 0;
|
||||
let stopped = false;
|
||||
let last: ServerStatus | null = null;
|
||||
|
||||
const schedule = (ms: number) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(poll, ms);
|
||||
};
|
||||
|
||||
async function poll() {
|
||||
if (stopped) return;
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
const abort = controller;
|
||||
const timeout = setTimeout(() => abort.abort(), STATUS_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const [pageJson, heartbeatJson] = await Promise.all([
|
||||
fetchJson(urls.page, abort.signal),
|
||||
fetchJson(urls.heartbeat, abort.signal),
|
||||
]);
|
||||
if (stopped) return;
|
||||
failures = 0;
|
||||
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups);
|
||||
setStatus(last);
|
||||
// While this client's connection is lost, keep asking every minute: that
|
||||
// is how it learns Kuma has checked the server since the drop.
|
||||
schedule(hasProblem(last) || urgent ? STATUS_POLL_PROBLEM_MS : STATUS_POLL_MS);
|
||||
} catch {
|
||||
if (stopped) return;
|
||||
failures += 1;
|
||||
last = null;
|
||||
setStatus(null);
|
||||
schedule(Math.min(STATUS_POLL_PROBLEM_MS * 2 ** failures, STATUS_BACKOFF_MAX_MS));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') poll();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
controller?.abort();
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
};
|
||||
// pageKey identifies the page; the object itself is rebuilt from config.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageKey, urgent]);
|
||||
|
||||
return status;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { atom, useAtom, useSetAtom } from 'jotai';
|
||||
import { useTauriEvent } from './useTauri';
|
||||
import { UpdatePhase, parseUpdateError } from '../utils/updateErrors';
|
||||
import { InstallKind, UpdatePhase, parseUpdateError, toInstallKind } from '../utils/updateErrors';
|
||||
|
||||
type TauriInternals = { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
||||
const tauriInvoke = (): TauriInternals['invoke'] | undefined =>
|
||||
@@ -36,6 +36,11 @@ export type UpdateFailure = { phase: UpdatePhase; message: string };
|
||||
// mid-request by the resolve/reject below, so nothing gets stuck.
|
||||
const updateStatusAtom = atom<UpdateStatus>({ state: 'idle' });
|
||||
|
||||
// How this install gets updated; asked once. A desktop build without the
|
||||
// command (older than it) rejects the call: keep the old in-app behaviour.
|
||||
const installKindAtom = atom<InstallKind | undefined>(undefined);
|
||||
let installKindRequested = false;
|
||||
|
||||
/**
|
||||
* Mirror native download progress into the status. Mounted once (in
|
||||
* TauriUpdateFeature) so the listener isn't duplicated per consumer.
|
||||
@@ -50,6 +55,16 @@ export function useTauriUpdateProgress(): void {
|
||||
export function useTauriUpdater() {
|
||||
const isTauri = !!tauriInvoke();
|
||||
const [status, setStatus] = useAtom(updateStatusAtom);
|
||||
const [installKind, setInstallKind] = useAtom(installKindAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const invoke = tauriInvoke();
|
||||
if (!invoke || installKindRequested) return;
|
||||
installKindRequested = true;
|
||||
invoke('update_install_kind')
|
||||
.then((kind) => setInstallKind(toInstallKind(kind)))
|
||||
.catch(() => setInstallKind('in-app'));
|
||||
}, [setInstallKind]);
|
||||
|
||||
const check = useCallback(async () => {
|
||||
const invoke = tauriInvoke();
|
||||
@@ -89,5 +104,5 @@ export function useTauriUpdater() {
|
||||
}
|
||||
}, [setStatus]);
|
||||
|
||||
return { isTauri, status, check, install };
|
||||
return { isTauri, status, check, install, installKind: installKind ?? 'in-app' };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StateEvent } from '../../types/matrix/room';
|
||||
import { VoiceLimitContent } from '../features/common-settings/general/RoomVoiceLimit';
|
||||
import { useCallMembers, useCallSession } from './useCall';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { voiceChannelOccupancy } from '../utils/voiceChannelFull';
|
||||
|
||||
export type VoiceChannelFull = {
|
||||
channelFull: boolean;
|
||||
@@ -26,9 +27,11 @@ export const useVoiceChannelFull = (room: Room): VoiceChannelFull => {
|
||||
|
||||
return useMemo(() => {
|
||||
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
|
||||
// A user already counted in the session is rejoining and should not be blocked.
|
||||
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId());
|
||||
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers;
|
||||
return { channelFull, current: callMembers.length, max: maxUsers };
|
||||
// Distinct people, not per-device memberships (Gitea #342).
|
||||
return voiceChannelOccupancy(
|
||||
callMembers.map((m) => m.sender),
|
||||
mx.getSafeUserId(),
|
||||
maxUsers,
|
||||
);
|
||||
}, [limitEvent, callMembers, mx]);
|
||||
};
|
||||
|
||||
+15
-3
@@ -36,7 +36,11 @@ import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
|
||||
import { zIndices } from '../styles/zIndex';
|
||||
import { OIDC_CALLBACK_PATH } from './paths';
|
||||
import { OidcCallback } from './auth/oidc/OidcCallback';
|
||||
import { resolveCallPageUrl, setCallPageUrl } from '../plugins/call/callPageUrl';
|
||||
import {
|
||||
resolveCallPageUrl,
|
||||
resolveDesktopCallPageUrl,
|
||||
setCallPageUrl,
|
||||
} from '../plugins/call/callPageUrl';
|
||||
|
||||
// The emoji families (Twemoji when "Twitter emoji" is on, Twemoji flags on
|
||||
// Windows — see SystemEmojiFeature) must sit before the generic family, or the
|
||||
@@ -44,7 +48,7 @@ import { resolveCallPageUrl, setCallPageUrl } from '../plugins/call/callPageUrl'
|
||||
const EMOJI_FONTS = 'var(--font-flags), var(--font-emoji)';
|
||||
const FONT_MAP: Record<string, string> = {
|
||||
system: `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', ${EMOJI_FONTS}, sans-serif`,
|
||||
inter: `'InterVariable', ${EMOJI_FONTS}, sans-serif`,
|
||||
inter: `'Inter Variable', 'InterVariable', ${EMOJI_FONTS}, sans-serif`,
|
||||
'jetbrains-mono': `'JetBrains Mono', ${EMOJI_FONTS}, monospace`,
|
||||
'fira-code': `'Fira Code', ${EMOJI_FONTS}, monospace`,
|
||||
};
|
||||
@@ -223,7 +227,15 @@ function App() {
|
||||
>
|
||||
{(clientConfig) => {
|
||||
// [Gitea #43] Idempotent: where the call page is loaded from.
|
||||
setCallPageUrl(resolveCallPageUrl(clientConfig.elementCallUrl, isTauri()));
|
||||
setCallPageUrl(
|
||||
isTauri()
|
||||
? resolveDesktopCallPageUrl(
|
||||
clientConfig.desktopCallOrigin,
|
||||
window.location.origin,
|
||||
import.meta.env.BASE_URL,
|
||||
)
|
||||
: resolveCallPageUrl(clientConfig.elementCallUrl, false),
|
||||
);
|
||||
return (
|
||||
<ClientConfigProvider value={clientConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { AuthFlowsLoader } from '../../components/AuthFlowsLoader';
|
||||
import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
|
||||
import { AuthServerProvider } from '../../hooks/useAuthServer';
|
||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||
import { LoginStatusBanner } from '../../features/server-status/ServerStatusBanner';
|
||||
|
||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png');
|
||||
|
||||
@@ -125,89 +126,92 @@ export function AuthLayout() {
|
||||
discoveryState.status === AsyncStatus.Success ? discoveryState.data.response : [];
|
||||
|
||||
return (
|
||||
<Scroll variant="Background" visibility="Hover" size="300" hideTrack>
|
||||
<Box
|
||||
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="SpaceBetween"
|
||||
gap="400"
|
||||
>
|
||||
<Box direction="Column" className={css.AuthCard}>
|
||||
<Header className={css.AuthHeader} size="600" variant="Surface">
|
||||
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
|
||||
<img className={css.AuthLogo} src={LotusLogo} alt="Lotus Chat Logo" />
|
||||
<Text as="h1" size="H3">
|
||||
Lotus Chat
|
||||
</Text>
|
||||
<Box direction="Column" style={{ height: '100%' }}>
|
||||
<LoginStatusBanner serverName={server} />
|
||||
<Scroll variant="Background" visibility="Hover" size="300" hideTrack>
|
||||
<Box
|
||||
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="SpaceBetween"
|
||||
gap="400"
|
||||
>
|
||||
<Box direction="Column" className={css.AuthCard}>
|
||||
<Header className={css.AuthHeader} size="600" variant="Surface">
|
||||
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
|
||||
<img className={css.AuthLogo} src={LotusLogo} alt="Lotus Chat Logo" />
|
||||
<Text as="h1" size="H3">
|
||||
Lotus Chat
|
||||
</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box className={css.AuthCardContent} direction="Column">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text as="label" size="L400" priority="300">
|
||||
Homeserver
|
||||
</Text>
|
||||
<ServerPicker
|
||||
server={server}
|
||||
serverList={clientConfig.homeserverList ?? []}
|
||||
allowCustomServer={clientConfig.allowCustomHomeservers}
|
||||
onServerChange={selectServer}
|
||||
/>
|
||||
</Box>
|
||||
{discoveryState.status === AsyncStatus.Loading && (
|
||||
<AuthLayoutLoading message="Looking for homeserver..." />
|
||||
)}
|
||||
{discoveryState.status === AsyncStatus.Error && (
|
||||
<AuthLayoutError message="Failed to find homeserver." />
|
||||
)}
|
||||
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
|
||||
<AuthLayoutError
|
||||
message={`Failed to connect. Homeserver configuration found with ${autoDiscoveryError.host} appears unusable.`}
|
||||
/>
|
||||
)}
|
||||
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
|
||||
<AuthLayoutError message="Failed to connect. Homeserver configuration base_url appears invalid." />
|
||||
)}
|
||||
{discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && (
|
||||
<AuthServerProvider value={discoveryState.data.serverName}>
|
||||
<AutoDiscoveryInfoProvider value={autoDiscoveryInfo}>
|
||||
<SpecVersionsLoader
|
||||
baseUrl={autoDiscoveryInfo['m.homeserver'].base_url}
|
||||
fallback={() => (
|
||||
<AuthLayoutLoading
|
||||
message={`Connecting to ${autoDiscoveryInfo['m.homeserver'].base_url}`}
|
||||
/>
|
||||
)}
|
||||
error={() => (
|
||||
<AuthLayoutError message="Failed to connect. Either homeserver is unavailable at this moment or does not exist." />
|
||||
)}
|
||||
>
|
||||
{(specVersions) => (
|
||||
<SpecVersionsProvider value={specVersions}>
|
||||
<AuthFlowsLoader
|
||||
fallback={() => (
|
||||
<AuthLayoutLoading message="Loading authentication flow..." />
|
||||
)}
|
||||
error={() => (
|
||||
<AuthLayoutError message="Failed to get authentication flow information." />
|
||||
)}
|
||||
>
|
||||
{(authFlows) => (
|
||||
<AuthFlowsProvider value={authFlows}>
|
||||
<Outlet />
|
||||
</AuthFlowsProvider>
|
||||
)}
|
||||
</AuthFlowsLoader>
|
||||
</SpecVersionsProvider>
|
||||
)}
|
||||
</SpecVersionsLoader>
|
||||
</AutoDiscoveryInfoProvider>
|
||||
</AuthServerProvider>
|
||||
)}
|
||||
</Box>
|
||||
</Header>
|
||||
<Box className={css.AuthCardContent} direction="Column">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text as="label" size="L400" priority="300">
|
||||
Homeserver
|
||||
</Text>
|
||||
<ServerPicker
|
||||
server={server}
|
||||
serverList={clientConfig.homeserverList ?? []}
|
||||
allowCustomServer={clientConfig.allowCustomHomeservers}
|
||||
onServerChange={selectServer}
|
||||
/>
|
||||
</Box>
|
||||
{discoveryState.status === AsyncStatus.Loading && (
|
||||
<AuthLayoutLoading message="Looking for homeserver..." />
|
||||
)}
|
||||
{discoveryState.status === AsyncStatus.Error && (
|
||||
<AuthLayoutError message="Failed to find homeserver." />
|
||||
)}
|
||||
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
|
||||
<AuthLayoutError
|
||||
message={`Failed to connect. Homeserver configuration found with ${autoDiscoveryError.host} appears unusable.`}
|
||||
/>
|
||||
)}
|
||||
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
|
||||
<AuthLayoutError message="Failed to connect. Homeserver configuration base_url appears invalid." />
|
||||
)}
|
||||
{discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && (
|
||||
<AuthServerProvider value={discoveryState.data.serverName}>
|
||||
<AutoDiscoveryInfoProvider value={autoDiscoveryInfo}>
|
||||
<SpecVersionsLoader
|
||||
baseUrl={autoDiscoveryInfo['m.homeserver'].base_url}
|
||||
fallback={() => (
|
||||
<AuthLayoutLoading
|
||||
message={`Connecting to ${autoDiscoveryInfo['m.homeserver'].base_url}`}
|
||||
/>
|
||||
)}
|
||||
error={() => (
|
||||
<AuthLayoutError message="Failed to connect. Either homeserver is unavailable at this moment or does not exist." />
|
||||
)}
|
||||
>
|
||||
{(specVersions) => (
|
||||
<SpecVersionsProvider value={specVersions}>
|
||||
<AuthFlowsLoader
|
||||
fallback={() => (
|
||||
<AuthLayoutLoading message="Loading authentication flow..." />
|
||||
)}
|
||||
error={() => (
|
||||
<AuthLayoutError message="Failed to get authentication flow information." />
|
||||
)}
|
||||
>
|
||||
{(authFlows) => (
|
||||
<AuthFlowsProvider value={authFlows}>
|
||||
<Outlet />
|
||||
</AuthFlowsProvider>
|
||||
)}
|
||||
</AuthFlowsLoader>
|
||||
</SpecVersionsProvider>
|
||||
)}
|
||||
</SpecVersionsLoader>
|
||||
</AutoDiscoveryInfoProvider>
|
||||
</AuthServerProvider>
|
||||
)}
|
||||
</Box>
|
||||
<AuthFooter />
|
||||
</Box>
|
||||
<AuthFooter />
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Scroll>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ThreadEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import { useNotificationsQuiet } from '../../hooks/useNotificationsQuiet';
|
||||
import { getLocalRoomName } from '../../hooks/useRoomMeta';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import NotificationSound from '../../../../public/sound/notification.ogg';
|
||||
import InviteSound from '../../../../public/sound/invite.ogg';
|
||||
@@ -31,6 +32,7 @@ import { allInvitesAtom } from '../../state/room-list/inviteList';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
|
||||
import { OutboxFeature } from '../../features/outbox/OutboxFeature';
|
||||
import { useSearchCacheInvalidation } from '../../utils/searchCacheInvalidation';
|
||||
import { ClockSkewMonitor } from '../../utils/clockSkew';
|
||||
import { clockSkewAtom } from '../../state/clockSkew';
|
||||
@@ -47,6 +49,7 @@ import {
|
||||
getNotificationType,
|
||||
getUnreadInfo,
|
||||
isNotificationEvent,
|
||||
isFromFilteredTimelineSet,
|
||||
} from '../../utils/room';
|
||||
import { NotificationType } from '../../../types/matrix/room';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
@@ -66,8 +69,14 @@ import { setPresenceWithRetry } from '../../utils/presenceWrite';
|
||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||
import { dismissToastAtom, toastQueueAtom } from '../../state/toast';
|
||||
import { useReminders } from '../../hooks/useReminders';
|
||||
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
|
||||
import {
|
||||
getRoomRetentionMs,
|
||||
getRoomRetentionSetAt,
|
||||
isExpired,
|
||||
retentionDeletesAllowed,
|
||||
} from '../../utils/retention';
|
||||
import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||
import { settingsRequestAtom } from '../../state/settingsRequest';
|
||||
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
||||
import { invokeTauri, isTauri as isTauriApp, useTauriEvent } from '../../hooks/useTauri';
|
||||
import { CloseBehaviorPrompt } from '../../components/CloseBehaviorPrompt';
|
||||
@@ -77,6 +86,7 @@ import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../feat
|
||||
import { useRoomsListener } from '../../hooks/useRoomsListener';
|
||||
import { threadNotificationsAtom } from '../../state/threadNotifications';
|
||||
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||
import { lotusLinkBases } from '../../hooks/useLotusLinkBase';
|
||||
import {
|
||||
getThreadNotificationMode,
|
||||
shouldNotifyThreadReply,
|
||||
@@ -118,11 +128,12 @@ function SystemEmojiFeature() {
|
||||
|
||||
// [Gitea #103] Mirror the privacy toggle into the html parser's module flag.
|
||||
function LotusPermalinkFeature() {
|
||||
const { hashRouter } = useClientConfig();
|
||||
const { hashRouter, webAppUrl } = useClientConfig();
|
||||
useEffect(() => {
|
||||
setLotusPermalinkBase(getOriginBaseUrl(hashRouter));
|
||||
// [Gitea #248] In the desktop app, links to the public web app count too.
|
||||
setLotusPermalinkBase(lotusLinkBases(getOriginBaseUrl(hashRouter), isTauriApp(), webAppUrl));
|
||||
return () => setLotusPermalinkBase(undefined);
|
||||
}, [hashRouter]);
|
||||
}, [hashRouter, webAppUrl]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -635,7 +646,8 @@ function MessageNotifications() {
|
||||
const avatarMxc =
|
||||
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
||||
notify({
|
||||
roomName: room.name ?? 'Unknown',
|
||||
// [Gitea #336] the user's own "Rename for me" name when they set one
|
||||
roomName: getLocalRoomName(mx, room.roomId) ?? room.name ?? 'Unknown',
|
||||
roomAvatar: avatarMxc
|
||||
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
|
||||
: undefined,
|
||||
@@ -673,6 +685,7 @@ function MessageNotifications() {
|
||||
data,
|
||||
) => {
|
||||
if (mx.getSyncState() !== 'SYNCING') return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] no duplicate notifications
|
||||
if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
|
||||
if (
|
||||
!room ||
|
||||
@@ -779,15 +792,20 @@ function ReminderMonitor() {
|
||||
const { reminders, removeReminder } = useReminders();
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||
const quietActive = useNotificationsQuiet();
|
||||
const firedRef = useRef<Set<string>>(new Set());
|
||||
const removingRef = useRef<Set<string>>(new Set());
|
||||
// Read the latest reminders / DM map via refs so the poll interval below is
|
||||
// created once — not torn down and restarted (which resets its 30s countdown
|
||||
// and can indefinitely defer a near-due reminder) on every reminder sync (N115).
|
||||
// Read the latest reminders / DM map / notification prefs via refs so the poll
|
||||
// interval below is created once — not torn down and restarted (which resets
|
||||
// its 30s countdown and can indefinitely defer a near-due reminder) on every
|
||||
// reminder sync (N115).
|
||||
const remindersRef = useRef(reminders);
|
||||
remindersRef.current = reminders;
|
||||
const mDirectsRef = useRef(mDirects);
|
||||
mDirectsRef.current = mDirects;
|
||||
const osNotifyRef = useRef(false);
|
||||
osNotifyRef.current = showNotifications && !quietActive;
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => {
|
||||
@@ -799,7 +817,7 @@ function ReminderMonitor() {
|
||||
if (!firedRef.current.has(key)) {
|
||||
firedRef.current.add(key);
|
||||
const room = mx.getRoom(r.roomId);
|
||||
const roomName = room?.name ?? 'Unknown Room';
|
||||
const roomName = getLocalRoomName(mx, r.roomId) ?? room?.name ?? 'Unknown Room';
|
||||
// Reminders for E2EE rooms carry no text (account data is server-
|
||||
// readable), so resolve the body from the local timeline at fire time.
|
||||
const localBody = room?.findEventById(r.eventId)?.getContent()?.body;
|
||||
@@ -811,6 +829,23 @@ function ReminderMonitor() {
|
||||
const hashPath = mDirectsRef.current.has(r.roomId)
|
||||
? getDirectRoomPath(r.roomId, r.eventId)
|
||||
: getHomeRoomPath(r.roomId, r.eventId);
|
||||
// [Gitea #338] A reminder is only done once the user has SEEN it: the
|
||||
// toast stays until clicked or dismissed (it used to vanish after 4 s
|
||||
// and the reminder was deleted at once, so one due while the user was
|
||||
// away was lost). The removal is persisted on that click/dismiss; an
|
||||
// unseen reminder comes back on the next start.
|
||||
const done = () => {
|
||||
if (removingRef.current.has(key)) return;
|
||||
removingRef.current.add(key);
|
||||
removeReminder(r.eventId, r.timestamp).catch(() => {
|
||||
removingRef.current.delete(key);
|
||||
});
|
||||
};
|
||||
const open = () => {
|
||||
done();
|
||||
// window.location.hash setter auto-prepends '#' (as the toast does)
|
||||
window.location.hash = hashPath;
|
||||
};
|
||||
setToast({
|
||||
id: `reminder-${key}`,
|
||||
displayName: 'Reminder',
|
||||
@@ -818,16 +853,30 @@ function ReminderMonitor() {
|
||||
roomName,
|
||||
roomId: r.roomId,
|
||||
hashPath,
|
||||
sticky: true,
|
||||
onClick: open,
|
||||
onDismiss: done,
|
||||
});
|
||||
}
|
||||
// Persist the removal, retrying on a later tick if it fails — without
|
||||
// re-showing the toast (N114). The server echo drops it from
|
||||
// `reminders` once the write lands.
|
||||
if (!removingRef.current.has(key)) {
|
||||
removingRef.current.add(key);
|
||||
removeReminder(r.eventId, r.timestamp).catch(() => {
|
||||
removingRef.current.delete(key);
|
||||
});
|
||||
// Away from the window: also raise an OS notification (the reminder is
|
||||
// an explicit request, but quiet hours / notifications-off win). Never
|
||||
// put decrypted E2EE text in it (N106).
|
||||
if (osNotifyRef.current && notificationPermission('granted') && !document.hasFocus()) {
|
||||
const encrypted = !!room?.hasEncryptionStateEvent();
|
||||
showOsNotification(
|
||||
'Reminder',
|
||||
{
|
||||
icon: LogoSVG,
|
||||
badge: LogoSVG,
|
||||
body: encrypted ? `A message in ${roomName}` : body,
|
||||
tag: `reminder-${key}`,
|
||||
data: { path: hashPath, roomId: r.roomId },
|
||||
},
|
||||
() => {
|
||||
window.focus();
|
||||
open();
|
||||
},
|
||||
).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -868,6 +917,9 @@ function RetentionSweeper() {
|
||||
mx.getRooms().forEach((room) => {
|
||||
const maxLifetime = getRoomRetentionMs(room);
|
||||
if (!maxLifetime) return;
|
||||
// [Gitea #298] Never delete under a policy that is less than a day old.
|
||||
const setAt = getRoomRetentionSetAt(room);
|
||||
if (setAt === undefined || !retentionDeletesAllowed(setAt, now)) return;
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
@@ -910,7 +962,8 @@ const UPDATE_PROGRESS_TOAST = 'tauri-update-progress';
|
||||
const UPDATE_FAILED_TOAST = 'tauri-update-failed';
|
||||
|
||||
function TauriUpdateFeature() {
|
||||
const { isTauri, status, check, install } = useTauriUpdater();
|
||||
const { isTauri, status, check, install, installKind } = useTauriUpdater();
|
||||
const requestSettings = useSetAtom(settingsRequestAtom);
|
||||
useTauriUpdateProgress();
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
const dismissToast = useSetAtom(dismissToastAtom);
|
||||
@@ -969,18 +1022,24 @@ function TauriUpdateFeature() {
|
||||
if (status.state !== 'available') return;
|
||||
if (firedRef.current === status.version) return;
|
||||
firedRef.current = status.version;
|
||||
// A Linux package install can't be updated in place: point at the
|
||||
// package-manager command in Settings instead of an install that fails.
|
||||
const viaPackage = installKind !== 'in-app';
|
||||
setToast({
|
||||
id: `tauri-update-${status.version}`,
|
||||
displayName: '⬆ Update Available',
|
||||
body: `Lotus Chat ${status.version} is ready. Click to install and restart.`,
|
||||
body: viaPackage
|
||||
? `Lotus Chat ${status.version} is available. Click for the command to update it with your package manager.`
|
||||
: `Lotus Chat ${status.version} is ready. Click to install and restart.`,
|
||||
roomName: 'System',
|
||||
roomId: '',
|
||||
onClick: () => {
|
||||
installFromToast();
|
||||
if (viaPackage) requestSettings('general');
|
||||
else installFromToast();
|
||||
},
|
||||
sticky: true,
|
||||
});
|
||||
}, [status, setToast, installFromToast]);
|
||||
}, [status, setToast, installFromToast, installKind, requestSettings]);
|
||||
|
||||
// [cinny-desktop #6] Mirror a pending update into the tray ("Restart to
|
||||
// update" + tooltip) so a dismissed toast isn't the only reminder. Kept while
|
||||
@@ -1069,6 +1128,11 @@ function ClockSkewFeature() {
|
||||
data,
|
||||
) => {
|
||||
if (!data.liveEvent) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
// Only events our homeserver stamped: a federated event's
|
||||
// origin_server_ts is the other server's clock.
|
||||
const senderServer = mEvent.getSender()?.split(':').slice(1).join(':');
|
||||
if (senderServer !== mx.getDomain()) return;
|
||||
monitor.sample(mEvent.getTs(), mEvent.getAge(), mEvent.localTimestamp);
|
||||
};
|
||||
mx.on(RoomEvent.Timeline, onTimeline);
|
||||
@@ -1105,6 +1169,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
<TauriDesktopFeatures />
|
||||
<CloseBehaviorPrompt />
|
||||
<ConsentRequiredPrompt />
|
||||
<OutboxFeature />
|
||||
<LotusDenoiseFeature />
|
||||
<DeepLinkNavigator />
|
||||
<KeyboardShortcutsFeature />
|
||||
|
||||
@@ -44,6 +44,11 @@ import { useSyncState } from '../../hooks/useSyncState';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { SyncStatus } from './SyncStatus';
|
||||
import { ClockSkewBanner } from './ClockSkewBanner';
|
||||
import {
|
||||
ServerStatusBanner,
|
||||
ServerStatusFeature,
|
||||
} from '../../features/server-status/ServerStatusBanner';
|
||||
import { SecurityBanner } from '../../features/security-nudge/SecurityBanner';
|
||||
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
|
||||
import { pushSessionToSW } from '../../../sw-session';
|
||||
@@ -245,6 +250,8 @@ export function ClientRoot({ children }: ClientRootProps) {
|
||||
return (
|
||||
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
|
||||
<SpecVersions baseUrl={baseUrl!}>
|
||||
{mx && <ServerStatusFeature mx={mx} />}
|
||||
{mx && !syncError && <ServerStatusBanner />}
|
||||
{mx && !syncError && <SyncStatus mx={mx} />}
|
||||
{mx && !syncError && <ClockSkewBanner />}
|
||||
{loading && <ClientRootOptions mx={mx} />}
|
||||
@@ -307,6 +314,8 @@ export function ClientRoot({ children }: ClientRootProps) {
|
||||
<ClientRootLoading />
|
||||
) : (
|
||||
<MatrixClientProvider value={mx}>
|
||||
{/* [Gitea #110/#123] Needs the client context (its hooks read it). */}
|
||||
{!syncError && <SecurityBanner />}
|
||||
<ServerConfigsLoader>
|
||||
{(serverConfigs) => (
|
||||
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
|
||||
|
||||
@@ -19,7 +19,7 @@ const readDismissedUntil = (): number => {
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #158] "Your computer's clock is 14 minutes ahead of the server."
|
||||
* [Gitea #158] "This device's clock is 14 minutes ahead of the server."
|
||||
* Same slot and style as the sync banners. Shown while the skew monitor is
|
||||
* over its threshold; the direction matters, so it is said. Dismissable for
|
||||
* 24 h; never auto-corrects anything.
|
||||
@@ -53,8 +53,8 @@ export function ClockSkewBanner() {
|
||||
>
|
||||
<Box alignItems="Center" gap="300" wrap="Wrap" justifyContent="Center">
|
||||
<Text size="L400" align="Center">
|
||||
Your computer's clock is <b>{describeSkewVsServer(skewMs)}</b>. Encrypted messages
|
||||
and voice calls will fail until it is fixed.
|
||||
This device's clock is <b>{describeSkewVsServer(skewMs)}</b>. Voice calls and
|
||||
encrypted messages can fail until it's corrected.
|
||||
</Text>
|
||||
<Button
|
||||
size="300"
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Box, config, Line, Text } from 'folds';
|
||||
import { useSyncState } from '../../hooks/useSyncState';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { serverStatusAtom } from '../../state/serverStatus';
|
||||
import { serverConfirmedUpSince } from '../../utils/kumaStatus';
|
||||
import { homeserverStalledAtom } from '../../hooks/useHomeserverStall';
|
||||
|
||||
type StateData = {
|
||||
current: SyncState | null;
|
||||
@@ -13,6 +17,8 @@ type SyncStatusProps = {
|
||||
mx: MatrixClient;
|
||||
};
|
||||
export function SyncStatus({ mx }: SyncStatusProps) {
|
||||
const serverStatus = useAtomValue(serverStatusAtom);
|
||||
const stalled = useAtomValue(homeserverStalledAtom);
|
||||
const [stateData, setStateData] = useState<StateData>({
|
||||
current: null,
|
||||
previous: undefined,
|
||||
@@ -30,6 +36,35 @@ export function SyncStatus({ mx }: SyncStatusProps) {
|
||||
}, []),
|
||||
);
|
||||
|
||||
// [Gitea #124] When Kuma confirms the homeserver is down, the status banner
|
||||
// says so ("Lotus Chat's server is down…") instead of "Connection Lost!".
|
||||
const serverDown = serverStatus.status?.homeserver === 'down';
|
||||
// Only blame the user's connection when Kuma has checked the server since
|
||||
// the drop and it was fine (a stale "up" from before proves nothing).
|
||||
const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt);
|
||||
|
||||
// [Gitea #317] Sync still says SYNCING, but the server has stopped answering.
|
||||
if (stalled && stateData.current === SyncState.Syncing && !serverDown) {
|
||||
return (
|
||||
<Box direction="Column" shrink="No">
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'Warning' })}
|
||||
style={{ padding: `${config.space.S100} 0` }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Text size="L400">
|
||||
Lotus Chat's server isn't responding. Messages will send when it's back.
|
||||
Still trying…
|
||||
</Text>
|
||||
</Box>
|
||||
<Line variant="Warning" size="300" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(stateData.current === SyncState.Prepared ||
|
||||
stateData.current === SyncState.Syncing ||
|
||||
@@ -53,7 +88,7 @@ export function SyncStatus({ mx }: SyncStatusProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (stateData.current === SyncState.Reconnecting) {
|
||||
if (stateData.current === SyncState.Reconnecting && !serverDown) {
|
||||
return (
|
||||
<Box direction="Column" shrink="No">
|
||||
<Box
|
||||
@@ -64,14 +99,18 @@ export function SyncStatus({ mx }: SyncStatusProps) {
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Text size="L400">Connection Lost! Reconnecting...</Text>
|
||||
<Text size="L400">
|
||||
{serverUp
|
||||
? "Connection lost. Our status checks say Lotus Chat's server is up, so it may be your internet connection. Reconnecting…"
|
||||
: 'Connection Lost! Reconnecting...'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Line variant="Warning" size="300" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (stateData.current === SyncState.Error) {
|
||||
if (stateData.current === SyncState.Error && !serverDown) {
|
||||
return (
|
||||
<Box direction="Column" shrink="No">
|
||||
<Box
|
||||
@@ -82,7 +121,11 @@ export function SyncStatus({ mx }: SyncStatusProps) {
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<Text size="L400">Connection Lost!</Text>
|
||||
<Text size="L400">
|
||||
{serverUp
|
||||
? "Connection lost. Our status checks say Lotus Chat's server is up, so check your internet connection."
|
||||
: 'Connection Lost!'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Line variant="Critical" size="300" />
|
||||
</Box>
|
||||
|
||||
+185
-134
@@ -255,6 +255,8 @@ function HomeCustomSection({
|
||||
return (localName ?? matrixName).toLowerCase().includes(query);
|
||||
});
|
||||
}, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]);
|
||||
// [Gitea #289] While filtering, a section with no matches is hidden.
|
||||
if (filterQuery.trim() && items.length === 0) return null;
|
||||
// Sections are user-curated and small, so they render plainly — no
|
||||
// virtualizer per section (a late-mounted one never measured its scroller).
|
||||
return (
|
||||
@@ -443,6 +445,26 @@ export function Home() {
|
||||
});
|
||||
}, [mx, sortedRooms, filterQuery]);
|
||||
|
||||
// [Gitea #289] While filtering, hide empty sections; with no match at all,
|
||||
// say so instead of leaving bare headers.
|
||||
const filtering = filterQuery.trim().length > 0;
|
||||
const customSectionMatch = useMemo(() => {
|
||||
if (!filtering) return true;
|
||||
const query = filterQuery.toLowerCase();
|
||||
const localNames = getLocalRoomNamesContent(mx);
|
||||
return customSections.some((section) =>
|
||||
section.rooms.some((rId) =>
|
||||
(localNames.rooms[rId] ?? mx.getRoom(rId)?.name ?? '').toLowerCase().includes(query),
|
||||
),
|
||||
);
|
||||
}, [mx, customSections, filtering, filterQuery]);
|
||||
const noFilterMatch =
|
||||
filtering &&
|
||||
!customSectionMatch &&
|
||||
filteredFavoriteRooms.length === 0 &&
|
||||
filteredRooms.length === 0 &&
|
||||
filteredLowPriorityRooms.length === 0;
|
||||
|
||||
const favVirtualizer = useVirtualizer({
|
||||
count: filteredFavoriteRooms.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
@@ -580,7 +602,7 @@ export function Home() {
|
||||
/>
|
||||
</Box>
|
||||
</NavCategory>
|
||||
{favoriteRooms.length > 0 && (
|
||||
{favoriteRooms.length > 0 && (!filtering || filteredFavoriteRooms.length > 0) && (
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
@@ -629,142 +651,171 @@ export function Home() {
|
||||
notificationPreferences={notificationPreferences}
|
||||
/>
|
||||
))}
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
|
||||
data-category-id={DEFAULT_CATEGORY_ID}
|
||||
onClick={handleCategoryClick}
|
||||
{noFilterMatch && (
|
||||
<NavCategory>
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{ padding: config.space.S400 }}
|
||||
>
|
||||
Rooms
|
||||
</RoomNavCategoryButton>
|
||||
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
|
||||
<>
|
||||
<IconButton
|
||||
aria-expanded={!!sortMenuAnchor}
|
||||
aria-haspopup="menu"
|
||||
variant="Background"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
setSortMenuAnchor((current) => (current ? undefined : cords));
|
||||
}}
|
||||
aria-label="Sort rooms"
|
||||
>
|
||||
<Icon src={Icons.Sort} size="100" />
|
||||
</IconButton>
|
||||
<PopOut
|
||||
anchor={sortMenuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={6}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setSortMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{ padding: config.space.S100 }}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('recent');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'recent' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
<Text size="T300" priority="300" align="Center">
|
||||
No rooms match “{filterQuery.trim()}”
|
||||
</Text>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={() => setFilterQuery('')}
|
||||
>
|
||||
<Text size="B300">Clear filter</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</NavCategory>
|
||||
)}
|
||||
{(!filtering || filteredRooms.length > 0) && (
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
|
||||
data-category-id={DEFAULT_CATEGORY_ID}
|
||||
onClick={handleCategoryClick}
|
||||
>
|
||||
Rooms
|
||||
</RoomNavCategoryButton>
|
||||
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
|
||||
<>
|
||||
<IconButton
|
||||
aria-expanded={!!sortMenuAnchor}
|
||||
aria-haspopup="menu"
|
||||
variant="Background"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
setSortMenuAnchor((current) => (current ? undefined : cords));
|
||||
}}
|
||||
aria-label="Sort rooms"
|
||||
>
|
||||
<Icon src={Icons.Sort} size="100" />
|
||||
</IconButton>
|
||||
<PopOut
|
||||
anchor={sortMenuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={6}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setSortMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{ padding: config.space.S100 }}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Recent Activity
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('alpha');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'alpha' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
A → Z
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('unread');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'unread' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Unread First
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</NavCategoryHeader>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const roomId = filteredRooms[vItem.index];
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const selected = selectedRoomId === roomId;
|
||||
|
||||
return (
|
||||
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selected}
|
||||
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||
notificationMode={getRoomNotificationMode(
|
||||
notificationPreferences,
|
||||
room.roomId,
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('recent');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'recent' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Recent Activity
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('alpha');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'alpha' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
A → Z
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('unread');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'unread' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Unread First
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</NavCategory>
|
||||
{lowPriorityRooms.length > 0 && (
|
||||
</>
|
||||
)}
|
||||
</NavCategoryHeader>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const roomId = filteredRooms[vItem.index];
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const selected = selectedRoomId === roomId;
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={roomId}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selected}
|
||||
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||
notificationMode={getRoomNotificationMode(
|
||||
notificationPreferences,
|
||||
room.roomId,
|
||||
)}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</NavCategory>
|
||||
)}
|
||||
{lowPriorityRooms.length > 0 && (!filtering || filteredLowPriorityRooms.length > 0) && (
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
|
||||
@@ -98,6 +98,7 @@ import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { PresenceRingAvatar } from '../../../components/presence';
|
||||
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||
|
||||
type RoomNotificationsGroup = {
|
||||
roomId: string;
|
||||
@@ -223,6 +224,7 @@ function RoomNotificationsGroupComp({
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
}: RoomNotificationsGroupProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
@@ -428,7 +430,7 @@ function RoomNotificationsGroupComp({
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="H4" truncate>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import { BlockType } from '../../../components/editor';
|
||||
import { bytesToSize } from '../../../utils/common';
|
||||
import { getHomePath } from '../../pathUtils';
|
||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||
|
||||
type Shared = { payload: SharePayload; files: File[] };
|
||||
|
||||
@@ -47,6 +48,7 @@ type Shared = { payload: SharePayload; files: File[] };
|
||||
* as the draft) — the user still presses Send.
|
||||
*/
|
||||
export function Share() {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const navigate = useNavigate();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
@@ -69,8 +71,12 @@ export function Share() {
|
||||
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
|
||||
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
|
||||
const q = query.trim().toLowerCase();
|
||||
return q ? all.filter((r) => r.name.toLowerCase().includes(q)) : all;
|
||||
}, [mx, query]);
|
||||
return q
|
||||
? all.filter((r) =>
|
||||
[r.name, localNames[r.roomId]].some((nm) => nm?.toLowerCase().includes(q)),
|
||||
)
|
||||
: all;
|
||||
}, [mx, query, localNames]);
|
||||
|
||||
// Written straight into the room's draft atoms so its composer shows the
|
||||
// upload board + text as soon as it mounts.
|
||||
@@ -210,6 +216,7 @@ function ShareRoomRow({
|
||||
disabled: boolean;
|
||||
onPick: (room: Room) => void;
|
||||
}) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const avatarMxc = room.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxc
|
||||
@@ -237,7 +244,7 @@ function ShareRoomRow({
|
||||
>
|
||||
<Box direction="Column">
|
||||
<Text size="T300" truncate>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
{dm && (
|
||||
<Text size="T200" priority="300" truncate>
|
||||
|
||||
@@ -49,13 +49,7 @@ import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { useAnyRoomLiveCall } from '../../../hooks/useSpaceLiveCall';
|
||||
import { LiveDot } from './SpaceTabs.css';
|
||||
import {
|
||||
getOriginBaseUrl,
|
||||
getSpaceLobbyPath,
|
||||
getSpacePath,
|
||||
joinPathComponent,
|
||||
} from '../../pathUtils';
|
||||
import { useClientConfig } from '../../../hooks/useClientConfig';
|
||||
import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils';
|
||||
import {
|
||||
SidebarAvatar,
|
||||
SidebarItem,
|
||||
@@ -102,6 +96,7 @@ import { settingsAtom } from '../../../state/settings';
|
||||
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import { useLotusShareBase } from '../../../hooks/useLotusLinkBase';
|
||||
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
|
||||
|
||||
type SpaceMenuProps = {
|
||||
@@ -112,7 +107,7 @@ type SpaceMenuProps = {
|
||||
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
|
||||
({ room, requestClose, onUnpin }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const { hashRouter } = useClientConfig();
|
||||
const lotusBase = useLotusShareBase();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const powerLevels = usePowerLevels(room);
|
||||
@@ -152,9 +147,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
|
||||
const handleCopyLotusLink = () => {
|
||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
||||
copyToClipboard(
|
||||
getLotusSpacePermalink(getOriginBaseUrl(hashRouter), roomIdOrAlias, viaServers),
|
||||
);
|
||||
copyToClipboard(getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers));
|
||||
requestClose();
|
||||
};
|
||||
|
||||
|
||||
@@ -39,13 +39,7 @@ import {
|
||||
NavItemContent,
|
||||
NavLink,
|
||||
} from '../../../components/nav';
|
||||
import {
|
||||
getOriginBaseUrl,
|
||||
getSpaceLobbyPath,
|
||||
getSpaceRoomPath,
|
||||
getSpaceSearchPath,
|
||||
} from '../../pathUtils';
|
||||
import { useClientConfig } from '../../../hooks/useClientConfig';
|
||||
import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils';
|
||||
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix';
|
||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||
import {
|
||||
@@ -92,6 +86,7 @@ import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { BreakWord } from '../../../styles/Text.css';
|
||||
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
|
||||
import { useLotusShareBase } from '../../../hooks/useLotusLinkBase';
|
||||
import { useCallEmbed } from '../../../hooks/useCallEmbed';
|
||||
|
||||
type SpaceMenuProps = {
|
||||
@@ -100,7 +95,7 @@ type SpaceMenuProps = {
|
||||
};
|
||||
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const { hashRouter } = useClientConfig();
|
||||
const lotusBase = useLotusShareBase();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
@@ -137,9 +132,7 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
|
||||
const handleCopyLotusLink = () => {
|
||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
||||
copyToClipboard(
|
||||
getLotusSpacePermalink(getOriginBaseUrl(hashRouter), roomIdOrAlias, viaServers),
|
||||
);
|
||||
copyToClipboard(getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers));
|
||||
requestClose();
|
||||
};
|
||||
|
||||
|
||||
@@ -91,6 +91,13 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
|
||||
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
|
||||
super();
|
||||
// [Gitea #316] More than 10 call features subscribe to state_update at once
|
||||
// (call bar, controls, hotkeys, AFK, muted-talk, join/leave sounds,
|
||||
// announcements, rejoin, policy toast, glance, desktop thumbbar/SMTC …).
|
||||
// Hitting the default limit logged a MaxListenersExceededWarning, and the
|
||||
// console then kept this CallControl — and through it the whole Element Call
|
||||
// document — alive: ~11 MB leaked per call. These are not leaks, so lift it.
|
||||
this.setMaxListeners(100);
|
||||
|
||||
this.state = state;
|
||||
this.call = call;
|
||||
@@ -261,6 +268,15 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
|
||||
private frameScreenshareListeners = new Set<() => void>();
|
||||
|
||||
// [Gitea #43] The fork draws the "Share your screen?" prompt inside the frame
|
||||
// on request (io.lotus.prompt_screenshare), so where the click can't be
|
||||
// delegated our call-bar button asks for that instead of starting the share.
|
||||
private _screensharePrompt = false;
|
||||
|
||||
// [Gitea #43] The fork's in-frame prompt is showing: anything the host lays
|
||||
// over the frame (the picture-in-picture overlay) must let clicks through.
|
||||
private _screensharePromptOpen = false;
|
||||
|
||||
// [Gitea #43] Whether the room's call policy allows screensharing; the fork
|
||||
// hides its in-frame button when it doesn't.
|
||||
private frameScreenshareAllowed = true;
|
||||
@@ -274,7 +290,32 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
return this._frameScreenshare;
|
||||
}
|
||||
|
||||
/** Subscribe to `frameScreenshare` changes. Returns an unsubscribe. */
|
||||
/**
|
||||
* Starting a share must go through the fork's in-frame prompt: this engine
|
||||
* can't delegate the click (Firefox, Safari, WebKitGTK) and the fork has
|
||||
* the prompt. Stopping never needs it.
|
||||
*/
|
||||
public get screenshareNeedsPrompt(): boolean {
|
||||
return this._screensharePrompt && !canDelegateCapability();
|
||||
}
|
||||
|
||||
/** The fork's in-frame "Share your screen?" prompt is showing. */
|
||||
public get screensharePromptOpen(): boolean {
|
||||
return this._screensharePromptOpen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the fork to show "Share your screen?" inside the call frame; its Share
|
||||
* button starts the share with the frame's own click.
|
||||
*/
|
||||
public promptScreenshare(): void {
|
||||
this.sendForkAction('io.lotus.prompt_screenshare', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to `frameScreenshare` / `screenshareNeedsPrompt` /
|
||||
* `screensharePromptOpen` changes. Returns an unsubscribe.
|
||||
*/
|
||||
public onFrameScreenshareChange(cb: () => void): () => void {
|
||||
this.frameScreenshareListeners.add(cb);
|
||||
return () => {
|
||||
@@ -289,7 +330,8 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
}
|
||||
|
||||
private sendFrameScreenshareAllowed(): void {
|
||||
if (!this.joined || !this.forkReady || !this._frameScreenshare) return;
|
||||
if (!this.joined || !this.forkReady) return;
|
||||
if (!this._frameScreenshare && !this._screensharePrompt) return;
|
||||
this.sendForkAction('io.lotus.set_frame_screenshare', {
|
||||
visible: this.frameScreenshareAllowed,
|
||||
});
|
||||
@@ -324,17 +366,31 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
/** [Gitea #43] The fork's `io.lotus.controls_state` report. */
|
||||
public onControlsState(data: unknown) {
|
||||
if (typeof data !== 'object' || data === null) return;
|
||||
const { screensharing, layout, frameScreenshare } = data as {
|
||||
screensharing?: unknown;
|
||||
layout?: unknown;
|
||||
frameScreenshare?: unknown;
|
||||
};
|
||||
const { screensharing, layout, frameScreenshare, screensharePrompt, screensharePromptOpen } =
|
||||
data as {
|
||||
screensharing?: unknown;
|
||||
layout?: unknown;
|
||||
frameScreenshare?: unknown;
|
||||
screensharePrompt?: unknown;
|
||||
screensharePromptOpen?: unknown;
|
||||
};
|
||||
const firstReport = !this.forkReady;
|
||||
this.forkReady = true;
|
||||
const frame = frameScreenshare === true;
|
||||
if (frame !== this._frameScreenshare) {
|
||||
const prompt = screensharePrompt === true;
|
||||
const promptOpen = screensharePromptOpen === true;
|
||||
if (promptOpen !== this._screensharePromptOpen) {
|
||||
this._screensharePromptOpen = promptOpen;
|
||||
if (frame === this._frameScreenshare && prompt === this._screensharePrompt) {
|
||||
this.frameScreenshareListeners.forEach((l) => l());
|
||||
}
|
||||
}
|
||||
if (frame !== this._frameScreenshare || prompt !== this._screensharePrompt) {
|
||||
const promptTurnedOn = prompt && !this._screensharePrompt;
|
||||
this._frameScreenshare = frame;
|
||||
this._screensharePrompt = prompt;
|
||||
this.frameScreenshareListeners.forEach((l) => l());
|
||||
if (promptTurnedOn && !firstReport) this.sendFrameScreenshareAllowed();
|
||||
}
|
||||
if (firstReport) {
|
||||
this.sendHotkeyCodes();
|
||||
|
||||
@@ -31,6 +31,7 @@ import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
||||
import { canDelegateCapability } from './utils';
|
||||
import { getCallPageUrl } from './callPageUrl';
|
||||
import { restrictWidgetMessages } from '../widgetTransport';
|
||||
import { isRingableRoom } from '../../utils/callRing';
|
||||
|
||||
// Maximum time to wait for the embedded Element Call iframe to progress from
|
||||
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
||||
@@ -251,8 +252,18 @@ export class CallEmbed {
|
||||
verifyDenoiseAssets(denoiseModel).catch(() => undefined);
|
||||
}
|
||||
|
||||
// [Gitea #295] Only ask Element Call to notify where a call can ring at all
|
||||
// (DMs, invite-only groups outside a space). In a voice channel or space
|
||||
// room the notification carries an @room mention, so every member got a
|
||||
// highlighted ping (and phone push) whenever a moderator hopped in, and
|
||||
// other clients showed it as a missed call.
|
||||
// `none` needs element-call-embedded ≥ 0.25.0-lotus.23 (older builds ignore it).
|
||||
if (CallEmbed.startingCall(intent)) {
|
||||
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
|
||||
let notify = 'none';
|
||||
if (isRingableRoom(room, CallEmbed.dmCall(intent))) {
|
||||
notify = CallEmbed.dmCall(intent) ? 'ring' : 'notification';
|
||||
}
|
||||
params.append('sendNotificationType', notify);
|
||||
}
|
||||
|
||||
// [Gitea #43] On its own origin when config.json sets elementCallUrl.
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
} from 'matrix-js-sdk';
|
||||
import { getCallCapabilities } from './utils';
|
||||
import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { dedupeInFlight } from '../../utils/inFlight';
|
||||
|
||||
/** A little longer than Element Call's own 2 s timeout for a restart. */
|
||||
const RESTART_TIMEOUT_MS = 5000;
|
||||
export class CallWidgetDriver extends WidgetDriver {
|
||||
private allowedCapabilities: Set<Capability>;
|
||||
|
||||
@@ -129,8 +132,18 @@ export class CallWidgetDriver extends WidgetDriver {
|
||||
await this.mx._unstable_cancelScheduledDelayedEvent(delayId);
|
||||
}
|
||||
|
||||
// [Gitea #318] While the homeserver is stalled, Element Call times out each
|
||||
// restart locally after 2 s and asks again, so requests for the same delay
|
||||
// id piled up and hit the server together on recovery (404s, then 429s).
|
||||
// Share one in-flight restart per id, and give it a timeout of its own.
|
||||
private readonly restartOnce = dedupeInFlight<unknown>();
|
||||
|
||||
public async restartScheduledDelayedEvent(delayId: string): Promise<void> {
|
||||
await this.mx._unstable_restartScheduledDelayedEvent(delayId);
|
||||
await this.restartOnce(delayId, () =>
|
||||
this.mx._unstable_restartScheduledDelayedEvent(delayId, {
|
||||
localTimeoutMs: RESTART_TIMEOUT_MS,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async sendScheduledDelayedEvent(delayId: string): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolveCallPageUrl } from './callPageUrl';
|
||||
import { resolveCallPageUrl, resolveDesktopCallPageUrl } from './callPageUrl';
|
||||
|
||||
const URL_OK = 'https://call.chat.example.org/public/element-call/index.html';
|
||||
|
||||
@@ -38,3 +38,44 @@ test('anything else falls back to the bundled page', () => {
|
||||
'data:text/html,x',
|
||||
].forEach((v) => assert.equal(resolveCallPageUrl(v, false), undefined, String(v)));
|
||||
});
|
||||
|
||||
const APP = 'http://localhost:44548';
|
||||
const PAGE = '/public/element-call/index.html';
|
||||
|
||||
test('desktop: the bundled page from the loopback origin on the same port', () => {
|
||||
assert.equal(
|
||||
resolveDesktopCallPageUrl('http://127.0.0.1:44548', APP, '/'),
|
||||
`http://127.0.0.1:44548${PAGE}`,
|
||||
);
|
||||
assert.equal(
|
||||
resolveDesktopCallPageUrl('http://127.0.0.1:44548/', APP, '/app/'),
|
||||
`http://127.0.0.1:44548/app${PAGE}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('desktop: unset or anything but same-port loopback http keeps the same-origin page', () => {
|
||||
[
|
||||
undefined,
|
||||
'',
|
||||
'http://127.0.0.1:44549',
|
||||
'http://127.0.0.1',
|
||||
'https://127.0.0.1:44548',
|
||||
'http://localhost:44548',
|
||||
'http://[::1]:44548',
|
||||
'http://10.0.0.5:44548',
|
||||
'https://call.chat.lotusguild.org',
|
||||
'http://127.0.0.1:44548/evil/',
|
||||
'http://127.0.0.1:44548/?x=1',
|
||||
'http://user:pw@127.0.0.1:44548',
|
||||
'not a url',
|
||||
42,
|
||||
].forEach((v) => assert.equal(resolveDesktopCallPageUrl(v, APP, '/'), undefined, String(v)));
|
||||
});
|
||||
|
||||
test('desktop: only when the app itself runs on http://localhost (release builds)', () => {
|
||||
const v = 'http://127.0.0.1:44548';
|
||||
assert.equal(resolveDesktopCallPageUrl(v, 'tauri://localhost', '/'), undefined);
|
||||
assert.equal(resolveDesktopCallPageUrl(v, 'http://tauri.localhost', '/'), undefined);
|
||||
assert.equal(resolveDesktopCallPageUrl(v, 'https://chat.lotusguild.org', '/'), undefined);
|
||||
assert.equal(resolveDesktopCallPageUrl(v, 'http://localhost', '/'), undefined);
|
||||
});
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
* app loads it from that origin instead, so the call frame can no longer
|
||||
* reach this origin's storage (login token, crypto store) or service worker.
|
||||
*
|
||||
* Web only: the desktop app keeps its bundled copy (its CSP doesn't allow
|
||||
* another frame origin, and a network copy could drift from the bundle).
|
||||
* Anything that isn't an absolute https URL (http only on localhost, for
|
||||
* Web only: the desktop app keeps its bundled copy (a network copy could
|
||||
* drift from the bundle); see resolveDesktopCallPageUrl for how it isolates
|
||||
* it. Anything that isn't an absolute https URL (http only on localhost, for
|
||||
* development) is ignored, so a bad value falls
|
||||
* back to the bundled page instead of breaking calls.
|
||||
*/
|
||||
@@ -28,6 +28,40 @@ export const resolveCallPageUrl = (value: unknown, desktop: boolean): string | u
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #43] Desktop: the bundled call page from a second origin.
|
||||
*
|
||||
* The desktop app is served by its local server at http://localhost:<port>.
|
||||
* The same server answers on http://127.0.0.1:<port>, which is a different
|
||||
* origin (and still a secure context), so loading the bundled call page from
|
||||
* there cuts the call frame off from the app's storage (login token, crypto
|
||||
* store) without a network copy that could drift from the bundle.
|
||||
*
|
||||
* Only used when cinny-desktop sets `desktopCallOrigin` (it ships the server
|
||||
* and CSP changes this needs in the same release), only for a loopback http
|
||||
* origin on the SAME port as the app, and only when the app itself runs on
|
||||
* http://localhost (release builds). Anything else keeps the same-origin page.
|
||||
*/
|
||||
export const resolveDesktopCallPageUrl = (
|
||||
value: unknown,
|
||||
appOrigin: string,
|
||||
basePath: string,
|
||||
): string | undefined => {
|
||||
if (typeof value !== 'string' || value.trim() === '') return undefined;
|
||||
try {
|
||||
const app = new URL(appOrigin);
|
||||
const call = new URL(value);
|
||||
if (app.protocol !== 'http:' || app.hostname !== 'localhost' || !app.port) return undefined;
|
||||
if (call.protocol !== 'http:' || call.hostname !== '127.0.0.1') return undefined;
|
||||
if (call.port !== app.port || call.username || call.password) return undefined;
|
||||
if (call.pathname !== '/' || call.search || call.hash) return undefined;
|
||||
const base = basePath.replace(/\/+$/, '');
|
||||
return `${call.origin}${base}/public/element-call/index.html`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
let callPageUrl: string | undefined;
|
||||
|
||||
export const setCallPageUrl = (url: string | undefined): void => {
|
||||
|
||||
@@ -75,19 +75,46 @@ export const useCallMicLevel = (callEmbed: CallEmbed | undefined): number => {
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #43] True when EC's own screenshare button is shown inside the call
|
||||
* frame (no Capability Delegation in this engine), so the host bar hides its.
|
||||
* [Gitea #43] How the call bar's screenshare button behaves:
|
||||
* - `hidden`: EC's own button is inside the frame (fork lotus.20/21), so ours hides;
|
||||
* - `prompt`: starting asks the fork for its in-frame "Share your screen?"
|
||||
* (no Capability Delegation in this engine);
|
||||
* - `direct`: our own confirm, then a (delegated) set_screenshare.
|
||||
*/
|
||||
export const useFrameScreenshare = (control: CallControl | undefined): boolean => {
|
||||
const [frame, setFrame] = useState(() => control?.frameScreenshare ?? false);
|
||||
export type ScreenshareMode = 'hidden' | 'prompt' | 'direct';
|
||||
|
||||
const screenshareMode = (control: CallControl): ScreenshareMode => {
|
||||
if (control.frameScreenshare) return 'hidden';
|
||||
return control.screenshareNeedsPrompt ? 'prompt' : 'direct';
|
||||
};
|
||||
|
||||
export const useScreenshareMode = (control: CallControl | undefined): ScreenshareMode => {
|
||||
const [mode, setMode] = useState<ScreenshareMode>(() =>
|
||||
control ? screenshareMode(control) : 'direct',
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!control) {
|
||||
setFrame(false);
|
||||
setMode('direct');
|
||||
return undefined;
|
||||
}
|
||||
const sync = () => setFrame(control.frameScreenshare);
|
||||
const sync = () => setMode(screenshareMode(control));
|
||||
sync();
|
||||
return control.onFrameScreenshareChange(sync);
|
||||
}, [control]);
|
||||
return frame;
|
||||
return mode;
|
||||
};
|
||||
|
||||
/** [Gitea #43] The fork's in-frame "Share your screen?" prompt is showing. */
|
||||
export const useScreensharePromptOpen = (control: CallControl | undefined): boolean => {
|
||||
const [open, setOpen] = useState(() => control?.screensharePromptOpen ?? false);
|
||||
useEffect(() => {
|
||||
if (!control) {
|
||||
setOpen(false);
|
||||
return undefined;
|
||||
}
|
||||
const sync = () => setOpen(control.screensharePromptOpen);
|
||||
sync();
|
||||
return control.onFrameScreenshareChange(sync);
|
||||
}, [control]);
|
||||
return open;
|
||||
};
|
||||
|
||||
@@ -122,11 +122,15 @@ const cleanHref = (href: string): string =>
|
||||
stripTrackingOnRender ? stripTrackingParams(href) : href;
|
||||
|
||||
// [Gitea #130] Links to THIS deployment's room routes render and click like
|
||||
// matrix.to links. The base is set once from the client config
|
||||
// (ClientNonUIFeatures) because this module has no access to hooks.
|
||||
let lotusPermalinkBase: string | undefined;
|
||||
export const setLotusPermalinkBase = (baseUrl: string | undefined): void => {
|
||||
lotusPermalinkBase = baseUrl;
|
||||
// matrix.to links. The bases are set once from the client config
|
||||
// (ClientNonUIFeatures) because this module has no access to hooks. [Gitea
|
||||
// #248] Several: the desktop app knows both its local origin and the public web
|
||||
// app (`webAppUrl`), and people share the public one.
|
||||
let lotusPermalinkBases: string[] = [];
|
||||
export const setLotusPermalinkBase = (baseUrls: string | string[] | undefined): void => {
|
||||
lotusPermalinkBases = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).filter(
|
||||
(b): b is string => !!b,
|
||||
);
|
||||
};
|
||||
/**
|
||||
* The matrix.to form of `href` when it is a matrix.to link or a Lotus
|
||||
@@ -134,7 +138,11 @@ export const setLotusPermalinkBase = (baseUrl: string | undefined): void => {
|
||||
*/
|
||||
export const toMatrixToHref = (href: string): string | undefined => {
|
||||
if (testMatrixTo(href)) return href;
|
||||
return lotusPermalinkBase ? lotusPermalinkToMatrixTo(lotusPermalinkBase, href) : undefined;
|
||||
for (const base of lotusPermalinkBases) {
|
||||
const matrixTo = lotusPermalinkToMatrixTo(base, href);
|
||||
if (matrixTo) return matrixTo;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const LINKIFY_OPTS: LinkifyOpts = {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { KeychainInvoke, sameTokens, syncKeychain, tokensOf } from './keychainMirror';
|
||||
import type { Session } from './sessions';
|
||||
|
||||
const session: Session = {
|
||||
baseUrl: 'https://matrix.example.org',
|
||||
userId: '@alice:example.org',
|
||||
deviceId: 'DEV1',
|
||||
accessToken: 'syt_token',
|
||||
refreshToken: 'mar_refresh',
|
||||
};
|
||||
|
||||
/** An in-memory keychain behind the same commands the desktop app exposes. */
|
||||
const fakeKeychain = (opts: { supported?: boolean; failSet?: boolean; corrupt?: boolean } = {}) => {
|
||||
let stored: unknown = null;
|
||||
const calls: string[] = [];
|
||||
const invoke: KeychainInvoke = async (cmd, args) => {
|
||||
calls.push(cmd);
|
||||
switch (cmd) {
|
||||
case 'secure_session_supported':
|
||||
return opts.supported ?? true;
|
||||
case 'secure_session_get':
|
||||
return stored;
|
||||
case 'secure_session_set':
|
||||
if (opts.failSet) throw new Error('Access is denied.');
|
||||
stored = opts.corrupt ? { ...(args?.tokens as object), accessToken: 'x' } : args?.tokens;
|
||||
return null;
|
||||
case 'secure_session_clear':
|
||||
stored = null;
|
||||
return null;
|
||||
default:
|
||||
throw new Error(`unknown ${cmd}`);
|
||||
}
|
||||
};
|
||||
return { invoke, calls, get: () => stored };
|
||||
};
|
||||
|
||||
test('stores the tokens (not the whole session) and verifies them', async () => {
|
||||
const kc = fakeKeychain();
|
||||
assert.deepEqual(await syncKeychain(kc.invoke, session), { state: 'ok' });
|
||||
assert.deepEqual(kc.get(), {
|
||||
userId: '@alice:example.org',
|
||||
deviceId: 'DEV1',
|
||||
accessToken: 'syt_token',
|
||||
refreshToken: 'mar_refresh',
|
||||
});
|
||||
assert.deepEqual(kc.calls, [
|
||||
'secure_session_supported',
|
||||
'secure_session_get',
|
||||
'secure_session_set',
|
||||
'secure_session_get',
|
||||
]);
|
||||
});
|
||||
|
||||
test('an up-to-date copy is not rewritten', async () => {
|
||||
const kc = fakeKeychain();
|
||||
await syncKeychain(kc.invoke, session);
|
||||
kc.calls.length = 0;
|
||||
assert.deepEqual(await syncKeychain(kc.invoke, session), { state: 'ok' });
|
||||
assert.deepEqual(kc.calls, ['secure_session_supported', 'secure_session_get']);
|
||||
});
|
||||
|
||||
test('a token rotation rewrites; no session clears', async () => {
|
||||
const kc = fakeKeychain();
|
||||
await syncKeychain(kc.invoke, session);
|
||||
await syncKeychain(kc.invoke, { ...session, accessToken: 'syt_new', refreshToken: undefined });
|
||||
assert.equal((kc.get() as { accessToken: string }).accessToken, 'syt_new');
|
||||
assert.equal('refreshToken' in (kc.get() as object), false);
|
||||
assert.deepEqual(await syncKeychain(kc.invoke, null), { state: 'cleared' });
|
||||
assert.equal(kc.get(), null);
|
||||
});
|
||||
|
||||
test('unsupported platform: nothing is touched', async () => {
|
||||
const kc = fakeKeychain({ supported: false });
|
||||
assert.deepEqual(await syncKeychain(kc.invoke, session), { state: 'unsupported' });
|
||||
assert.deepEqual(kc.calls, ['secure_session_supported']);
|
||||
});
|
||||
|
||||
test('failures become a status, never an exception', async () => {
|
||||
assert.deepEqual(await syncKeychain(fakeKeychain({ failSet: true }).invoke, session), {
|
||||
state: 'error',
|
||||
error: 'Access is denied.',
|
||||
});
|
||||
assert.deepEqual(await syncKeychain(fakeKeychain({ corrupt: true }).invoke, session), {
|
||||
state: 'error',
|
||||
error: 'read-back did not match',
|
||||
});
|
||||
// The credential store hangs (the support check itself is instant).
|
||||
const hung: KeychainInvoke = async (cmd) =>
|
||||
cmd === 'secure_session_supported'
|
||||
? true
|
||||
: new Promise(() => {
|
||||
/* never settles */
|
||||
});
|
||||
assert.deepEqual(await syncKeychain(hung, session, 50), {
|
||||
state: 'error',
|
||||
error: 'keychain timed out',
|
||||
});
|
||||
});
|
||||
|
||||
test('a desktop build without the commands reads as unsupported, not an error', async () => {
|
||||
const missingCommand: KeychainInvoke = async (cmd) => {
|
||||
throw new Error(`Command ${cmd} not found`);
|
||||
};
|
||||
assert.deepEqual(await syncKeychain(missingCommand, session), { state: 'unsupported' });
|
||||
});
|
||||
|
||||
test('sameTokens compares only the secrets, treating a missing refresh token as absent', () => {
|
||||
const t = tokensOf({ ...session, refreshToken: undefined });
|
||||
assert.equal(sameTokens({ ...t }, t), true);
|
||||
assert.equal(sameTokens({ ...t, refreshToken: undefined }, t), true);
|
||||
assert.equal(sameTokens({ ...t, accessToken: 'other' }, t), false);
|
||||
assert.equal(sameTokens({ ...t, refreshToken: 'r' }, t), false);
|
||||
assert.equal(sameTokens(null, t), false);
|
||||
assert.equal(sameTokens('string', t), false);
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getFallbackSession, onSessionPersisted, Session } from './sessions';
|
||||
|
||||
/**
|
||||
* [Gitea #105] Desktop, step 1: mirror the login tokens into the OS keychain.
|
||||
*
|
||||
* The session is still read from localStorage exactly as before; this only
|
||||
* keeps a copy in the keychain (Windows Credential Manager) and checks it by
|
||||
* reading it back, so real installs prove the keychain works before step 2
|
||||
* switches reads over to it. Nothing here can log anyone out: every failure
|
||||
* just records a status for Settings.
|
||||
*
|
||||
* Writes are serialized (a token rotation right after login must not race
|
||||
* the login's write) and time out, so a hung credential store can't pile up.
|
||||
* When there's no session the keychain entry is cleared, which also covers a
|
||||
* logout whose page reload beat the clear.
|
||||
*/
|
||||
|
||||
export type KeychainTokens = {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
export type KeychainMirrorStatus =
|
||||
| { state: 'idle' }
|
||||
| { state: 'unsupported' }
|
||||
| { state: 'ok' }
|
||||
| { state: 'cleared' }
|
||||
| { state: 'error'; error: string };
|
||||
|
||||
export type KeychainInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
export const KEYCHAIN_TIMEOUT_MS = 5000;
|
||||
|
||||
export const tokensOf = (session: Session): KeychainTokens => ({
|
||||
userId: session.userId,
|
||||
deviceId: session.deviceId,
|
||||
accessToken: session.accessToken,
|
||||
...(session.refreshToken ? { refreshToken: session.refreshToken } : {}),
|
||||
});
|
||||
|
||||
export const sameTokens = (a: unknown, b: KeychainTokens): boolean => {
|
||||
if (!a || typeof a !== 'object') return false;
|
||||
const t = a as Partial<KeychainTokens>;
|
||||
return (
|
||||
t.userId === b.userId &&
|
||||
t.deviceId === b.deviceId &&
|
||||
t.accessToken === b.accessToken &&
|
||||
(t.refreshToken ?? undefined) === (b.refreshToken ?? undefined)
|
||||
);
|
||||
};
|
||||
|
||||
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T> =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('keychain timed out')), ms);
|
||||
p.then(
|
||||
(v) => {
|
||||
clearTimeout(timer);
|
||||
resolve(v);
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const errorText = (e: unknown): string =>
|
||||
(e instanceof Error ? e.message : String(e)).slice(0, 200);
|
||||
|
||||
/**
|
||||
* Bring the keychain in line with `session` (null = no session). Reads first
|
||||
* and only writes when the stored copy differs, then verifies by reading back.
|
||||
*/
|
||||
export const syncKeychain = async (
|
||||
invoke: KeychainInvoke,
|
||||
session: Session | null,
|
||||
timeoutMs = KEYCHAIN_TIMEOUT_MS,
|
||||
): Promise<KeychainMirrorStatus> => {
|
||||
// A desktop build without the commands (older than this feature) rejects
|
||||
// the call: that is "not supported", not an error to show the user.
|
||||
let supported: unknown;
|
||||
try {
|
||||
supported = await withTimeout(invoke('secure_session_supported'), timeoutMs);
|
||||
} catch {
|
||||
supported = false;
|
||||
}
|
||||
if (supported !== true) return { state: 'unsupported' };
|
||||
try {
|
||||
if (!session) {
|
||||
await withTimeout(invoke('secure_session_clear'), timeoutMs);
|
||||
return { state: 'cleared' };
|
||||
}
|
||||
const tokens = tokensOf(session);
|
||||
const stored = await withTimeout(invoke('secure_session_get'), timeoutMs);
|
||||
if (sameTokens(stored, tokens)) return { state: 'ok' };
|
||||
await withTimeout(invoke('secure_session_set', { tokens }), timeoutMs);
|
||||
const back = await withTimeout(invoke('secure_session_get'), timeoutMs);
|
||||
if (!sameTokens(back, tokens)) return { state: 'error', error: 'read-back did not match' };
|
||||
return { state: 'ok' };
|
||||
} catch (e) {
|
||||
return { state: 'error', error: errorText(e) };
|
||||
}
|
||||
};
|
||||
|
||||
let status: KeychainMirrorStatus = { state: 'idle' };
|
||||
const statusListeners = new Set<(s: KeychainMirrorStatus) => void>();
|
||||
const setStatus = (next: KeychainMirrorStatus): void => {
|
||||
status = next;
|
||||
statusListeners.forEach((cb) => cb(next));
|
||||
};
|
||||
|
||||
export const getKeychainMirrorStatus = (): KeychainMirrorStatus => status;
|
||||
|
||||
let started = false;
|
||||
|
||||
/** Start mirroring (desktop only; call once at boot). */
|
||||
export const startKeychainMirror = (invoke: KeychainInvoke | undefined): void => {
|
||||
if (!invoke || started) return;
|
||||
started = true;
|
||||
let chain: Promise<unknown> = Promise.resolve();
|
||||
const enqueue = (session: Session | null) => {
|
||||
chain = chain.then(async () => setStatus(await syncKeychain(invoke, session)));
|
||||
};
|
||||
enqueue(getFallbackSession() ?? null);
|
||||
onSessionPersisted((session) => enqueue(session));
|
||||
};
|
||||
|
||||
export const useKeychainMirrorStatus = (): KeychainMirrorStatus => {
|
||||
const [value, setValue] = useState(status);
|
||||
useEffect(() => {
|
||||
setValue(status);
|
||||
statusListeners.add(setValue);
|
||||
return () => {
|
||||
statusListeners.delete(setValue);
|
||||
};
|
||||
}, []);
|
||||
return value;
|
||||
};
|
||||
@@ -26,7 +26,7 @@ const api = {
|
||||
: Object.getOwnPropertyDescriptor(target, key),
|
||||
});
|
||||
|
||||
const { clearPlaintextCaches } = await import('./plaintextCaches');
|
||||
const { clearPlaintextCaches, plaintextCachesWiped } = await import('./plaintextCaches');
|
||||
|
||||
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
|
||||
store.clear();
|
||||
@@ -36,6 +36,7 @@ test('clearPlaintextCaches removes every plaintext/PII localStorage key', () =>
|
||||
store.set('cinny_recent_forward_targets_v1', '[]');
|
||||
store.set('cinny_recent_gifs_v1', '[]');
|
||||
store.set('cinny_recent_stickers_v1', '[]');
|
||||
store.set('lotus_outbox_v1', '{"userId":"@me:server","entries":[]}');
|
||||
removed.length = 0;
|
||||
|
||||
clearPlaintextCaches();
|
||||
@@ -47,6 +48,7 @@ test('clearPlaintextCaches removes every plaintext/PII localStorage key', () =>
|
||||
'cinny_recent_forward_targets_v1',
|
||||
'cinny_recent_gifs_v1',
|
||||
'cinny_recent_stickers_v1',
|
||||
'lotus_outbox_v1', // [Gitea #112] unsent message content
|
||||
]) {
|
||||
assert.ok(removed.includes(key), `${key} cleared`);
|
||||
}
|
||||
@@ -81,3 +83,24 @@ test('clearPlaintextCaches wipes drafts (#41) and the status message (#204) but
|
||||
assert.ok(!removed.includes('cinny_session'), 'session key not this module’s concern');
|
||||
assert.ok(store.has('cinny_session'));
|
||||
});
|
||||
|
||||
test('clearPlaintextCaches removes the call-volume list and the settings-sync stamp (#347)', () => {
|
||||
store.clear();
|
||||
store.set('lotus-per-user-volume', '{"@bob:server":0.3}');
|
||||
store.set('settings-sync-meta:@me:server', '{"lastSyncedAt":1}');
|
||||
store.set('settings', '{}');
|
||||
clearPlaintextCaches('@me:server');
|
||||
assert.equal(store.has('lotus-per-user-volume'), false);
|
||||
assert.equal(store.has('settings-sync-meta:@me:server'), false);
|
||||
assert.equal(store.has('settings'), true, 'device preferences are kept');
|
||||
});
|
||||
|
||||
test('clearPlaintextCaches clears the encrypted-search opt-in and flags the wipe (#296, #319)', () => {
|
||||
store.clear();
|
||||
store.set('searchCacheEnabled', 'true');
|
||||
store.set('mxjssdk_memory_filter_FILTER_SYNC_@me:server', '0');
|
||||
clearPlaintextCaches('@me:server');
|
||||
assert.equal(store.has('searchCacheEnabled'), false);
|
||||
assert.equal(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false);
|
||||
assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads');
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import { clearRecentStickers } from './recentStickers';
|
||||
import { clearNavToActivePathStore } from './navToActivePath';
|
||||
import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft';
|
||||
import { clearCallSession } from '../utils/callRejoin';
|
||||
import { clearOutbox } from '../utils/outbox';
|
||||
import { NUDGE_KEY_PREFIX } from '../utils/securityNudge';
|
||||
|
||||
/**
|
||||
* [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). Drafts
|
||||
@@ -44,6 +46,9 @@ const clearMsgDrafts = (): void => {
|
||||
* - `cinny_recent_forward_targets_v1` — recent forward contact/room graph (PII)
|
||||
* - `cinny_recent_gifs_v1` / `cinny_recent_stickers_v1` — media the user sent
|
||||
* - `navToActivePath<userId>` — per-space last-visited room paths (needs userId)
|
||||
* - `lotus_outbox_v1` — decrypted content of messages not yet sent ([Gitea #112])
|
||||
* - `lotus-per-user-volume`, `settings-sync-meta:<userId>` — whom you adjusted in
|
||||
* calls, and a key naming the account ([Gitea #347])
|
||||
* - `draft-msg-*` — unsent composer drafts (decrypted message text, unscoped by
|
||||
* user — see [Gitea #41]; previously deliberately preserved across logout
|
||||
* (N98), which let the next account on this device see/send a prior user's
|
||||
@@ -75,7 +80,11 @@ const clearStatusMessage = (): void => {
|
||||
return;
|
||||
}
|
||||
keys.forEach((key) => {
|
||||
if (key.startsWith('lotus-status-msg-') || key.startsWith('lotus-status-expiry-')) {
|
||||
if (
|
||||
key.startsWith('lotus-status-msg-') ||
|
||||
key.startsWith('lotus-status-expiry-') ||
|
||||
key.startsWith(NUDGE_KEY_PREFIX)
|
||||
) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
@@ -85,7 +94,52 @@ const clearStatusMessage = (): void => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #347] Per-person residue that isn't message text but still says whom
|
||||
* the previous user dealt with: Element Call's per-user volume memory (same
|
||||
* origin on the web — element-call `lotusVolumeMemory.ts`) and the settings-sync
|
||||
* stamp, whose key names the account.
|
||||
*/
|
||||
export const CALL_VOLUME_KEY = 'lotus-per-user-volume';
|
||||
const clearCallVolumesAndSyncMeta = (userId?: string): void => {
|
||||
const keys = [CALL_VOLUME_KEY, ...(userId ? [`settings-sync-meta:${userId}`] : [])];
|
||||
keys.forEach((key) => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// [Gitea #319] Set once a logout has wiped the caches. Logout then reloads the
|
||||
// page, and the reload's `pagehide` made RoomInput flush the open composer's
|
||||
// draft straight back to localStorage; draft writes check this and stop.
|
||||
// Module state, so it resets with the new page.
|
||||
let wiped = false;
|
||||
export const plaintextCachesWiped = (): boolean => wiped;
|
||||
|
||||
/**
|
||||
* [Gitea #296] Device-wide flags/keys that carry the previous account's choice
|
||||
* or id: the encrypted-search opt-in (the next account would otherwise start
|
||||
* indexing decrypted text without opting in) and the SDK's per-user filter key.
|
||||
*/
|
||||
const clearAccountScopedFlags = (userId?: string): void => {
|
||||
const keys = [
|
||||
'searchCacheEnabled',
|
||||
...(userId ? [`mxjssdk_memory_filter_FILTER_SYNC_${userId}`] : []),
|
||||
];
|
||||
keys.forEach((key) => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const clearPlaintextCaches = (userId?: string): void => {
|
||||
wiped = true;
|
||||
clearTranslationCache();
|
||||
clearScheduledMessages();
|
||||
clearRecentSearches();
|
||||
@@ -93,7 +147,10 @@ export const clearPlaintextCaches = (userId?: string): void => {
|
||||
clearRecentGifs();
|
||||
clearRecentStickers();
|
||||
clearMsgDrafts();
|
||||
clearOutbox();
|
||||
clearCallSession();
|
||||
clearStatusMessage();
|
||||
clearCallVolumesAndSyncMeta(userId);
|
||||
clearAccountScopedFlags(userId);
|
||||
if (userId) clearNavToActivePathStore(userId);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
getUnreadInfo,
|
||||
getUnreadInfos,
|
||||
isNotificationEvent,
|
||||
isFromFilteredTimelineSet,
|
||||
} from '../../utils/room';
|
||||
import { roomToParentsAtom } from './roomToParents';
|
||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||
@@ -236,6 +237,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
||||
data: IRoomTimelineData,
|
||||
) => {
|
||||
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
// Single-owner rule: thread replies drive the room badge via
|
||||
// RoomEvent.UnreadNotifications below — ignore them here so the count is
|
||||
// never double-driven / mis-attributed to the main timeline.
|
||||
|
||||
@@ -21,8 +21,8 @@ const internalAtom = atomWithStorage<Record<string, ScheduledMessage[]>>(
|
||||
/**
|
||||
* Global atom: Map<roomId, ScheduledMessage[]>
|
||||
* Stores all locally-tracked scheduled messages across rooms.
|
||||
* MSC4140 has no list endpoint, so we track them ourselves.
|
||||
* Backed by localStorage so scheduled messages survive page refreshes.
|
||||
* Loaded from the server's MSC4140 `GET /delayed_events` when the tray mounts
|
||||
* (Gitea #322); localStorage is a cache for the first paint.
|
||||
*/
|
||||
export const scheduledMessagesAtom = atom(
|
||||
(get): Map<string, ScheduledMessage[]> => new Map(Object.entries(get(internalAtom))),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
/**
|
||||
* [Gitea #112] True while the client can't reach the homeserver (sync is
|
||||
* reconnecting / erroring, or the browser reports offline). Messages that
|
||||
* failed for network reasons show "Queued" instead of "Failed to send" while
|
||||
* this is set; the outbox sends them again once it clears.
|
||||
*/
|
||||
export const sendOfflineAtom = atom(false);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { atom } from 'jotai';
|
||||
import { ServerStatus } from '../utils/kumaStatus';
|
||||
|
||||
/**
|
||||
* [Gitea #124] The homeserver's status from its Kuma status page (null: none
|
||||
* configured, not fetched yet, or Kuma unreachable — all mean "no banner"),
|
||||
* plus whether this client's own connection is currently lost.
|
||||
*/
|
||||
export const serverStatusAtom = atom<{
|
||||
status: ServerStatus | null;
|
||||
syncLost: boolean;
|
||||
/** When this client's connection dropped (ms), null while connected. */
|
||||
lostAt: number | null;
|
||||
}>({
|
||||
status: null,
|
||||
syncLost: false,
|
||||
lostAt: null,
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user