Compare commits

...
16 Commits
Author SHA1 Message Date
jared 1618d71e53 Merge pull request 'fix(location): confirm before sharing, with accuracy and an approximate option (#340)' (#353) from fix/location-confirm into lotus
CI / Build & Quality Checks (push) Successful in 3m8s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 12m15s
2026-10-11 23:23:06 -04:00
jared 4736e23400 Merge pull request 'fix(reminders): a reminder stays until you've seen it (#338)' (#352) from fix/reminders-stick into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-11 23:22:56 -04:00
jared 06294f5269 Merge pull request 'fix(calls): "Channel Full" counts people, not devices (#342)' (#351) from fix/voice-full-distinct-users into lotus
CI / Build & Quality Checks (push) Canceled after 44s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-11 22:57:38 -04:00
jared fea9484a03 Merge pull request 'fix(presence): closing a tab no longer erases your custom status (#339)' (#350) from fix/presence-pagehide-status into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-11 22:57:25 -04:00
Lotus CIandClaude Opus 5.5 cf22fd8501 fix(location): confirm before sharing, with accuracy and an approximate option (#340)
CI / Build & Quality Checks (pull_request) Successful in 3m19s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m32s
The composer's location button sent the exact position (6 decimals) from a
single click once the browser permission had been granted, discarding the
fix's accuracy, so a ±3.5 km Wi-Fi/IP fix was shown as exact.

- ShareLocationDialog: "Share your location?" with the coordinates and
  ±accuracy, a "Check on OpenStreetMap" link, and Cancel / Send
  approximate (~1 km) / Send. Focus starts on Cancel; Esc cancels.
- utils/geoShare.ts (unit-tested): RFC 5870 geo URI with `;u=` accuracy;
  approximate shares round to 2 decimals and never claim better than 1 km.
- Location cards show the ± when the event carries `u=`.
- The toolbar icon is a pin instead of the globe.

Verified headless (fix 40.748817,-73.985428 ±3500 m): the click opens the
dialog and sends nothing; Cancel/Esc send nothing; Send approximate →
geo:40.75,-73.99;u=3500; Send → geo:40.748817,-73.985428;u=3500; the card
reads "40.74882, -73.98543 (±3.5 km)".

Fixes #340

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-11 22:16:05 -04:00
Lotus CIandClaude Opus 5.5 ddf9da1a55 fix(reminders): a reminder stays until you've seen it (#338)
CI / Build & Quality Checks (pull_request) Successful in 3m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m6s
ReminderMonitor pushed an ordinary toast (auto-dismissed after 4 s) and
deleted the reminder at once, with no OS notification — one that came due
while the app was in the background or the user was away was lost.

Now the reminder toast is sticky; clicking it opens the message and
dismissing it closes it, and only then is the reminder deleted (an unseen
one returns on the next start). When the window isn't focused it also
raises an OS notification (respecting "show notifications" and quiet
hours; no decrypted E2EE text, per N106).

Verified headless (notifications granted, window unfocused): due at
t+24 s, the toast stayed up for the remaining 51 s, one OS notification
"Reminder :: call the dentist before 5", the reminder stayed in
io.lotus.reminders until dismissed, then was removed.

Fixes #338

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-11 22:11:31 -04:00
Lotus CIandClaude Opus 5.5 861293243c fix(calls): "Channel Full" counts people, not devices (#342)
CI / Build & Quality Checks (pull_request) Successful in 3m9s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m44s
useVoiceChannelFull compared the number of MatrixRTC memberships (one per
device) with io.lotus.voice_limit, so one person on desktop + phone, or a
crashed tab whose membership hadn't expired, filled two slots and locked
others out, while the server-side guard (which counts distinct users)
would have admitted them. utils/voiceChannelFull.ts (unit-tested) counts
distinct senders; the header/sidebar counter shows people too.

Verified on the local stack: limit 2, @aud_main in the call on two
devices; @aud_bob's prescreen now shows 1/2 with Join enabled (was
"Channel Full (2/2)"), matching the guard's 200.

Fixes #342

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-11 22:07:18 -04:00
Lotus CIandClaude Opus 5.5 e50bd677d6 fix(presence): closing a tab no longer erases your custom status (#339)
CI / Build & Quality Checks (pull_request) Successful in 3m16s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 15s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m20s
The pagehide keepalive write sent { presence: 'offline' } with no
status_msg, which Synapse treats as clearing it, so closing or reloading
any Lotus tab wiped the user's status for everyone (intermittently, as
the 10 s presence rate limit sometimes 429'd the write). It now sends the
current status along with the offline state.

Verified against the local Synapse: with a status set from another
device, two reloads and a tab close keep it (server shows
offline/"Gone fishing" after the close; before, the first reload wiped it).

Fixes #339

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-11 22:04:57 -04:00
jared 022e5e6987 Merge pull request 'fix(embeds): GIF previews keep their own size and shape (#341)' (#344) from fix/gif-card-aspect into lotus
CI / Build & Quality Checks (push) Successful in 3m6s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 11m45s
2026-10-11 00:53:25 -04:00
jared 5c765925d2 Merge pull request 'deps: fix the npm audit advisories that turned CI red (#345)' (#346) from deps/npm-audit-fixes into lotus
CI / Build & Quality Checks (push) Canceled after 13s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-11 00:53:12 -04:00
Lotus CIandClaude Opus 5.5 747714b2f3 Merge branch 'deps/npm-audit-fixes' into fix/gif-card-aspect
CI / Build & Quality Checks (pull_request) Successful in 3m0s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m31s
Brings in #346 (npm audit fixes) so this PR's CI audit step passes; the
shared commit drops out of this PR's diff once #346 is merged into lotus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-10-10 22:45:04 -04:00
Lotus CIandClaude Opus 5.5 c9d276d2a5 deps: fix the npm audit advisories that turned CI red (#345)
CI / Build & Quality Checks (pull_request) Successful in 2m58s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m35s
`npm audit --audit-level=high --omit=dev` (Build & Quality Checks) started
failing on every branch after new advisories were published:

- source-map-js 1.2.1 → 1.2.2 (high, GHSA-68fv-2mgg-jv7q; transitive via
  sanitize-html → postcss, in range — lockfile only)
- i18next-http-backend 4.0.0 → 4.0.2 (low, GHSA-xvq9-wjp8-hwqf)
- katex 0.16.47 → 0.18.11 (low, GHSA-238p-pmpm-9mq7; fixed in 0.18.2).
  0.17/0.18's breaking changes are an internal __defineFunction API and
  prefixed internal CSS classes; Lotus uses neither (KaTeX.tsx calls
  renderToString and imports the package's own stylesheet).

Only these four lockfile entries change (plus katex's own CLI dependency
commander 8 → 15). npm audit --omit=dev: 0 vulnerabilities.

Verified headless against Vite with a fresh dep cache (katex 0.18.11
served): inline and display math render (\frac, \sqrt, \sum with limits),
KaTeX fonts load, no page errors; translations still load (en strings,
no raw keys). tsc clean, 1319 unit tests pass, eslint 0 errors (36
warnings, unchanged), prettier clean, production build OK.

Fixes #345

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-10-10 22:28:30 -04:00
Lotus CIandClaude Opus 5.5 68f44f0f1c fix(embeds): GIF previews keep their own size and shape (#341)
CI / Build & Quality Checks (pull_request) Failing after 3m21s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
Giphy and Tenor link previews forced every GIF into the same box — full
card width, max 200px tall, object-fit: cover — whatever its shape: a
square GIF lost half its height, a portrait one three quarters (only the
middle band showed, so captions and faces at the top/bottom were cut), and
a 100×80 GIF was blown up 4×.

utils/gifPreviewSize.ts (unit-tested) fits the GIF inside 400×320 from
og:image:width/height, keeping its aspect ratio and enlarging small GIFs
at most 2×. The card centres the GIF on the surface colour with
object-fit: contain, reserves the box before it loads, and the GIF badge
sits on the GIF's corner. Without dimensions it shows at natural size,
capped by the card. The >10 MB thumbnail fallback is requested at 2× the
box (800×640) so it isn't upscaled.

Verified headless (fixture previews, desktop / Pixel 7 / 320px phone):
480×200 → 398×166, 300×300 → 320×320 (292×292 on 320px), 240×480 →
160×320, 100×80 → 200×160, all 100% visible; the no-dimension fallback
and narrow cards keep the ratio. tsc clean, 1325 unit tests pass, eslint
0 errors (36 warnings, unchanged), prettier clean.

Fixes #341

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-10-10 22:15:56 -04:00
jared 30e2dbcde9 Remove stray config backup; ignore *.bak
CI / Build & Quality Checks (push) Successful in 2m9s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 16s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 11m33s
2026-10-03 00:16:58 -04:00
jared d1993f2084 Merge pull request 'Device-security nudge: verify this device / key backup (#110, #123)' (#258) from security-nudge into lotus
CI / Build & Quality Checks (push) Successful in 1m44s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 2s
CI / Playwright smoke (e2e) (push) Successful in 11m5s
Merge pull request #258
2026-09-30 21:03:23 -04:00
Lotus CIandClaude Opus 5.5 6a7627fa26 feat: device-security nudge — verify this device / key backup (#110, #123)
CI / Build & Quality Checks (pull_request) Successful in 1m48s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m38s
One "security" strip, in the same top slot and style as the status banner
(#124), for this device, in priority order:
- "Verify this device" — cross-signing exists but this device isn't
  verified (can't unlock backed-up history, untrusted to others). First,
  because verifying with the recovery key also connects the backup.
- "Connect this device to your key backup" — a backup exists, this device
  isn't using it.
- "Set up key backup" — no backup at all (includes accounts without
  cross-signing; the setup flow does both).
The button opens Settings → Devices (existing flows); "Not now" snoozes.

Rules: nothing in a device's first 24 h; "Not now" snoozes that nudge 7
days; 3 dismissals stop it; never on a healthy device; waits until sync
has settled (never under "Connecting…"); outage/maintenance/connection
strips win. Per-device record in localStorage, wiped on logout. Mounted
inside the Matrix client context and an error boundary (an early version
outside the context crashed the app on load — caught before pushing).

Also: the status strip wraps its text on phones instead of truncating it
when there's no Details button (benefits #124's strips too).

Design approved on #123 (real-client screenshots there). Tests: 4 unit
(the #123 state table, loading, timing, stored record); e2e: nothing during
the grace period, "Set up key backup" → Settings → Devices, "Not now"
holds across a reload. Unit 1319, Playwright 29 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-30 20:28:32 -04:00
25 changed files with 946 additions and 89 deletions
-17
View File
@@ -1,17 +0,0 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
"basename": "/"
}
}
+3
View File
@@ -14,3 +14,6 @@ test-results/
# local dev homeserver (scripts/dev-homeserver.sh) # local dev homeserver (scripts/dev-homeserver.sh)
.dev-homeserver/ .dev-homeserver/
__pycache__/ __pycache__/
# Local config backups
*.bak
+74
View File
@@ -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);
});
});
+18 -16
View File
@@ -45,12 +45,12 @@
"html-react-parser": "6.1.2", "html-react-parser": "6.1.2",
"i18next": "26.2.0", "i18next": "26.2.0",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"i18next-http-backend": "4.0.0", "i18next-http-backend": "4.0.2",
"immer": "11.1.8", "immer": "11.1.8",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.20.0", "jotai": "2.20.0",
"jsqr": "1.4.0", "jsqr": "1.4.0",
"katex": "0.16.47", "katex": "0.18.11",
"linkify-react": "4.3.3", "linkify-react": "4.3.3",
"linkifyjs": "4.3.3", "linkifyjs": "4.3.3",
"matrix-js-sdk": "41.7.0", "matrix-js-sdk": "41.7.0",
@@ -8168,9 +8168,9 @@
} }
}, },
"node_modules/i18next-http-backend": { "node_modules/i18next-http-backend": {
"version": "4.0.0", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.0.tgz", "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.2.tgz",
"integrity": "sha512-EgSjO3Q1G6f2Q5oy7u9mmxuesE0oSfzAD97NFBjC8EmkK4guBSYLljM0Fng3DarMWIIkU70jfo4+mUzmyVISTA==", "integrity": "sha512-oay62dIB2kL7+WHzoUXBjWfL3+mwijD3pQkQkIEaRLhy6kjXxUhrRenV0gInwlCASAaJaDy94+XvYizK+cAGdA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -9162,28 +9162,29 @@
} }
}, },
"node_modules/katex": { "node_modules/katex": {
"version": "0.16.47", "version": "0.18.11",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.11.tgz",
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", "integrity": "sha512-yvyz/2VMKqoho/35Hat5w37wIEdWJ5ElVMidTAlWrxr5uW4aBGsEpveTmKh18/zw08v/7lGswCPon4OhqmqrTA==",
"deprecated": "Accidentally published with breaking changes. Use 0.19.0 instead.",
"funding": [ "funding": [
"https://opencollective.com/katex", "https://opencollective.com/katex",
"https://github.com/sponsors/katex" "https://github.com/sponsors/katex"
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"commander": "^8.3.0" "commander": "^15.0.0"
}, },
"bin": { "bin": {
"katex": "cli.js" "katex": "cli.js"
} }
}, },
"node_modules/katex/node_modules/commander": { "node_modules/katex/node_modules/commander": {
"version": "8.3.0", "version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 12" "node": ">=22.12.0"
} }
}, },
"node_modules/keyv": { "node_modules/keyv": {
@@ -12164,9 +12165,10 @@
} }
}, },
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
+2 -2
View File
@@ -72,12 +72,12 @@
"html-react-parser": "6.1.2", "html-react-parser": "6.1.2",
"i18next": "26.2.0", "i18next": "26.2.0",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"i18next-http-backend": "4.0.0", "i18next-http-backend": "4.0.2",
"immer": "11.1.8", "immer": "11.1.8",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.20.0", "jotai": "2.20.0",
"jsqr": "1.4.0", "jsqr": "1.4.0",
"katex": "0.16.47", "katex": "0.18.11",
"linkify-react": "4.3.3", "linkify-react": "4.3.3",
"linkifyjs": "4.3.3", "linkifyjs": "4.3.3",
"matrix-js-sdk": "41.7.0", "matrix-js-sdk": "41.7.0",
@@ -37,6 +37,7 @@ import {
} from '../../../types/matrix/common'; } from '../../../types/matrix/common';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes'; import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common'; import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { formatAccuracy } from '../../utils/geoShare';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment'; import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader'; import { FileHeader, FileDownloadButton } from './FileHeader';
@@ -732,6 +733,8 @@ export function MLocation({ content }: MLocationProps) {
const lon = parseFloat(location.longitude); const lon = parseFloat(location.longitude);
if (!isFinite(lat) || !isFinite(lon)) return <BrokenContent />; if (!isFinite(lat) || !isFinite(lon)) return <BrokenContent />;
const description = typeof msc3488?.description === 'string' ? msc3488.description : undefined; 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},${ const mapSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.007},${
lat - 0.004 lat - 0.004
},${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`; },${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`;
@@ -772,6 +775,7 @@ export function MLocation({ content }: MLocationProps) {
)} )}
<Text size="T300" priority="300"> <Text size="T300" priority="300">
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`} {`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
{accuracyLabel && ` (${accuracyLabel})`}
</Text> </Text>
<Button <Button
as="a" as="a"
@@ -1,5 +1,6 @@
import { keyframes, style } from '@vanilla-extract/css'; import { keyframes, style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds'; import { DefaultReset, color, config, toRem } from 'folds';
import { GIF_PREVIEW_MAX_HEIGHT } from '../../utils/gifPreviewSize';
export const UrlPreview = style([ export const UrlPreview = style([
DefaultReset, DefaultReset,
@@ -955,12 +956,14 @@ export const PortraitSideLayout = style([
// GIF card (Giphy / Tenor) // 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([ export const GifThumbnailWrapper = style([
DefaultReset, DefaultReset,
{ {
position: 'relative', display: 'flex',
justifyContent: 'center',
width: '100%', width: '100%',
maxHeight: toRem(200),
overflow: 'hidden', overflow: 'hidden',
flexShrink: 0, flexShrink: 0,
backgroundColor: color.Surface.Container, 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([ export const GifThumbnailImg = style([
DefaultReset, DefaultReset,
{ {
width: '100%',
maxHeight: toRem(200),
objectFit: 'cover',
objectPosition: 'center',
display: 'block', display: 'block',
maxWidth: '100%',
maxHeight: toRem(GIF_PREVIEW_MAX_HEIGHT),
height: 'auto',
objectFit: 'contain',
}, },
]); ]);
@@ -13,6 +13,7 @@ import {
as, as,
color, color,
config, config,
toRem,
} from 'folds'; } from 'folds';
import { ImageOverlay } from '../ImageOverlay'; import { ImageOverlay } from '../ImageOverlay';
import { MobileTouchTarget } from '../../styles/mobile.css'; import { MobileTouchTarget } from '../../styles/mobile.css';
@@ -48,6 +49,11 @@ import {
tiktokOembedUrl, tiktokOembedUrl,
tiktokPlayerEmbedUrl, tiktokPlayerEmbedUrl,
} from '../../utils/videoEmbed'; } from '../../utils/videoEmbed';
import {
GIF_PREVIEW_MAX_HEIGHT,
GIF_PREVIEW_MAX_WIDTH,
gifPreviewBox,
} from '../../utils/gifPreviewSize';
const linkStyles = { color: color.Success.Main }; const linkStyles = { color: color.Success.Main };
@@ -2277,6 +2283,9 @@ function GifCard({
}) { }) {
const title = (prev['og:title'] as string | undefined) ?? ''; const title = (prev['og:title'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] 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 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. // a thumbnail — the thumbnail endpoint would return a frozen first frame.
@@ -2284,7 +2293,15 @@ function GifCard({
const thumbSrc = mxcImage const thumbSrc = mxcImage
? shouldServeGifOriginal(url, prev) ? shouldServeGifOriginal(url, prev)
? mxcUrlToHttp(mx, mxcImage, useAuthentication) ? 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; : null;
// If there's no image, fall back to a generic-style layout // If there's no image, fall back to a generic-style layout
@@ -2316,7 +2333,9 @@ function GifCard({
return ( return (
<Box direction="Column" style={{ width: '100%' }}> <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 <a
href={url} href={url}
target="_blank" target="_blank"
@@ -2324,8 +2343,22 @@ function GifCard({
className={previewCss.GifThumbnailWrapper} className={previewCss.GifThumbnailWrapper}
aria-label={`View GIF on ${siteBadgeLabel}: ${title}`} aria-label={`View GIF on ${siteBadgeLabel}: ${title}`}
> >
<img className={previewCss.GifThumbnailImg} src={thumbSrc} alt={title} loading="lazy" /> <span className={previewCss.GifFrame}>
<span className={previewCss.GifBadge}>GIF</span> <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> </a>
{/* Footer row */} {/* Footer row */}
<UrlPreviewContent> <UrlPreviewContent>
+40 -21
View File
@@ -153,6 +153,9 @@ import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationS
import { ScheduleMessageModal } from './ScheduleMessageModal'; import { ScheduleMessageModal } from './ScheduleMessageModal';
import { ScheduledMessagesTray } from './ScheduledMessagesTray'; import { ScheduledMessagesTray } from './ScheduledMessagesTray';
import { DraftIndicator } from './DraftIndicator'; 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 { scheduledMessagesAtom } from '../../state/scheduledMessages';
import { createErrorToast, toastQueueAtom } from '../../state/toast'; import { createErrorToast, toastQueueAtom } from '../../state/toast';
import { getThreadDraftKey } from '../../state/room/thread'; import { getThreadDraftKey } from '../../state/room/thread';
@@ -356,6 +359,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order), () => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order], [composerToolbarButtons?.order],
); );
const displayRoomName = useLocalRoomName(room);
const [locating, setLocating] = React.useState(false); const [locating, setLocating] = React.useState(false);
const [locationError, setLocationError] = React.useState<string | null>(null); const [locationError, setLocationError] = React.useState<string | null>(null);
const handleShareLocation = useCallback(() => { const handleShareLocation = useCallback(() => {
@@ -368,21 +372,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(pos) => { (pos) => {
setLocating(false); setLocating(false);
const { latitude, longitude } = pos.coords; // [Gitea #340] Don't send straight away: confirm first (ShareLocationDialog).
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`; const { latitude, longitude, accuracy } = pos.coords;
const ts = Date.now(); setPendingLocation({ latitude, longitude, accuracy });
// 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,
});
}, },
(err) => { (err) => {
setLocating(false); setLocating(false);
@@ -397,7 +389,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}, },
{ timeout: 10000 }, { 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( const handleVoiceSend = useCallback(
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => { async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
@@ -1538,11 +1553,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
style={touchTarget} style={touchTarget}
className={MobileTouchTarget} className={MobileTouchTarget}
> >
{locating ? ( {locating ? <Spinner variant="Secondary" size="400" /> : <Icon src={Icons.Pin} />}
<Spinner variant="Secondary" size="400" />
) : (
<Icon src={Icons.SpaceGlobe} />
)}
</IconButton> </IconButton>
) : null; ) : null;
@@ -1751,6 +1762,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onClose={() => setPollOpen(false)} onClose={() => setPollOpen(false)}
/> />
)} )}
{pendingLocation && (
<ShareLocationDialog
location={pendingLocation}
roomName={displayRoomName}
onSend={handleSendLocation}
requestClose={() => setPendingLocation(null)}
/>
)}
{scheduleOpen && ( {scheduleOpen && (
<ScheduleMessageModal <ScheduleMessageModal
roomId={roomId} roomId={roomId}
@@ -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&apos;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>
);
}
@@ -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>
);
}
@@ -22,7 +22,7 @@ const readDismissed = (): string[] => {
} }
}; };
const bannerIcon = (b: StatusBanner): IconSrc => { const bannerIcon = (b: StripContent): IconSrc => {
if (b.kind === 'maintenance') return Icons.Setting; if (b.kind === 'maintenance') return Icons.Setting;
if (b.tone === 'Primary') return Icons.Info; if (b.tone === 'Primary') return Icons.Info;
return Icons.Warning; return Icons.Warning;
@@ -32,12 +32,28 @@ const bannerIcon = (b: StatusBanner): IconSrc => {
* [Gitea #124] One status strip, in the same slot and style as the sync and * [Gitea #124] One status strip, in the same slot and style as the sync and
* clock banners. Presentational: the caller picks the banner. * clock banners. Presentational: the caller picks the banner.
*/ */
export type StripContent = Pick<
StatusBanner,
'key' | 'tone' | 'text' | 'detail' | 'dismissable'
> & {
kind?: StatusBanner['kind'];
};
export function StatusStrip({ export function StatusStrip({
banner, banner,
onDismiss, onDismiss,
icon,
action,
dismissLabel,
}: { }: {
banner: StatusBanner; banner: StripContent;
onDismiss?: () => void; 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 [open, setOpen] = useState(false);
const mobile = useScreenSizeContext() === ScreenSize.Mobile; const mobile = useScreenSizeContext() === ScreenSize.Mobile;
@@ -59,8 +75,8 @@ export function StatusStrip({
gap="200" gap="200"
style={{ minHeight: config.size.X300 }} style={{ minHeight: config.size.X300 }}
> >
<Icon size="100" src={bannerIcon(banner)} /> <Icon size="100" src={icon ?? bannerIcon(banner)} />
<Text size="L400" truncate={mobile && !open} style={{ minWidth: 0 }}> <Text size="L400" truncate={mobile && !!banner.detail && !open} style={{ minWidth: 0 }}>
{banner.text} {banner.text}
</Text> </Text>
{banner.detail && ( {banner.detail && (
@@ -78,7 +94,31 @@ export function StatusStrip({
</Text> </Text>
</Button> </Button>
)} )}
{banner.dismissable && onDismiss && ( {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 <IconButton
size="300" size="300"
variant={banner.tone} variant={banner.tone}
+5 -1
View File
@@ -167,13 +167,17 @@ export function usePresenceUpdater() {
// it isn't usable here. fetch(..., { keepalive: true }) lets the request outlive the // 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 // page and is the correct mechanism for an authed endpoint. (keepalive bodies are
// capped at 64KB, which this tiny payload is well under.) // 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`, { fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ presence: 'offline' }), body: JSON.stringify({ presence: 'offline', ...(status ? { status_msg: status } : {}) }),
keepalive: true, keepalive: true,
}).catch((err) => warnPresenceFailure('offline (pagehide)', err)); }).catch((err) => warnPresenceFailure('offline (pagehide)', err));
}; };
+7 -4
View File
@@ -5,6 +5,7 @@ import { StateEvent } from '../../types/matrix/room';
import { VoiceLimitContent } from '../features/common-settings/general/RoomVoiceLimit'; import { VoiceLimitContent } from '../features/common-settings/general/RoomVoiceLimit';
import { useCallMembers, useCallSession } from './useCall'; import { useCallMembers, useCallSession } from './useCall';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { voiceChannelOccupancy } from '../utils/voiceChannelFull';
export type VoiceChannelFull = { export type VoiceChannelFull = {
channelFull: boolean; channelFull: boolean;
@@ -26,9 +27,11 @@ export const useVoiceChannelFull = (room: Room): VoiceChannelFull => {
return useMemo(() => { return useMemo(() => {
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0; const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
// A user already counted in the session is rejoining and should not be blocked. // Distinct people, not per-device memberships (Gitea #342).
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId()); return voiceChannelOccupancy(
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers; callMembers.map((m) => m.sender),
return { channelFull, current: callMembers.length, max: maxUsers }; mx.getSafeUserId(),
maxUsers,
);
}, [limitEvent, callMembers, mx]); }, [limitEvent, callMembers, mx]);
}; };
+48 -12
View File
@@ -783,15 +783,20 @@ function ReminderMonitor() {
const { reminders, removeReminder } = useReminders(); const { reminders, removeReminder } = useReminders();
const setToast = useSetAtom(toastQueueAtom); const setToast = useSetAtom(toastQueueAtom);
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const quietActive = useNotificationsQuiet();
const firedRef = useRef<Set<string>>(new Set()); const firedRef = useRef<Set<string>>(new Set());
const removingRef = 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 // Read the latest reminders / DM map / notification prefs via refs so the poll
// created once — not torn down and restarted (which resets its 30s countdown // interval below is created once — not torn down and restarted (which resets
// and can indefinitely defer a near-due reminder) on every reminder sync (N115). // its 30s countdown and can indefinitely defer a near-due reminder) on every
// reminder sync (N115).
const remindersRef = useRef(reminders); const remindersRef = useRef(reminders);
remindersRef.current = reminders; remindersRef.current = reminders;
const mDirectsRef = useRef(mDirects); const mDirectsRef = useRef(mDirects);
mDirectsRef.current = mDirects; mDirectsRef.current = mDirects;
const osNotifyRef = useRef(false);
osNotifyRef.current = showNotifications && !quietActive;
useEffect(() => { useEffect(() => {
const check = () => { const check = () => {
@@ -815,6 +820,23 @@ function ReminderMonitor() {
const hashPath = mDirectsRef.current.has(r.roomId) const hashPath = mDirectsRef.current.has(r.roomId)
? getDirectRoomPath(r.roomId, r.eventId) ? getDirectRoomPath(r.roomId, r.eventId)
: getHomeRoomPath(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({ setToast({
id: `reminder-${key}`, id: `reminder-${key}`,
displayName: 'Reminder', displayName: 'Reminder',
@@ -822,16 +844,30 @@ function ReminderMonitor() {
roomName, roomName,
roomId: r.roomId, roomId: r.roomId,
hashPath, hashPath,
sticky: true,
onClick: open,
onDismiss: done,
}); });
} // Away from the window: also raise an OS notification (the reminder is
// Persist the removal, retrying on a later tick if it fails — without // an explicit request, but quiet hours / notifications-off win). Never
// re-showing the toast (N114). The server echo drops it from // put decrypted E2EE text in it (N106).
// `reminders` once the write lands. if (osNotifyRef.current && notificationPermission('granted') && !document.hasFocus()) {
if (!removingRef.current.has(key)) { const encrypted = !!room?.hasEncryptionStateEvent();
removingRef.current.add(key); showOsNotification(
removeReminder(r.eventId, r.timestamp).catch(() => { 'Reminder',
removingRef.current.delete(key); {
}); 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);
}
} }
}); });
}; };
+3
View File
@@ -48,6 +48,7 @@ import {
ServerStatusBanner, ServerStatusBanner,
ServerStatusFeature, ServerStatusFeature,
} from '../../features/server-status/ServerStatusBanner'; } from '../../features/server-status/ServerStatusBanner';
import { SecurityBanner } from '../../features/security-nudge/SecurityBanner';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession, removeFallbackSession } from '../../state/sessions'; import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { pushSessionToSW } from '../../../sw-session'; import { pushSessionToSW } from '../../../sw-session';
@@ -313,6 +314,8 @@ export function ClientRoot({ children }: ClientRootProps) {
<ClientRootLoading /> <ClientRootLoading />
) : ( ) : (
<MatrixClientProvider value={mx}> <MatrixClientProvider value={mx}>
{/* [Gitea #110/#123] Needs the client context (its hooks read it). */}
{!syncError && <SecurityBanner />}
<ServerConfigsLoader> <ServerConfigsLoader>
{(serverConfigs) => ( {(serverConfigs) => (
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}> <CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
+6 -1
View File
@@ -8,6 +8,7 @@ import { clearNavToActivePathStore } from './navToActivePath';
import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft'; import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft';
import { clearCallSession } from '../utils/callRejoin'; import { clearCallSession } from '../utils/callRejoin';
import { clearOutbox } from '../utils/outbox'; import { clearOutbox } from '../utils/outbox';
import { NUDGE_KEY_PREFIX } from '../utils/securityNudge';
/** /**
* [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). Drafts * [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). Drafts
@@ -77,7 +78,11 @@ const clearStatusMessage = (): void => {
return; return;
} }
keys.forEach((key) => { 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 { try {
localStorage.removeItem(key); localStorage.removeItem(key);
} catch { } catch {
+30
View File
@@ -0,0 +1,30 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildGeoUri, formatAccuracy } from './geoShare';
import { parseGeoUri } from './common';
test('buildGeoUri: exact share keeps 6 decimals and adds the accuracy as u=', () => {
assert.equal(buildGeoUri(40.748817, -73.985428, 12.4), 'geo:40.748817,-73.985428;u=12');
assert.equal(buildGeoUri(40.748817, -73.985428), 'geo:40.748817,-73.985428');
assert.equal(buildGeoUri(40.748817, -73.985428, Number.NaN), 'geo:40.748817,-73.985428');
});
test('buildGeoUri: approximate share rounds to ~1 km and never claims better than 1 km', () => {
assert.equal(buildGeoUri(40.748817, -73.985428, 12, true), 'geo:40.75,-73.99;u=1000');
assert.equal(buildGeoUri(40.748817, -73.985428, 3500, true), 'geo:40.75,-73.99;u=3500');
});
test('buildGeoUri output still parses with the renderer', () => {
const loc = parseGeoUri(buildGeoUri(51.5, -0.12, 30));
assert.ok(loc);
assert.equal(parseFloat(loc.latitude), 51.5);
assert.equal(parseFloat(loc.longitude), -0.12);
});
test('formatAccuracy', () => {
assert.equal(formatAccuracy(12.4), '±12 m');
assert.equal(formatAccuracy(3500), '±3.5 km');
assert.equal(formatAccuracy(25000), '±25 km');
assert.equal(formatAccuracy(undefined), undefined);
assert.equal(formatAccuracy(0), undefined);
});
+39
View File
@@ -0,0 +1,39 @@
/**
* [Gitea #340] Helpers for the "Share location" confirm step: the geo URI to
* send (RFC 5870, with the fix's accuracy as `u=` so receivers can draw a
* radius) and a human accuracy label, so a ±3 km Wi-Fi/IP fix isn't presented
* — or sent — as if it were exact.
*/
/** Decimals kept for an "approximate" share: 2 → ~1.1 km of latitude. */
export const APPROXIMATE_DECIMALS = 2;
/** Uncertainty reported for an approximate share (half a 0.01° cell, rounded up). */
export const APPROXIMATE_UNCERTAINTY_M = 1000;
const validAccuracy = (accuracy?: number): number | undefined =>
typeof accuracy === 'number' && Number.isFinite(accuracy) && accuracy > 0
? Math.round(accuracy)
: undefined;
export const buildGeoUri = (
latitude: number,
longitude: number,
accuracy?: number,
approximate = false,
): string => {
const decimals = approximate ? APPROXIMATE_DECIMALS : 6;
const u = approximate
? Math.max(APPROXIMATE_UNCERTAINTY_M, validAccuracy(accuracy) ?? 0)
: validAccuracy(accuracy);
const base = `geo:${latitude.toFixed(decimals)},${longitude.toFixed(decimals)}`;
return u ? `${base};u=${u}` : base;
};
/** "±12 m", "±3.5 km", or undefined when the browser gave no accuracy. */
export const formatAccuracy = (accuracy?: number): string | undefined => {
const m = validAccuracy(accuracy);
if (m === undefined) return undefined;
if (m < 1000) return `±${m} m`;
const km = m / 1000;
return `±${km < 10 ? km.toFixed(1) : Math.round(km)} km`;
};
+36
View File
@@ -0,0 +1,36 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { gifPreviewBox } from './gifPreviewSize';
test('gifPreviewBox: a landscape GIF wider than the card fills its width', () => {
assert.deepEqual(gifPreviewBox(480, 270), { width: 400, height: 225 });
assert.deepEqual(gifPreviewBox(480, 200), { width: 400, height: 167 });
});
test('gifPreviewBox: square and portrait GIFs keep their shape instead of being cropped', () => {
assert.deepEqual(gifPreviewBox(300, 300), { width: 320, height: 320 });
assert.deepEqual(gifPreviewBox(480, 480), { width: 320, height: 320 });
assert.deepEqual(gifPreviewBox(240, 480), { width: 160, height: 320 });
});
test('gifPreviewBox: a small GIF is enlarged at most 2×', () => {
assert.deepEqual(gifPreviewBox(100, 80), { width: 200, height: 160 });
assert.deepEqual(gifPreviewBox(150, 150), { width: 300, height: 300 });
});
test('gifPreviewBox: extreme aspect ratios stay inside the box and never collapse to 0', () => {
assert.deepEqual(gifPreviewBox(2000, 50), { width: 400, height: 10 });
assert.deepEqual(gifPreviewBox(1, 10000), { width: 1, height: 320 });
});
test('gifPreviewBox: accepts numeric strings and rejects unknown sizes', () => {
assert.deepEqual(gifPreviewBox('480', '270'), { width: 400, height: 225 });
assert.equal(gifPreviewBox(undefined, 270), undefined);
assert.equal(gifPreviewBox(480, 0), undefined);
assert.equal(gifPreviewBox('wide', 270), undefined);
assert.equal(gifPreviewBox(Number.NaN, 270), undefined);
});
test('gifPreviewBox: custom limits', () => {
assert.deepEqual(gifPreviewBox(480, 270, 200, 200, 1), { width: 200, height: 113 });
});
+39
View File
@@ -0,0 +1,39 @@
/**
* Display size for a Giphy / Tenor link-preview GIF. The card used to force
* every GIF into a full-width × 200px band with `object-fit: cover`, so square
* and portrait GIFs lost most of their height and small ones were blown up.
* Fit the GIF inside `maxWidth × maxHeight` keeping its aspect ratio, and
* enlarge a small GIF at most `maxUpscale`× so it stays sharp.
*/
/** The GIF card's width (UrlPreview is 25rem); a phone shrinks it further via CSS. */
export const GIF_PREVIEW_MAX_WIDTH = 400;
export const GIF_PREVIEW_MAX_HEIGHT = 320;
export const GIF_PREVIEW_MAX_UPSCALE = 2;
export type GifPreviewBox = { width: number; height: number };
// og:image:width / og:image:height are numbers from Synapse, but some sites
// publish them as strings; anything else (missing, 0, NaN) means "unknown".
const dimension = (value: unknown): number | undefined => {
const n = typeof value === 'string' ? Number(value) : value;
return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : undefined;
};
/** The box to render the GIF in, or `undefined` when its size isn't known. */
export const gifPreviewBox = (
rawWidth: unknown,
rawHeight: unknown,
maxWidth = GIF_PREVIEW_MAX_WIDTH,
maxHeight = GIF_PREVIEW_MAX_HEIGHT,
maxUpscale = GIF_PREVIEW_MAX_UPSCALE,
): GifPreviewBox | undefined => {
const width = dimension(rawWidth);
const height = dimension(rawHeight);
if (!width || !height) return undefined;
const scale = Math.min(maxWidth / width, maxHeight / height, maxUpscale);
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
};
};
+81
View File
@@ -0,0 +1,81 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
dismissNudge,
NUDGE_GRACE_MS,
NUDGE_SNOOZE_MS,
nudgeFor,
parseNudgeRecord,
SecurityState,
shouldShowNudge,
} from './securityNudge';
const healthy: SecurityState = {
crossSigning: true,
deviceVerified: true,
backupOnServer: true,
backupActive: true,
};
test('the #123 table: which nudge for which state', () => {
// E: healthy → nothing, ever.
assert.equal(nudgeFor(healthy), undefined);
// B: cross-signing, this device unverified, backup on server → verify first.
assert.equal(nudgeFor({ ...healthy, deviceVerified: false }), 'verify-device');
// C: cross-signing, unverified, no backup → still verify first.
assert.equal(
nudgeFor({ ...healthy, deviceVerified: false, backupOnServer: false }),
'verify-device',
);
// D: verified, no backup → #110's set-up nudge.
assert.equal(nudgeFor({ ...healthy, backupOnServer: false }), 'setup-backup');
// #110 state 2: backup exists, this device not using it.
assert.equal(nudgeFor({ ...healthy, backupActive: false }), 'connect-backup');
// A: never set up cross-signing, no backup → set up (the flow does both).
assert.equal(
nudgeFor({
crossSigning: false,
deviceVerified: false,
backupOnServer: false,
backupActive: false,
}),
'setup-backup',
);
});
test('nothing while crypto is still loading (F)', () => {
assert.equal(nudgeFor({ ...healthy, deviceVerified: undefined }), undefined);
assert.equal(nudgeFor({ ...healthy, backupOnServer: undefined }), undefined);
assert.equal(nudgeFor({ ...healthy, backupActive: undefined }), undefined);
// Unknown backup connection doesn't hide a known "no backup at all".
assert.equal(
nudgeFor({ ...healthy, backupOnServer: false, backupActive: undefined }),
'setup-backup',
);
});
test('timing: 24 h grace, 7-day snooze, stop after 3 dismissals', () => {
const t0 = 1_700_000_000_000;
let record = parseNudgeRecord(null, t0);
assert.equal(shouldShowNudge('verify-device', record, t0), false, 'first launch');
assert.equal(shouldShowNudge('verify-device', record, t0 + NUDGE_GRACE_MS - 1), false);
let now = t0 + NUDGE_GRACE_MS;
assert.equal(shouldShowNudge('verify-device', record, now), true, 'after 24 h');
for (let i = 1; i <= 3; i += 1) {
record = dismissNudge('verify-device', record, now);
assert.equal(shouldShowNudge('verify-device', record, now), false, `snoozed after #${i}`);
now += NUDGE_SNOOZE_MS;
assert.equal(shouldShowNudge('verify-device', record, now), i < 3, `after 7 days (#${i})`);
}
// Dismissing one nudge doesn't hide another.
assert.equal(shouldShowNudge('setup-backup', record, now), true);
});
test('stored record: round trip, and garbage starts a fresh grace period', () => {
const t0 = 1_700_000_000_000;
const r = dismissNudge('setup-backup', parseNudgeRecord(null, t0 - 5), t0);
assert.deepEqual(parseNudgeRecord(JSON.stringify(r), t0 + 1), r);
for (const bad of ['{', 'null', '"x"', '{"firstSeen":"yesterday"}', '{"firstSeen":1}']) {
assert.deepEqual(parseNudgeRecord(bad, t0), { firstSeen: t0, dismissals: {} }, bad);
}
});
+94
View File
@@ -0,0 +1,94 @@
/**
* [Gitea #110, #123] One "security" nudge for this device, in priority order:
*
* - verify-device: the account has cross-signing but THIS device isn't signed.
* It can't unlock backed-up history and shows as untrusted to others.
* Verifying with the recovery key also connects the backup, so it comes first.
* - connect-backup: a key backup exists on the server but this device isn't
* backing up to it (`getActiveSessionBackupVersion()` null).
* - setup-backup: no key backup at all (includes accounts that never set up
* cross-signing: the setup flow does both).
*
* Anything not yet known (crypto still loading) → no nudge. Timing: not in
* the first 24 h on a device (onboarding), "Not now" snoozes 7 days, three
* dismissals of a nudge stop it for good; a healthy device never sees one.
*/
export type NudgeKind = 'verify-device' | 'connect-backup' | 'setup-backup';
export type SecurityState = {
/** m.cross_signing.master account data present. */
crossSigning: boolean;
/** This device cross-signing-verified; undefined while crypto isn't ready. */
deviceVerified: boolean | undefined;
/** A key backup version on the server; undefined while loading. */
backupOnServer: boolean | undefined;
/** This device is backing up to it; undefined while loading. */
backupActive: boolean | undefined;
};
export const nudgeFor = (s: SecurityState): NudgeKind | undefined => {
if (s.crossSigning) {
if (s.deviceVerified === undefined) return undefined;
if (!s.deviceVerified) return 'verify-device';
}
if (s.backupOnServer === undefined) return undefined;
if (!s.backupOnServer) return 'setup-backup';
if (s.backupActive === undefined) return undefined;
return s.backupActive ? undefined : 'connect-backup';
};
export const NUDGE_GRACE_MS = 24 * 60 * 60 * 1000;
export const NUDGE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
export const NUDGE_MAX_DISMISSALS = 3;
export type NudgeRecord = {
/** When this device was first seen by the nudge (ms). */
firstSeen: number;
dismissals: Partial<Record<NudgeKind, { count: number; last: number }>>;
};
export const shouldShowNudge = (kind: NudgeKind, record: NudgeRecord, now: number): boolean => {
if (now - record.firstSeen < NUDGE_GRACE_MS) return false;
const d = record.dismissals[kind];
if (!d) return true;
return d.count < NUDGE_MAX_DISMISSALS && now - d.last >= NUDGE_SNOOZE_MS;
};
export const dismissNudge = (kind: NudgeKind, record: NudgeRecord, now: number): NudgeRecord => ({
...record,
dismissals: {
...record.dismissals,
[kind]: { count: (record.dismissals[kind]?.count ?? 0) + 1, last: now },
},
});
export const NUDGE_KEY_PREFIX = 'lotus-security-nudge-';
/** Parse a stored record; anything unexpected starts a fresh one (grace period from now). */
export const parseNudgeRecord = (raw: string | null, now: number): NudgeRecord => {
try {
const v = JSON.parse(raw ?? '');
if (v && typeof v.firstSeen === 'number' && v.dismissals && typeof v.dismissals === 'object') {
return { firstSeen: v.firstSeen, dismissals: v.dismissals };
}
} catch {
/* fresh record below */
}
return { firstSeen: now, dismissals: {} };
};
export const NUDGE_COPY: Record<NudgeKind, { text: string; action: string }> = {
'verify-device': {
text: 'Verify this device so you can read your older encrypted messages and others see it as trusted.',
action: 'Verify',
},
'connect-backup': {
text: "This device isn't using your key backup yet. Connect it so your encrypted messages stay readable if you lose it.",
action: 'Connect',
},
'setup-backup': {
text: "Your encryption keys aren't backed up. Set up key backup so you don't lose your encrypted messages if you log out or lose this device.",
action: 'Set up',
},
};
+28
View File
@@ -0,0 +1,28 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { voiceChannelOccupancy } from './voiceChannelFull';
test('voiceChannelOccupancy: one user on two devices fills one slot, not two', () => {
assert.deepEqual(voiceChannelOccupancy(['@a:x', '@a:x'], '@b:x', 2), {
channelFull: false,
current: 1,
max: 2,
});
});
test('voiceChannelOccupancy: full when distinct users reach the limit', () => {
assert.deepEqual(voiceChannelOccupancy(['@a:x', '@c:x', '@a:x'], '@b:x', 2), {
channelFull: true,
current: 2,
max: 2,
});
});
test('voiceChannelOccupancy: someone already in the call can always join another device', () => {
assert.equal(voiceChannelOccupancy(['@a:x', '@b:x'], '@b:x', 2).channelFull, false);
});
test('voiceChannelOccupancy: no limit (0) is never full; unknown senders are ignored', () => {
assert.equal(voiceChannelOccupancy(['@a:x', '@c:x'], '@b:x', 0).channelFull, false);
assert.equal(voiceChannelOccupancy([undefined, '@a:x'], '@b:x', 2).current, 1);
});
+25
View File
@@ -0,0 +1,25 @@
/**
* [Gitea #342] Whether a voice channel with an `io.lotus.voice_limit` is full.
* Counts distinct PEOPLE, not call memberships: a MatrixRTC membership is per
* device, so one user on desktop + phone (or with a crashed tab's membership
* not yet expired) used to fill two slots. This matches the server-side guard
* (matrix `voice-limit-guard.py`, which reduces LiveKit identities to users).
*/
export type VoiceChannelOccupancy = {
channelFull: boolean;
/** Distinct users in the call. */
current: number;
max: number;
};
export const voiceChannelOccupancy = (
memberSenders: Array<string | undefined>,
myUserId: string,
maxUsers: number,
): VoiceChannelOccupancy => {
const users = new Set(memberSenders.filter((s): s is string => !!s));
// A user already in the call (another device, or rejoining) is never blocked.
const alreadyMember = users.has(myUserId);
const channelFull = maxUsers > 0 && !alreadyMember && users.size >= maxUsers;
return { channelFull, current: users.size, max: maxUsers };
};