Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 7d7a379ce0 feat(desktop): call page on its own loopback origin, opt-in (#43)
CI / Build & Quality Checks (pull_request) Successful in 1m46s
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 10m24s
The desktop app loads the bundled Element Call page from its own origin
(http://localhost:<port>), so the call frame can read the app's storage
(login token) and DOM — the hole #43 closed on the web by moving the page
to call.chat.lotusguild.org.

The desktop's local server can also answer on http://127.0.0.1:<port>: the
same server and bundle, but a different origin (and still a secure
context). resolveDesktopCallPageUrl loads the bundled page from there when
the desktop config sets `desktopCallOrigin`:
- only a loopback http origin on the SAME port as the app, no path, query
  or credentials;
- only when the app itself runs on http://localhost (release builds; debug
  builds on tauri:// keep the same-origin page);
- unset (every desktop build until cinny-desktop opts in, together with the
  server bind, CSP and permission changes it needs): unchanged.

The web app is unchanged (elementCallUrl as before).

Tested in a simulated desktop app (Tauri bridge stub + the desktop
config.json, served on localhost and 127.0.0.1) against a local Synapse +
LiveKit, two users: call page from http://127.0.0.1:<port>, parentUrl =
the app origin; the frame gets SecurityError on parent.localStorage and
parent.document (same-origin control: readable); join, speaking indicator,
mic off/on, screenshare start/stop, layout switch and hang-up all work, no
page errors — 12/12 in 5 of 6 runs, like the same-origin control (3 of 4;
the misses on both sides were the local LiveKit connection).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 00:09:27 -04:00
jared 91f82d60e3 Merge pull request 'A stalled server no longer reads as "your clock is ahead"' (#251) from clock-skew-lag into lotus
CI / Build & Quality Checks (push) Successful in 3m4s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Successful in 10m32s
Merge pull request #251: a stalled server no longer reads as a wrong clock
2026-09-28 22:40:13 -04:00
Lotus CI f0865115a4 Merge remote-tracking branch 'origin/lotus' into clock-skew-lag
CI / Build & Quality Checks (pull_request) Successful in 3m7s
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 10m59s
2026-09-28 22:11:31 -04:00
jared 899e160aed Merge pull request 'Offline outbox: unsent messages survive reload and retry (#112)' (#250) from offline-outbox into lotus
CI / Build & Quality Checks (push) Successful in 2m58s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 10m52s
Merge pull request #250: Offline outbox (#112)
2026-09-28 22:08:28 -04:00
Lotus CIandClaude Opus 5.5 3e5fdd0dab test(e2e): clock-ahead warning needs a minute of samples (#158)
CI / Build & Quality Checks (pull_request) Successful in 2m57s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Canceled after 0s
"Ahead" is now reported only once it has held for a minute of fresh
samples (a stalled server delivers late and reads as ahead). The test sends
its ticks, checks nothing is shown yet, fast-forwards the page clock past a
minute and sends two more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-28 22:08:13 -04:00
Lotus CIandClaude Opus 5.5 bb569d69a2 fix: a stalled server no longer reads as "your clock is ahead"
CI / Build & Quality Checks (pull_request) Successful in 3m1s
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) Failing after 11m28s
Incident 2026-09-29: the homeserver's host ran out of memory and stalled for
~2 minutes. The /sync that finally went out carried events whose `age` was
computed ~30 s before it arrived, so every client showed "Your computer's
clock is 30 seconds ahead of the server" while the real problem was the
server (all host clocks were within 0.25 s the whole evening).

The skew estimate was the median of the last 5 samples, and a sample is
local skew + delivery delay, so one late /sync with a handful of events
tripped it.

- Estimate = the LOWEST sample of the last 5 minutes: delay only ever adds,
  so the fastest-delivered event is the truest.
- "Behind" (which a delay can't cause) is reported as soon as there are 3
  samples, like before. "Ahead" must hold across samples received at least
  a minute apart, so a single late burst never trips it.
- Samples are aged on the monotonic clock, and a change of the local clock
  (someone fixing it) resets the measurement, so the warning clears at once.
- Only events stamped by our own homeserver are sampled: a federated event's
  origin_server_ts is the other server's clock.
- Wording: "This device's clock is … Voice calls and encrypted messages can
  fail until it's corrected." / call bar "Device clock … : calls may fail"
  (was "will fail").

Unit tests: the incident (late burst after normal traffic, and a fresh
client whose first samples are all late), mixed slow/fast deliveries,
ahead only after a minute, behind at once, hysteresis, clock fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-28 21:40:02 -04:00
Lotus CIandClaude Opus 5.5 02d86caeb0 feat: offline outbox — unsent messages survive reload and retry (#112)
CI / Build & Quality Checks (pull_request) Successful in 6m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 27s
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 10m28s
Until now a send that failed (offline, homeserver down, a blip) went
straight to "Failed to send": nothing retried it, and a reload dropped it
without trace (chronological pending ordering keeps local echoes in memory
only).

- Outbox (utils/outbox.ts + features/outbox/OutboxFeature): own message
  sends (text, stickers, reactions, polls; not call signalling or
  redactions) are mirrored to localStorage from their first local echo until
  the server confirms them or the user cancels.
- After a reload they come back as local echoes via room.addPendingEvent,
  same shape as the SDK's own. Recent ones (< 1 h) are sent again with the
  same txnId; older ones come back as "Failed to send" for the user to
  retry or cancel. Ones the server already has (transaction id seen in
  /sync) are dropped, so no duplicates.
- Retries: network failures (ConnectionError, 408/429/5xx) are re-sent when
  the connection returns (sync recovers or the browser goes back online),
  and after a blip while online (5 s, backing off, max 10 per message).
  Oldest first, in order per room. 4xx / consent / encryption failures are
  left to the user.
- UI: a network failure while offline shows a clock, "Queued. Will send
  when you're back online" (thread view too), not the red ✕. The ✕ is now
  a button: click to retry.
- Logout wipes the outbox with the other plaintext caches (the content is
  decrypted, like drafts).

Tested end to end against a local Synapse (Chromium): offline → queued →
sent once on reconnect; homeserver unreachable → queued → sent once; failed
send → reload → sent once and shown once; server accepted but response lost
→ reload → no duplicate; 2 h old entry → failed, not sent, click ✕ → sent;
cancel → gone after reload; encrypted room → restored message goes out as
m.room.encrypted with no plaintext and decrypts; one-off failure retried by
itself in ~5 s; no page errors. Unit tests for the pure parts; Playwright
20 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-28 21:02:45 -04:00
jared c0c93213c1 Merge pull request 'Desktop: Lotus links use the public web app and open in-app (#248)' (#249) from desktop-lotus-links into lotus
CI / Build & Quality Checks (push) Successful in 2m7s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 9m6s
Merge pull request #249: Desktop Lotus links use the public web app (#248)
2026-09-28 19:53:20 -04:00
Lotus CIandClaude Opus 5.5 1ea6987083 fix(desktop): Lotus links use the public web app, and open in-app (#248)
CI / Build & Quality Checks (pull_request) Successful in 1m50s
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 9m19s
Lotus permalinks (#130) were built from window.location.origin. In the
desktop app that's the local tauri-plugin-localhost server (hash-routed), so
"Copy Lotus Link" copied e.g. http://localhost:…/#/home/!room…, which works
for nobody else. And the link recogniser only knew that local base, so a real
https://chat.lotusguild.org/home/… link in a message opened the browser
instead of the room.

- useLotusShareBase: in the desktop app, links for other people use config
  `webAppUrl` (https only, set by cinny-desktop #23) in web path-routing
  form; otherwise the origin, as before. Used by "Copy Lotus Link" on
  messages, the space menu and space tabs.
- The recogniser accepts several bases: the origin, plus `webAppUrl` in the
  desktop app.
- The web app is unchanged.

Verified with a simulated desktop (Tauri bridge + webAppUrl) against a local
Synapse. Copied links are https://chat.lotusguild.org/home/<room>/<event> and
https://chat.lotusguild.org/<space>. A public link in a message renders as
the room pill and clicking it opens the room in-app, with nothing sent to the
system browser. The web app still copies origin links. Unit tests for the
base selection; Playwright 20 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-28 19:39:48 -04:00
jared be8e49a2bb Merge pull request #247: readable poll card (#246)
CI / Build & Quality Checks (push) Successful in 1m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 7m57s
2026-09-27 23:28:48 -04:00
Lotus CIandClaude Opus 5.5 1e217592fc fix(polls): readable poll card: counts, visible bars/indicators, no chip overflow (#246)
CI / Build & Quality Checks (pull_request) Successful in 1m35s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 7m55s
Rendering only; the vote logic (tally, optimistic votes, end poll, keyboard
radiogroup, voters) is unchanged.

- Every row shows "N votes · P%". Multiple choice looked broken as
  "100% / 100%" with no counts; the footer now says "voters" there.
- Results are a thin progress bar under each answer: accent for your pick,
  success for the winner, neutral otherwise. The old full-row fill was the
  same grey as the row, so a 100% answer just looked disabled.
- Radio and checkbox indicators are 18px with a 2px border in a colour mixed
  from the theme's text colour. Primary.ContainerLine was nearly invisible,
  especially in dark themes.
- The winner shows a star and "Winner" instead of a second check mark.
- Bordered card, 460px wide (max 100%, same width for every poll). The header
  is now "Poll · Single choice | Pick up to N | Results hidden until the end |
  Final results", replacing the letter-spaced "◉ POLL · …" line.
- Footer: plain muted text that wraps, and chips ("Who voted", "End poll")
  that never wrap. Previously the chip label broke onto two lines inside a
  one-line chip and spilled out of it. Reproduced before/after in Lotus
  Terminal at 150% zoom, a 360px phone, and dark at 125%.
- Lotus Terminal theme: data-winner gets its own green border rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 23:18:25 -04:00
jared ffe1d5a81e Merge pull request #245: desktop without WebRTC offers "Open in browser"
CI / Build & Quality Checks (push) Successful in 1m53s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Successful in 9m54s
2026-09-27 17:10:50 -04:00
Lotus CIandClaude Opus 5.5 fff583fca5 feat(call): desktop without WebRTC offers "Open in browser" instead of a dead end
CI / Build & Quality Checks (pull_request) Successful in 2m12s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m22s
The Linux desktop app runs on WebKitGTK, which ships without WebRTC (2.52
has no RTCPeerConnection; 2.54 disables it outright pending a libwebrtc
backend around 2.56), so calls can't work there. Until now the call button
just disappeared, the call room said "Your browser does not support WebRTC"
with Join disabled, and an incoming call couldn't be answered.

In the desktop app (isTauri) without WebRTC:
- call rooms: "Calls aren't available in the desktop app on Linux yet: its
  web engine has no WebRTC" + an "Open in browser" button;
- incoming-call overlay: the same, with "Answer in browser";
- room header: the call button stays, and opens the room in the browser.

The link is the room in the web app (config.json `webAppUrl`, https only,
new key); the user presses Join there. Deliberately not an auto-join link:
a crafted URL must not be able to join a call and open someone's mic. It
opens through the desktop's new-window handler (web/mail schemes only → the
system browser). Without `webAppUrl` the explanation shows with no button;
browsers without WebRTC keep the old message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 16:54:29 -04:00
jared e2b23397bd Merge pull request #244: screenshare stays on the call bar in Firefox/Safari (#43)
CI / Build & Quality Checks (push) Successful in 3m54s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 14s
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Successful in 11m34s
2026-09-27 13:54:11 -04:00
Lotus CIandClaude Opus 5.5 b929cbf74b chore(call): pin element-call-embedded 0.25.0-lotus.22 (#43)
CI / Build & Quality Checks (pull_request) Successful in 4m14s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m39s
lotus.22 draws the "Share your screen?" prompt inside the call frame on
request and drops the corner button. Verified against the published
package: no-delegation (Firefox path) cross- and same-origin — bar button,
in-frame prompt, Cancel, Share → screenshare tracks, bar Stop; Chromium
unchanged; picture-in-picture prompt fits and Share works; room policy
hides the button and refuses the prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 13:32:45 -04:00
Lotus CIandClaude Opus 5.5 8520de232d feat(call): screenshare stays on the call bar in Firefox/Safari via the in-frame prompt (#43)
CI / Build & Quality Checks (pull_request) Successful in 4m18s
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 11m27s
With a fork that reports `screensharePrompt` (element-call lotus-screenshare-
prompt), the call bar and status bar keep their screenshare button on every
engine. Where the click can't be delegated, starting asks the fork to show
"Share your screen?" inside the call frame (io.lotus.prompt_screenshare)
instead of our own confirm; its Share click starts the share. Stopping works
from the bar as before (no click needed in the frame). Chromium is unchanged.

- useScreenshareMode: hidden (older fork's corner button) | prompt | direct.
- The room's call policy is also pushed to the fork in prompt mode, so the
  prompt never opens where sharing is forbidden.
- Picture-in-picture: the "Return to call" overlay covers the frame; while
  the fork reports the prompt open it lets clicks through to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 12:48:12 -04:00
jared 773e41311e Merge pull request #243: optional Element Call origin via config.json elementCallUrl (#43)
CI / Build & Quality Checks (push) Successful in 4m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 13s
CI / Playwright smoke (e2e) (push) Successful in 14m40s
2026-09-27 02:24:47 -04:00
37 changed files with 1512 additions and 295 deletions
+15 -6
View File
@@ -257,12 +257,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);
+4 -4
View File
@@ -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.22",
"@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.22",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.22/element-call-embedded-0.25.0-lotus.22.tgz",
"integrity": "sha512-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -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.22",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+7 -8
View File
@@ -38,7 +38,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';
@@ -82,6 +82,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;
@@ -231,13 +232,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 && (
@@ -882,6 +877,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 +1359,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',
+170 -114
View File
@@ -261,52 +261,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 +333,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 +369,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 +410,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 +484,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>
);
+11 -7
View File
@@ -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}
/>
)}
+2 -2
View File
@@ -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>
</>
)}
+11 -8
View File
@@ -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
+5 -9
View File
@@ -23,6 +23,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 +34,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 +139,7 @@ function CallPrescreen() {
)}
{!inOtherCall && hasPermission && !channelFull && (
<JoinMessage
roomId={room.roomId}
hasParticipant={hasParticipant}
livekitSupported={livekitSupported}
rtcSupported={rtcSupported}
@@ -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>
);
}
+268
View File
@@ -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;
}
+39 -2
View File
@@ -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"
+67 -40
View File
@@ -26,6 +26,7 @@ import {
config,
} from 'folds';
import React, {
CSSProperties,
FormEventHandler,
MouseEventHandler,
ReactNode,
@@ -38,7 +39,7 @@ import { useHover, useFocusWithin } from 'react-aria';
import { MatrixEvent, 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 +84,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 +97,41 @@ 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);
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 +142,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 +494,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?.();
};
@@ -1110,7 +1137,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>
);
@@ -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&apos;re back online
</Text>
</Box>
)}
</div>
);
};
+15
View File
@@ -25,6 +25,21 @@ 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;
/**
* 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);
+23
View File
@@ -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]);
});
});
+37
View File
@@ -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],
);
};
+14 -2
View File
@@ -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
@@ -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}>
+11 -3
View File
@@ -31,6 +31,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';
@@ -77,6 +78,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 +120,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;
}
@@ -1069,6 +1072,10 @@ function ClockSkewFeature() {
data,
) => {
if (!data.liveEvent) return;
// 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 +1112,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<TauriDesktopFeatures />
<CloseBehaviorPrompt />
<ConsentRequiredPrompt />
<OutboxFeature />
<LotusDenoiseFeature />
<DeepLinkNavigator />
<KeyboardShortcutsFeature />
+3 -3
View File
@@ -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&apos;s clock is <b>{describeSkewVsServer(skewMs)}</b>. Encrypted messages
and voice calls will fail until it is fixed.
This device&apos;s clock is <b>{describeSkewVsServer(skewMs)}</b>. Voice calls and
encrypted messages can fail until it&apos;s corrected.
</Text>
<Button
size="300"
+4 -11
View File
@@ -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();
};
+4 -11
View File
@@ -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();
};
+57 -8
View File
@@ -261,6 +261,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 +283,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 +323,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 +359,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();
+42 -1
View File
@@ -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);
});
+37 -3
View File
@@ -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 => {
+34 -7
View File
@@ -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;
};
+14 -6
View File
@@ -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 = {
+2
View File
@@ -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`);
}
+3
View File
@@ -7,6 +7,7 @@ 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';
/**
* [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). Drafts
@@ -44,6 +45,7 @@ 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])
* - `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
@@ -93,6 +95,7 @@ export const clearPlaintextCaches = (userId?: string): void => {
clearRecentGifs();
clearRecentStickers();
clearMsgDrafts();
clearOutbox();
clearCallSession();
clearStatusMessage();
if (userId) clearNavToActivePathStore(userId);
+9
View File
@@ -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);
+32
View File
@@ -0,0 +1,32 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getCallInBrowserUrl, resolveWebAppUrl } from './callInBrowser';
test('resolveWebAppUrl keeps an https origin (+ path) without a trailing slash', () => {
assert.equal(resolveWebAppUrl('https://chat.example.org'), 'https://chat.example.org');
assert.equal(resolveWebAppUrl('https://chat.example.org/'), 'https://chat.example.org');
assert.equal(resolveWebAppUrl('https://example.org/chat/'), 'https://example.org/chat');
assert.equal(resolveWebAppUrl('https://chat.example.org/?x=1#y'), 'https://chat.example.org');
});
test('resolveWebAppUrl rejects anything but absolute https', () => {
[
undefined,
null,
1,
'',
' ',
'/home',
'http://chat.example.org',
'not a url',
['javascript', 'alert(1)'].join(':'),
'data:text/html,x',
].forEach((v) => assert.equal(resolveWebAppUrl(v), undefined, String(v)));
});
test('getCallInBrowserUrl points at the room in the web app (no auto-join)', () => {
const url = getCallInBrowserUrl('https://chat.example.org', '!room:example.org');
assert.equal(url, 'https://chat.example.org/home/!room%3Aexample.org');
assert.ok(!/join|call=/i.test(url ?? ''));
assert.equal(getCallInBrowserUrl(undefined, '!room:example.org'), undefined);
});
+27
View File
@@ -0,0 +1,27 @@
import { getLotusRoomPermalink } from '../plugins/lotus-permalink';
/**
* The public web app's address from config.json `webAppUrl` (the desktop app
* sets it; its own origin is a local server). Absolute https only; anything
* else is ignored.
*/
export const resolveWebAppUrl = (value: unknown): string | undefined => {
if (typeof value !== 'string' || value.trim() === '') return undefined;
try {
const url = new URL(value);
if (url.protocol !== 'https:') return undefined;
return url.origin + url.pathname.replace(/\/+$/, '');
} catch {
return undefined;
}
};
/**
* Where to send the user to take a call this app can't make: the room in the
* web app (they press Join there themselves — never an auto-join link, which
* a crafted URL could abuse to open someone's mic).
*/
export const getCallInBrowserUrl = (
webAppUrl: string | undefined,
roomId: string,
): string | undefined => (webAppUrl ? getLotusRoomPermalink(webAppUrl, roomId) : undefined);
+76 -27
View File
@@ -8,44 +8,96 @@ import {
formatSkew,
} from './clockSkew';
// A live event received when the local clock is `skew` ms ahead of the server:
// origin_server_ts = T (server clock), age = a, localTimestamp = (T + a + skew) - a.
const feed = (m: ClockSkewMonitor, skew: number, age = 500, t = 1_700_000_000_000) =>
m.sample(t, age, t + skew);
const T = 1_700_000_000_000;
test('needs three samples, then reports the median with direction', () => {
/**
* A live event received `atSec` seconds into the test, when the local clock is
* `skew` ms off the server and the response took `delay` ms to arrive:
* localTimestamp − origin_server_ts = skew + delay.
*/
const feed = (m: ClockSkewMonitor, skew: number, atSec = 0, delay = 0, wallJump = 0) =>
m.sample(T, 500, T + skew + delay, { wall: T + atSec * 1000 + wallJump, mono: atSec * 1000 });
test('behind: reported as soon as there are three samples', () => {
const m = new ClockSkewMonitor();
assert.equal(feed(m, 60_000).skewMs, null);
assert.equal(feed(m, 61_000).skewMs, null);
const s = feed(m, 59_000);
assert.equal(s.skewMs, 60_000);
assert.equal(feed(m, -60_000, 0).skewMs, null);
assert.equal(feed(m, -61_000, 1).skewMs, null);
const s = feed(m, -59_000, 2);
assert.equal(s.skewMs, -61_000);
assert.equal(s.warning, true);
assert.equal(formatSkew(s.skewMs!), '60 seconds ahead');
assert.equal(formatSkew(s.skewMs!), '61 seconds behind');
});
test('one bad sample cannot trip the warning (median) and hysteresis clears only under 15 s', () => {
test('ahead: only once it has held for a minute', () => {
const m = new ClockSkewMonitor();
feed(m, 1000);
feed(m, 1500);
assert.equal(feed(m, 90_000).warning, false); // outlier
assert.equal(m.getState().skewMs, 1500);
feed(m, 60_000, 0);
feed(m, 60_000, 10);
assert.equal(feed(m, 60_000, 20).warning, false);
assert.equal(m.getState().skewMs, 60_000);
assert.equal(feed(m, 60_000, 59).warning, false);
assert.equal(feed(m, 60_000, 61).warning, true);
});
test('server stall (2026-09-29): a burst of late events does not read as a wrong clock', () => {
const m = new ClockSkewMonitor();
// Normal traffic, then the homeserver stalls and one /sync arrives 30 s late
// with a pile of events, then normal traffic again.
feed(m, 200, 0);
feed(m, 150, 5);
feed(m, 300, 10);
[1, 2, 3, 4, 5, 6].forEach(() => feed(m, 0, 130, 31_000));
assert.equal(m.getState().warning, false);
assert.ok(m.getState().skewMs! < 1000);
// Fresh client whose first samples are all from the late burst.
const fresh = new ClockSkewMonitor();
[1, 2, 3, 4, 5, 6].forEach(() => feed(fresh, 0, 0, 31_000));
assert.equal(fresh.getState().warning, false);
// …and the next timely event brings the estimate back down.
feed(fresh, 0, 70, 100);
assert.equal(fresh.getState().warning, false);
assert.equal(fresh.getState().skewMs, 100);
});
test('slow deliveries mixed with fast ones: the fastest one wins', () => {
const m = new ClockSkewMonitor();
[0, 20, 40, 60, 80].forEach((at, i) => feed(m, 45_000, at, i === 2 ? 0 : 20_000));
assert.equal(m.getState().skewMs, 45_000);
assert.equal(m.getState().warning, true);
});
test('hysteresis: once on, clears only under 15 s', () => {
const w = new ClockSkewMonitor();
[40_000, 41_000, 39_000, 40_000, 40_000].forEach((s) => feed(w, s));
[0, 1, 2].forEach((at) => feed(w, -40_000, at));
assert.equal(w.getState().warning, true);
// drifting down to 20 s: still >= 15 s → stays on
[20_000, 20_000, 20_000, 20_000, 20_000].forEach((s) => feed(w, s));
// Samples expire after 5 minutes; drifting to -20 s keeps it on (>= 15 s).
[400, 401, 402].forEach((at) => feed(w, -20_000, at));
assert.equal(w.getState().skewMs, -20_000);
assert.equal(w.getState().warning, true);
[10_000, 10_000, 10_000, 10_000, 10_000].forEach((s) => feed(w, s));
[800, 801, 802].forEach((at) => feed(w, -10_000, at));
assert.equal(w.getState().warning, false);
});
test('fixing the local clock starts the measurement afresh', () => {
const m = new ClockSkewMonitor();
[0, 1, 2].forEach((at) => feed(m, -14 * 60_000, at));
assert.equal(m.getState().warning, true);
// The user sets the clock forward 14 minutes: wall jumps vs the monotonic clock.
const jump = 14 * 60_000;
feed(m, 0, 10, 0, jump);
assert.equal(m.getState().warning, false);
assert.equal(m.getState().skewMs, null);
feed(m, 0, 11, 0, jump);
feed(m, 0, 12, 0, jump);
assert.equal(m.getState().skewMs, 0);
assert.equal(m.getState().warning, false);
});
test('stale or missing age is ignored (cache replay must not read as skew)', () => {
const m = new ClockSkewMonitor();
const t = 1_700_000_000_000;
m.sample(t, undefined, t + 3_600_000);
m.sample(t, 40 * 24 * 60 * 60 * 1000, t + 3_600_000);
m.sample(t, -5, t);
m.sample(T, undefined, T + 3_600_000);
m.sample(T, 40 * 24 * 60 * 60 * 1000, T + 3_600_000);
m.sample(T, -5, T);
assert.equal(m.getState().skewMs, null);
});
@@ -53,10 +105,7 @@ test('subscribe fires on change only; reset clears', () => {
const m = new ClockSkewMonitor();
const seen: (number | null)[] = [];
m.subscribe((s) => seen.push(s.skewMs));
feed(m, -120_000);
feed(m, -120_000);
feed(m, -120_000);
feed(m, -120_000);
[0, 1, 2, 3].forEach((at) => feed(m, -120_000, at));
assert.deepEqual(seen, [-120_000]);
assert.equal(formatSkew(-120_000), '2 minutes behind');
m.reset();
+56 -11
View File
@@ -22,8 +22,23 @@
export const SKEW_WARN_MS = 30_000;
export const SKEW_CLEAR_MS = 15_000;
export const SKEW_SAMPLES = 5;
export const SKEW_MIN_SAMPLES = 3;
/** Samples older than this are forgotten. */
export const SKEW_WINDOW_MS = 5 * 60 * 1000;
export const SKEW_MAX_SAMPLES = 30;
/**
* "Ahead" must hold across samples received at least this far apart.
*
* Incident 2026-09-29: the homeserver's host ran out of memory and stalled for
* ~2 minutes; the /sync that finally went out carried events whose `age` was
* computed ~30 s before it arrived, so every client read "your clock is 30 s
* ahead" — while the real problem was the server. A late delivery can only make
* the local clock look AHEAD (never behind), so the estimate is the LOWEST
* recent sample (the one delivered fastest), and "ahead" has to persist across
* a minute of fresh samples before it is reported. "Behind" can't come from a
* delay and is reported as soon as there are enough samples.
*/
export const SKEW_AHEAD_SPAN_MS = 60_000;
/**
* Sanity cap on `age`. Old events are still valid samples (the server computes
* `age` at response time, so `ts + age` is its clock regardless of the event's
@@ -38,14 +53,21 @@ export type ClockSkewState = {
warning: boolean;
};
const median = (xs: number[]): number => {
const s = [...xs].sort((a, b) => a - b);
const mid = Math.floor(s.length / 2);
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
/** A wall-clock change larger than this (vs the monotonic clock) resets the samples. */
export const CLOCK_JUMP_MS = 5_000;
type Sample = { skew: number; at: number };
const currentClock = (): { wall: number; mono: number } => {
const wall = Date.now();
const mono = typeof performance !== 'undefined' ? performance.now() : wall;
return { wall, mono };
};
export class ClockSkewMonitor {
private samples: number[] = [];
private samples: Sample[] = [];
private clockOffset: number | undefined;
private state: ClockSkewState = { skewMs: null, warning: false };
@@ -64,25 +86,47 @@ export class ClockSkewMonitor {
/**
* Feed one live event. `originServerTs` + `age` come from the event;
* `localTimestamp` is the SDK's `Date.now() − age` at construction.
* `localTimestamp` is the SDK's `Date.now() − age` at construction; `clock`
* is when the sample was taken: wall clock and a monotonic clock
* (performance.now()), so samples are aged by real elapsed time and a change
* of the local clock (someone fixing it) starts the measurement afresh.
* Only feed events stamped by OUR homeserver: another server's
* `origin_server_ts` carries that server's clock.
* Returns the new state (unchanged object when nothing moved).
*/
public sample(
originServerTs: number,
age: number | undefined,
localTimestamp: number,
clock: { wall: number; mono: number } = currentClock(),
): ClockSkewState {
if (age === undefined || !Number.isFinite(age) || age < 0 || age > SKEW_MAX_AGE_MS) {
return this.state;
}
if (!Number.isFinite(originServerTs) || !Number.isFinite(localTimestamp)) return this.state;
this.samples.push(localTimestamp - originServerTs);
if (this.samples.length > SKEW_SAMPLES) this.samples.shift();
const now = clock.mono;
const offset = clock.wall - clock.mono;
if (this.clockOffset !== undefined && Math.abs(offset - this.clockOffset) > CLOCK_JUMP_MS) {
// The local clock was changed: earlier samples measured the old clock.
this.reset();
}
this.clockOffset = offset;
this.samples.push({ skew: localTimestamp - originServerTs, at: now });
this.samples = this.samples.filter((s) => now - s.at <= SKEW_WINDOW_MS);
if (this.samples.length > SKEW_MAX_SAMPLES) this.samples.shift();
if (this.samples.length < SKEW_MIN_SAMPLES) return this.state;
const skewMs = median(this.samples);
// Delivery delay only ever adds to a sample: the smallest is the truest.
const skewMs = Math.min(...this.samples.map((s) => s.skew));
const abs = Math.abs(skewMs);
const warning = this.state.warning ? abs >= SKEW_CLEAR_MS : abs > SKEW_WARN_MS;
let warning: boolean;
if (this.state.warning) warning = abs >= SKEW_CLEAR_MS;
else if (skewMs < -SKEW_WARN_MS) warning = true;
else if (skewMs > SKEW_WARN_MS) {
// Ahead: only if the fastest-delivered samples stayed high for a minute.
const span = now - Math.min(...this.samples.map((s) => s.at));
warning = span >= SKEW_AHEAD_SPAN_MS;
} else warning = false;
if (skewMs === this.state.skewMs && warning === this.state.warning) return this.state;
this.state = { skewMs, warning };
this.listeners.forEach((cb) => cb(this.state));
@@ -91,6 +135,7 @@ export class ClockSkewMonitor {
public reset(): void {
this.samples = [];
this.clockOffset = undefined;
if (this.state.skewMs !== null || this.state.warning) {
this.state = { skewMs: null, warning: false };
this.listeners.forEach((cb) => cb(this.state));
+131
View File
@@ -0,0 +1,131 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
OUTBOX_AUTOSEND_MS,
OUTBOX_MAX_ENTRIES,
OutboxEntry,
isRetryableSendError,
outboxRetryDelayMs,
parseOutbox,
planRestore,
shouldKeepInOutbox,
withEntry,
withoutEntry,
} from './outbox';
const entry = (txnId: string, ts: number, roomId = '!a:hs'): OutboxEntry => ({
txnId,
roomId,
threadId: null,
type: 'm.room.message',
content: { msgtype: 'm.text', body: txnId },
ts,
});
test('keeps message-like events, not call signalling or redactions', () => {
assert.equal(shouldKeepInOutbox('m.room.message', { body: 'hi' }), true);
assert.equal(shouldKeepInOutbox('m.reaction', { 'm.relates_to': { event_id: '$x' } }), true);
assert.equal(shouldKeepInOutbox('org.matrix.msc3381.poll.start', {}), true);
assert.equal(shouldKeepInOutbox('org.matrix.msc4075.rtc.notification', {}), false);
assert.equal(shouldKeepInOutbox('m.call.invite', {}), false);
assert.equal(shouldKeepInOutbox('m.room.redaction', {}), false);
});
test('skips events that point at another unsent message (local id)', () => {
assert.equal(
shouldKeepInOutbox('m.reaction', { 'm.relates_to': { event_id: '~!a:hs:m123' } }),
false,
);
assert.equal(
shouldKeepInOutbox('m.room.message', {
body: 'reply',
'm.relates_to': { 'm.in_reply_to': { event_id: '~!a:hs:m1' } },
}),
false,
);
assert.equal(
shouldKeepInOutbox('m.room.message', {
body: 'reply',
'm.relates_to': { 'm.in_reply_to': { event_id: '$real' } },
}),
true,
);
});
test('retryable: connection loss, timeouts, rate limits, server errors', () => {
const connectionError = new Error('fetch failed');
Object.defineProperty(connectionError, 'name', { value: 'ConnectionError' });
assert.equal(isRetryableSendError(connectionError), true);
assert.equal(isRetryableSendError({ httpStatus: 429 }), true);
assert.equal(isRetryableSendError({ httpStatus: 408 }), true);
assert.equal(isRetryableSendError({ httpStatus: 502 }), true);
});
test('not retryable: client errors, consent, unknown or missing errors', () => {
assert.equal(isRetryableSendError({ httpStatus: 403, errcode: 'M_FORBIDDEN' }), false);
assert.equal(isRetryableSendError({ httpStatus: 403, errcode: 'M_CONSENT_NOT_GIVEN' }), false);
assert.equal(isRetryableSendError({ httpStatus: 400 }), false);
assert.equal(isRetryableSendError(new Error('encryption failed')), false);
assert.equal(isRetryableSendError(undefined), false);
assert.equal(isRetryableSendError(null), false);
});
test('parse: only this user, only well-formed entries, junk tolerated', () => {
const good = entry('m1', 1);
const raw = JSON.stringify({ userId: '@me:hs', entries: [good, { txnId: 5 }, null] });
assert.deepEqual(parseOutbox(raw, '@me:hs'), [good]);
assert.deepEqual(parseOutbox(raw, '@other:hs'), []);
assert.deepEqual(parseOutbox('{not json', '@me:hs'), []);
assert.deepEqual(parseOutbox(null, '@me:hs'), []);
});
test('withEntry: first write wins, sorted, capped (oldest dropped)', () => {
const a = entry('a', 2);
let list = withEntry([], a);
assert.equal(withEntry(list, { ...a, content: { body: 'changed' } }), list);
list = withEntry(list, entry('b', 1));
assert.deepEqual(
list.map((e) => e.txnId),
['b', 'a'],
);
let many: OutboxEntry[] = [];
for (let i = 0; i < OUTBOX_MAX_ENTRIES + 5; i += 1) many = withEntry(many, entry(`t${i}`, i));
assert.equal(many.length, OUTBOX_MAX_ENTRIES);
assert.equal(many[0].txnId, 't5');
});
test('withoutEntry: removes, and returns the same list when absent', () => {
const list = [entry('a', 1), entry('b', 2)];
assert.deepEqual(
withoutEntry(list, 'a').map((e) => e.txnId),
['b'],
);
assert.equal(withoutEntry(list, 'zzz'), list);
});
test('restore plan: drops delivered / left rooms, auto-sends only recent ones', () => {
const now = 10 * OUTBOX_AUTOSEND_MS;
const recent = entry('recent', now - 60_000);
const old = entry('old', now - OUTBOX_AUTOSEND_MS - 1);
const delivered = entry('delivered', now - 1000);
const left = entry('left', now - 1000, '!left:hs');
const plan = planRestore(
[recent, left, old, delivered],
now,
(roomId) => roomId !== '!left:hs',
(e) => e.txnId === 'delivered',
);
assert.deepEqual(
plan.restore.map((e) => e.txnId),
['old', 'recent'],
);
assert.deepEqual([...plan.autoSend], ['recent']);
assert.deepEqual(plan.drop.map((e) => e.txnId).sort(), ['delivered', 'left']);
});
test('blip retry delay backs off and is capped at a minute', () => {
assert.deepEqual(
[0, 1, 2, 3, 4, 9].map(outboxRetryDelayMs),
[5000, 10000, 20000, 40000, 60000, 60000],
);
});
+182
View File
@@ -0,0 +1,182 @@
import { IContent } from 'matrix-js-sdk';
/**
* [Gitea #112] Offline outbox: unsent messages survive a reload and are
* retried when the connection comes back.
*
* The SDK keeps local echoes in memory only (chronological pending ordering),
* so a reload used to drop every message that hadn't reached the server. Each
* own message send is mirrored here (type + clear content + txnId) from its
* first local echo until the server confirms it or the user cancels it. On
* the next start it is put back as a failed local echo (Retry / Cancel as
* usual) and, if it's recent, sent again with the SAME txnId — the server
* deduplicates a transaction it already accepted.
*
* The content is the decrypted message, like a composer draft: it lives in
* localStorage until sent and is wiped on logout (clearPlaintextCaches).
*/
export type OutboxEntry = {
txnId: string;
roomId: string;
threadId: string | null;
type: string;
content: IContent;
/** When the message was first sent (local echo timestamp, ms). */
ts: number;
};
type Stored = { userId: string; entries: OutboxEntry[] };
const STORAGE_KEY = 'lotus_outbox_v1';
/** Hard cap so a long offline stretch can't grow localStorage without bound. */
export const OUTBOX_MAX_ENTRIES = 100;
/**
* Restored messages younger than this are sent again automatically after a
* reload; older ones come back as "Failed to send" and wait for the user
* (sending a message typed hours ago without asking would surprise people).
*/
export const OUTBOX_AUTOSEND_MS = 60 * 60 * 1000;
/** Auto-retries per message per session (one per reconnect). */
export const OUTBOX_MAX_AUTO_RETRIES = 10;
/** Delay before automatic retry number `attempt` (0-based) after a blip. */
export const outboxRetryDelayMs = (attempt: number): number =>
Math.min(5000 * 2 ** Math.max(0, attempt), 60_000);
/** Message-like events worth keeping. Not call signalling, not redactions. */
export const OUTBOX_EVENT_TYPES: ReadonlySet<string> = new Set([
'm.room.message',
'm.sticker',
'm.reaction',
'm.poll.start',
'm.poll.response',
'm.poll.end',
'org.matrix.msc3381.poll.start',
'org.matrix.msc3381.poll.response',
'org.matrix.msc3381.poll.end',
]);
const relatesToPendingEvent = (content: IContent): boolean => {
const rel = content['m.relates_to'] as
| { event_id?: unknown; 'm.in_reply_to'?: { event_id?: unknown } }
| undefined;
const ids = [rel?.event_id, rel?.['m.in_reply_to']?.event_id];
// A local echo's id ("~!room:txn") means nothing after a reload.
return ids.some((id) => typeof id === 'string' && id.startsWith('~'));
};
/** Whether a send should be mirrored into the outbox. */
export const shouldKeepInOutbox = (type: string, content: IContent): boolean =>
OUTBOX_EVENT_TYPES.has(type) && !relatesToPendingEvent(content);
/**
* A failure the network is to blame for: no connection (the SDK's
* ConnectionError), a timeout, rate limiting or a server error. Anything else
* (403, consent, bad request, encryption failure) needs the user.
*/
export const isRetryableSendError = (err: unknown): boolean => {
if (!err || typeof err !== 'object') return false;
const { name, httpStatus } = err as { name?: unknown; httpStatus?: unknown };
if (name === 'ConnectionError') return true;
if (typeof httpStatus !== 'number') return false;
return httpStatus === 408 || httpStatus === 429 || httpStatus >= 500;
};
const isEntry = (e: unknown): e is OutboxEntry => {
if (!e || typeof e !== 'object') return false;
const o = e as Record<string, unknown>;
return (
typeof o.txnId === 'string' &&
typeof o.roomId === 'string' &&
(o.threadId === null || typeof o.threadId === 'string') &&
typeof o.type === 'string' &&
!!o.content &&
typeof o.content === 'object' &&
typeof o.ts === 'number'
);
};
/** Parse the stored outbox, keeping only this user's well-formed entries. */
export const parseOutbox = (raw: string | null, userId: string): OutboxEntry[] => {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as Partial<Stored>;
if (parsed.userId !== userId || !Array.isArray(parsed.entries)) return [];
return parsed.entries.filter(isEntry);
} catch {
return [];
}
};
/** Add (or keep) an entry: first write wins, oldest dropped past the cap. */
export const withEntry = (entries: OutboxEntry[], entry: OutboxEntry): OutboxEntry[] => {
if (entries.some((e) => e.txnId === entry.txnId)) return entries;
const next = [...entries, entry].sort((a, b) => a.ts - b.ts);
return next.length > OUTBOX_MAX_ENTRIES ? next.slice(next.length - OUTBOX_MAX_ENTRIES) : next;
};
export const withoutEntry = (entries: OutboxEntry[], txnId: string): OutboxEntry[] =>
entries.some((e) => e.txnId === txnId) ? entries.filter((e) => e.txnId !== txnId) : entries;
export type RestorePlan = {
/** Put back as failed local echoes, oldest first. */
restore: OutboxEntry[];
/** Subset of `restore` to send again right away. */
autoSend: Set<string>;
/** Already delivered or no longer sendable: forget them. */
drop: OutboxEntry[];
};
/**
* Decide what to do with the stored outbox on start.
* `canSend(roomId)`: the user is still joined; `delivered(entry)`: the
* server already has it (the transaction id came back down /sync).
*/
export const planRestore = (
entries: OutboxEntry[],
now: number,
canSend: (roomId: string) => boolean,
delivered: (entry: OutboxEntry) => boolean,
): RestorePlan => {
const restore: OutboxEntry[] = [];
const autoSend = new Set<string>();
const drop: OutboxEntry[] = [];
[...entries]
.sort((a, b) => a.ts - b.ts)
.forEach((entry) => {
if (!canSend(entry.roomId) || delivered(entry)) {
drop.push(entry);
return;
}
restore.push(entry);
if (now - entry.ts < OUTBOX_AUTOSEND_MS) autoSend.add(entry.txnId);
});
return { restore, autoSend, drop };
};
export const loadOutbox = (userId: string): OutboxEntry[] => {
try {
return parseOutbox(localStorage.getItem(STORAGE_KEY), userId);
} catch {
return [];
}
};
export const saveOutbox = (userId: string, entries: OutboxEntry[]): void => {
try {
if (entries.length === 0) localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, JSON.stringify({ userId, entries } satisfies Stored));
} catch {
// Storage full or blocked: the outbox is best-effort.
}
};
/** Wipe the outbox (logout): it holds decrypted message content. */
export const clearOutbox = (): void => {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* localStorage unavailable — nothing to clear */
}
};
+8
View File
@@ -898,6 +898,14 @@ globalStyle(
boxShadow: '0 0 10px rgba(255,107,0,0.10)',
},
);
// [Gitea #246] The poll's winning answer (after it ends).
globalStyle(
`body.${lotusTerminalBodyClass} [data-poll-content] [data-poll-answer][data-winner="true"]`,
{
border: '1px solid rgba(0,255,136,0.55) !important',
boxShadow: '0 0 10px rgba(0,255,136,0.10)',
},
);
globalStyle(`body.${lotusTerminalBodyClass} [data-poll-content] [data-poll-content-label]`, {
color: 'rgba(0,212,255,0.60) !important',
opacity: '1 !important',