Compare commits

...
9 Commits
Author SHA1 Message Date
jaredandClaude Opus 5 e078a2cc10 privacy: wipe the local status-message mirror on logout (#204)
CI / Build & Quality Checks (push) Successful in 1m35s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Successful in 3m23s
Of the two plaintext-localStorage items in #204, composer drafts were already
swept on logout (#41); the presence status message + expiry were deliberately
kept. They are PII with an authoritative copy in server presence, so sweep
them too. The test's localStorage mock now enumerates keys like the real
Storage object, so the prefix sweeps (drafts, status) are actually exercised —
the old 'draft preserved' assertion only passed because Object.keys() saw
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:38:47 -04:00
jaredandClaude Opus 5 c921f11521 feat(calls): 'Missed call from X' line in the timeline (#162)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
The ring event (m.rtc.notification) now renders — only once the ring is over
(its lifetime lapsed or the caller hung up) and only if we did not join between
the ring and the hang-up — as a red 'Missed call from bob' line with the ring's
time, in the same style as the call-membership summaries. Derived entirely from
events already in the timeline; nothing is sent; a call we answered shows
nothing extra, and a later separate call cannot retro-actively 'answer' an
earlier missed one.

Verified headless in a DM: ring ignored → line appears once bob hangs up; a
second call that alice answers adds no line and leaves the first one in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:35:02 -04:00
jaredandClaude Opus 5 0e2671891f feat(e2ee): undecryptable placeholder says why and offers the fix (#159)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
'Unable to decrypt message' now carries one sentence per matrix-js-sdk
DecryptionFailureCode (describeDecryptionFailure, unit-tested against every
code so no raw code can leak into the copy) and, where something fixes it,
one button: no key backup → 'Set up key backup'; backup exists but this
session can't open it / key withheld for an unverified session → 'Unlock key
backup' / 'Verify this session' (both open Settings → Devices via a new
settingsRequestAtom that SettingsTab consumes); backup working or unknown
session (rust-crypto re-requests keys itself) → 'Retry', which re-runs
decryptEventIfNeeded. Sender-side problems are plain text. The raw code sits
in the placeholder's tooltip for support.

Verified headless on a fresh session in the encrypted seed room: each event
shows 'Sent before you signed in here, and no key backup exists…' with
tooltip HISTORICAL_MESSAGE_NO_KEY_BACKUP; the button opens Settings → Devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:23:25 -04:00
jaredandClaude Opus 5 6f25035341 privacy: self-host the VT323 font instead of Google Fonts (#214)
CI / Build & Quality Checks (push) Failing after 1m34s
CI / Trigger Desktop Build (push) Skipped
CI / Docker image build & smoke test (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
Every page load contacted fonts.googleapis.com / fonts.gstatic.com (IP +
referrer to Google, GDPR exposure) for the Lotus Terminal font. VT323 (OFL)
now ships in public/fonts as latin + latin-ext woff2 subsets with @font-face
declarations in the existing custom-fonts.css; the three Google tags are gone
from index.html. Also deletes three unreferenced JetBrainsMono-*.woff2 files
that were actually 1.6 kB Google 404 HTML pages.

Verified headless: no third-party host on a plain load; document.fonts.load
('16px VT323') resolves from /fonts/VT323-latin.woff2. CSP tightening
(nginx + tauri) follows in their repos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:16:32 -04:00
jaredandClaude Opus 5 f111b3c9af feat(media): timeline images open the gallery lightbox at that event (#219)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Clicking an image in the room or thread timeline now opens the same viewer
the media gallery uses — dark backdrop, sender + date, 1/N counter, ←/→ across
the room's media, +/-/0 and wheel/double-click zoom, download, jump to
message — positioned at the clicked event. RoomMediaLightbox feeds it from the
detached media timeline (#163); when the event isn't in the loaded window it
pages back (bounded, 6 pages) and shows the clicked image alone meanwhile, so
the viewer opens instantly. ImageContent gains onOpenViewer (RenderMessageContent
passes onOpenImageViewer); its built-in viewer remains for stickers, search
results, pins, notifications and avatars.

Verified headless: click → 'Media viewer' dialog focused, counter 1/2, '+' →
120 %, Jump to message present, Esc closes; an older image 10 pages up →
found at 10/45 after paging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:13:28 -04:00
jaredandClaude Opus 5 8b1c9fa610 fix(threads): thread panel no longer squeezes the composers (#218)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Two causes. (1) Three right-hand columns don't fit under ~1500 px: the member
drawer now yields to an open content panel (thread / gallery / widgets /
threads list) on desktops ≤ 1500 px and returns when it closes, like Element.
(2) The thread composer rendered the full ten-button toolbar inside a 360 px
panel, leaving 24 px for the input ('S…') at every width — RoomInput gains a
compactLayout prop (the phone one-row '+ | input | emoji | send' layout) and
ThreadPanel uses it.

Measured headless: 1400 px thread open → drawer hidden, composers 328/168 px
(was 422 → 'Send a mes…' + 24 px); 1600 px → drawer stays, thread composer 168 px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:05:10 -04:00
jaredandClaude Opus 5 8658ec05c3 perf: lazy-load seasonal overlays as their own chunks (#167)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
All 11 seasonal overlays (particles + vanilla-extract keyframes) were in the
main JS and CSS for everyone year-round. Each is now a React.lazy chunk fetched
only when that season is active (auto date or override) or previewed in
Settings → Appearance; the schedule/override decision stays in the main bundle.

check-bundle-size: largest chunk 332.4 kB → 320.1 kB gzip; main CSS 68.4 kB →
56.5 kB; total gzip 1551.6 kB → 1561.3 kB (per-chunk overhead, only paid when
a season is on). Verified headless: no theme chunk requested at startup with
'auto' out of season; the Settings grid fetches the 11 previews; picking
Halloween renders the full-screen overlay (20 particle nodes).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:59:28 -04:00
jaredandClaude Opus 5 2e7915d086 feat(a11y): screen-reader announcements for call events (#168)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
A visually hidden aria-live=polite region (rendered by CallEmbedProvider so it
outlives the embed) announces joins/leaves — batched over 1.5 s: 'alice
joined', 'alice and bob joined', '3 people joined' — your own mute/unmute,
deafen/undeafen and screenshare start/stop, and 'Call ended'. Nothing visible,
nothing audible for anyone else, no setting. Verified headless by observing
the region: bob joined → You are muted → … → bob left → Call ended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:55:01 -04:00
jaredandClaude Opus 5 84c906fe33 feat: warn when the local clock is far off the homeserver's (#158)
Incident 2026-09-17: a wrong Windows clock broke calls and media keys while
the server answered 200 to everything, with no hint in the UI.

Measurement needs no extra requests and no CORS-exposed headers: every live
event carries origin_server_ts and unsigned.age (our server's now − ts at
response time), so localTimestamp − origin_server_ts is the skew. Only
RoomEvent.Timeline live events count (cache replays have stale age and are
already flagged liveEvent=false by the SDK); the initial network sync
qualifies, so a wrong clock is flagged within seconds of startup. Median of
the last 5 samples, ≥3 needed; warn at |skew| > 30 s, clear below 15 s.

UI: a banner in the sync-status slot — "Your computer's clock is 14 minutes
ahead of the server. Encrypted messages and voice calls will fail until it is
fixed." with a per-OS How-to-fix hint and Dismiss for 24 h — plus the same
line in the call status bar while in a call. Never auto-corrects anything.

Unit-tested (median, hysteresis, stale-age rejection, wording); verified
headless with Playwright's clock skewed +14 min and −3 h (banner, hint,
in-call line, dismiss) and in sync (nothing shown).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:52:03 -04:00
35 changed files with 1107 additions and 95 deletions
-3
View File
@@ -27,9 +27,6 @@
<meta name="theme-color" content="#000000" />
<meta name="color-scheme" content="dark light" />
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/fonts/custom-fonts.css" />
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
-11
View File
@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>That’s an error.</ins>
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
-11
View File
@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>That’s an error.</ins>
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
-11
View File
@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>That’s an error.</ins>
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
Binary file not shown.
Binary file not shown.
+22
View File
@@ -49,3 +49,25 @@
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* VT323 (OFL) for the Lotus Terminal theme — was Google Fonts (Gitea #214) */
@font-face {
font-family: 'VT323';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/VT323-latin-ext.woff2') format('woff2');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
@font-face {
font-family: 'VT323';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/VT323-latin.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+15
View File
@@ -47,6 +47,9 @@ import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ri
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
import { callAnnouncementAtom } from '../state/callAnnouncement';
import { SrOnly } from '../features/call-status/styles.css';
import { useCallHotkeys } from '../hooks/useCallHotkeys';
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
import { useCallQuality } from '../hooks/useCallQuality';
@@ -676,6 +679,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
);
}
// [Gitea #168] Lives outside the embed so "Call ended" is still announced.
function CallAnnouncementRegion() {
const announcement = useAtomValue(callAnnouncementAtom);
return (
<span className={SrOnly} role="status" aria-live="polite" aria-atomic="true">
{announcement}
</span>
);
}
function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
@@ -687,6 +700,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
useAfkAutoMute(joined ? embed : undefined);
useCallJoinLeaveSounds(embed);
useCallPolicyRevokedToast(embed, joined);
useCallAnnouncements(embed, joined);
useCallThemeSync(embed);
useCallQuality(embed);
useCallHangupEvent(
@@ -1230,6 +1244,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
return (
<CallEmbedContextProvider value={callEmbed}>
{callEmbed && <CallUtils embed={callEmbed} joined={joined} />}
<CallAnnouncementRegion />
<CallEmbedRefContextProvider value={callEmbedRef}>
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
{children}
+9 -2
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { MsgType } from 'matrix-js-sdk';
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { config, Text } from 'folds';
@@ -70,6 +70,10 @@ type RenderMessageContentProps = {
linkifyOpts: Opts;
outlineAttachment?: boolean;
eventId?: string;
/** [Gitea #219] Open the room's shared media lightbox at this event. */
onOpenImageViewer?: () => void;
/** [Gitea #159] The event, for the undecryptable placeholder's reason + retry. */
mEvent?: MatrixEvent;
};
export function RenderMessageContent({
displayName,
@@ -85,6 +89,8 @@ export function RenderMessageContent({
linkifyOpts,
outlineAttachment,
eventId,
onOpenImageViewer,
mEvent,
}: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => {
// Cap previews per message so a link-dump doesn't spawn dozens of preview
@@ -241,6 +247,7 @@ export function RenderMessageContent({
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
onOpenViewer={onOpenImageViewer}
/>
)}
outlined={outlineAttachment}
@@ -342,7 +349,7 @@ export function RenderMessageContent({
}
if (msgType === 'm.bad.encrypted') {
return <MBadEncrypted />;
return <MBadEncrypted mEvent={mEvent} />;
}
if (msgType === 'm.key.verification.request') {
@@ -1,7 +1,11 @@
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds';
import { IContent } from 'matrix-js-sdk';
import { IContent, MatrixEvent } from 'matrix-js-sdk';
import { useSetAtom } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { settingsRequestAtom } from '../../state/settingsRequest';
import { describeDecryptionFailure } from '../../utils/decryptionReason';
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
import { trimReplyFromBody } from '../../utils/room';
import { MessageTextBody } from './layout';
@@ -120,11 +124,59 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
);
}
export function MBadEncrypted() {
type MBadEncryptedProps = {
mEvent?: MatrixEvent;
};
/**
* [Gitea #159] The undecryptable placeholder says WHY and offers the one
* action that fixes it (set up key backup / verify this session / retry).
* The raw SDK code stays in the tooltip for support.
*/
export function MBadEncrypted({ mEvent }: MBadEncryptedProps) {
const mx = useMatrixClient();
const requestSettings = useSetAtom(settingsRequestAtom);
const [retrying, setRetrying] = useState(false);
const code = mEvent?.decryptionFailureReason ?? null;
const reason = describeDecryptionFailure(code);
const handleAction = async () => {
if (reason.action === 'setup-backup' || reason.action === 'verify-session') {
requestSettings('devices');
return;
}
if (reason.action === 'retry' && mEvent) {
setRetrying(true);
try {
await mx.decryptEventIfNeeded(mEvent, { forceRedecryptIfUntrusted: true });
} catch {
// still undecryptable — the placeholder re-renders with the current reason
} finally {
setRetrying(false);
}
}
};
return (
<Text>
<MessageBadEncryptedContent />
</Text>
<Box direction="Column" gap="100" alignItems="Start">
<Text>
<MessageBadEncryptedContent title={code ?? undefined} />
</Text>
<Text size="T200" priority="300">
{reason.text}
</Text>
{reason.action !== 'none' && (
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={handleAction}
disabled={retrying}
>
<Text size="B300">{retrying ? 'Retrying…' : reason.actionLabel}</Text>
</Button>
)}
</Box>
);
}
@@ -59,6 +59,9 @@ export type ImageContentProps = {
spoilerReason?: string;
renderViewer: (props: RenderViewerProps) => ReactNode;
renderImage: (props: RenderImageProps) => ReactNode;
// [Gitea #219] When given, a click opens THIS instead of the built-in
// viewer — the room timeline hands it to the shared media lightbox.
onOpenViewer?: () => void;
};
export const ImageContent = as<'div', ImageContentProps>(
(
@@ -74,6 +77,7 @@ export const ImageContent = as<'div', ImageContentProps>(
spoilerReason,
renderViewer,
renderImage,
onOpenViewer,
...props
},
ref,
@@ -182,7 +186,7 @@ export const ImageContent = as<'div', ImageContentProps>(
src: srcState.data,
onLoad: handleLoad,
onError: handleError,
onClick: () => setViewer(true),
onClick: () => (onOpenViewer ? onOpenViewer() : setViewer(true)),
tabIndex: 0,
})}
</Box>
+42 -14
View File
@@ -1,21 +1,49 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { Suspense, useEffect, useMemo, useState } from 'react';
import { useAtomValue } from 'jotai';
import { settingsAtom } from '../../state/settings';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { zIndices } from '../../styles/zIndex';
import { SeasonTheme } from './types';
import { resolveSeasonTheme } from './seasonSchedule';
import { HalloweenOverlay } from './themes/Halloween';
import { ChristmasOverlay } from './themes/Christmas';
import { NewYearOverlay } from './themes/NewYear';
import { AutumnOverlay } from './themes/Autumn';
import { AprilFoolsOverlay } from './themes/AprilFools';
import { LunarNewYearOverlay } from './themes/LunarNewYear';
import { ValentinesOverlay } from './themes/Valentines';
import { StPatricksOverlay } from './themes/StPatricks';
import { EarthDayOverlay } from './themes/EarthDay';
import { DeepSpaceOverlay } from './themes/DeepSpace';
import { ArcadeOverlay } from './themes/Arcade';
// [Gitea #167] Each overlay (its particles + keyframes) is its own chunk,
// fetched only when that season is active or previewed in Settings — the
// date/override decision stays in the main bundle.
const lazyOverlay = (load: () => Promise<{ default: React.ComponentType<{ reduced: boolean }> }>) =>
React.lazy(load);
const HalloweenOverlay = lazyOverlay(() =>
import('./themes/Halloween').then((m) => ({ default: m.HalloweenOverlay })),
);
const ChristmasOverlay = lazyOverlay(() =>
import('./themes/Christmas').then((m) => ({ default: m.ChristmasOverlay })),
);
const NewYearOverlay = lazyOverlay(() =>
import('./themes/NewYear').then((m) => ({ default: m.NewYearOverlay })),
);
const AutumnOverlay = lazyOverlay(() =>
import('./themes/Autumn').then((m) => ({ default: m.AutumnOverlay })),
);
const AprilFoolsOverlay = lazyOverlay(() =>
import('./themes/AprilFools').then((m) => ({ default: m.AprilFoolsOverlay })),
);
const LunarNewYearOverlay = lazyOverlay(() =>
import('./themes/LunarNewYear').then((m) => ({ default: m.LunarNewYearOverlay })),
);
const ValentinesOverlay = lazyOverlay(() =>
import('./themes/Valentines').then((m) => ({ default: m.ValentinesOverlay })),
);
const StPatricksOverlay = lazyOverlay(() =>
import('./themes/StPatricks').then((m) => ({ default: m.StPatricksOverlay })),
);
const EarthDayOverlay = lazyOverlay(() =>
import('./themes/EarthDay').then((m) => ({ default: m.EarthDayOverlay })),
);
const DeepSpaceOverlay = lazyOverlay(() =>
import('./themes/DeepSpace').then((m) => ({ default: m.DeepSpaceOverlay })),
);
const ArcadeOverlay = lazyOverlay(() =>
import('./themes/Arcade').then((m) => ({ default: m.ArcadeOverlay })),
);
// SeasonTheme + the date-window logic now live in leaf modules (single source
// of truth, shared with the settings UI). Re-exported here for existing
@@ -69,7 +97,7 @@ function SeasonalOverlay({ theme, reduced }: { theme: SeasonTheme; reduced: bool
overflow: 'hidden',
}}
>
{buildOverlayContent(theme, reduced)}
<Suspense fallback={null}>{buildOverlayContent(theme, reduced)}</Suspense>
</div>
);
}
@@ -94,7 +122,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
containerType: 'inline-size',
}}
>
{buildOverlayContent(theme, true)}
<Suspense fallback={null}>{buildOverlayContent(theme, true)}</Suspense>
</div>
);
}
+19 -1
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { Box, Spinner } from 'folds';
import { Box, Spinner, Text, color } from 'folds';
import { useAtomValue } from 'jotai';
import classNames from 'classnames';
import { LiveChip } from './LiveChip';
import * as css from './styles.css';
@@ -14,6 +15,8 @@ import { CallEmbed } from '../../plugins/call/CallEmbed';
import { useCallJoined } from '../../hooks/useCallEmbed';
import { useCallSpeakers } from '../../hooks/useCallSpeakers';
import { MemberSpeaking } from './MemberSpeaking';
import { clockSkewAtom } from '../../state/clockSkew';
import { describeSkewVsServer } from '../../utils/clockSkew';
type CallStatusProps = {
callEmbed: CallEmbed;
@@ -26,6 +29,8 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
const screenSize = useScreenSize();
const callJoined = useCallJoined(callEmbed);
const speakers = useCallSpeakers(callEmbed);
// [Gitea #158] Same warning as the top banner, where the user is looking during a call.
const clockSkew = useAtomValue(clockSkewAtom);
const compact = screenSize === ScreenSize.Mobile;
@@ -51,6 +56,19 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
{!compact && (
<>
<CallRoomName room={room} />
{clockSkew.warning && clockSkew.skewMs !== null && (
<>
<StatusDivider />
<Text
size="T200"
truncate
style={{ color: color.Warning.Main }}
title="Fix your computer's clock — calls and encryption depend on it"
>
Clock {describeSkewVsServer(clockSkew.skewMs)} — calls will fail
</Text>
</>
)}
{speakers.size > 0 && (
<>
<StatusDivider />
+32 -24
View File
@@ -173,7 +173,7 @@ function getThumbMxc(mEvent: MatrixEvent): string | undefined {
// ── Lightbox ──────────────────────────────────────────────────────────────────
type LightboxItem = {
export type LightboxItem = {
mxcUrl: string;
encInfo?: IEncryptedFile;
mimeType?: string;
@@ -184,6 +184,35 @@ type LightboxItem = {
eventId: string;
};
/**
* Images + videos of a media timeline as lightbox slots. Shared with the
* timeline's viewer (#219) so both open the same items in the same order.
*/
export function toLightboxItems(room: Room, events: MatrixEvent[]): LightboxItem[] {
return events
.filter((ev) => {
const c = ev.getContent();
if (c.msgtype !== MsgType.Image && c.msgtype !== MsgType.Video) return false;
// Match the grid's guard exactly: tiles without a thumb are not rendered,
// so they must not occupy a lightbox slot either.
return !!getThumbMxc(ev);
})
.map((ev) => {
const c = ev.getContent();
const isEnc = !!c.file;
return {
mxcUrl: c.file?.url ?? c.url ?? '',
encInfo: isEnc ? c.file : undefined,
mimeType: c.info?.mimetype,
msgtype: c.msgtype as MsgType.Image | MsgType.Video,
body: c.body ?? '',
sender: getSenderName(room, ev.getSender() ?? ''),
ts: ev.getTs(),
eventId: ev.getId() ?? '',
};
});
}
function LightboxMedia({
item,
useAuthentication,
@@ -280,7 +309,7 @@ function LightboxMedia({
);
}
function Lightbox({
export function Lightbox({
items,
initialIndex,
useAuthentication,
@@ -765,28 +794,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
}, [canLoadMore, loading, handleLoadMore]);
// Lightbox items (images + videos, flat)
const lightboxItems: LightboxItem[] = events
.filter((ev) => {
const c = ev.getContent();
if (c.msgtype !== MsgType.Image && c.msgtype !== MsgType.Video) return false;
// Match the grid's guard exactly: tiles without a thumb are not rendered,
// so they must not occupy a lightbox slot either.
return !!getThumbMxc(ev);
})
.map((ev) => {
const c = ev.getContent();
const isEnc = !!c.file;
return {
mxcUrl: c.file?.url ?? c.url ?? '',
encInfo: isEnc ? c.file : undefined,
mimeType: c.info?.mimetype,
msgtype: c.msgtype as MsgType.Image | MsgType.Video,
body: c.body ?? '',
sender: getSenderName(room, ev.getSender() ?? ''),
ts: ev.getTs(),
eventId: ev.getId() ?? '',
};
});
const lightboxItems: LightboxItem[] = toLightboxItems(room, events);
// Per-tab counts for the tab labels (single pass over the loaded media)
const tabCounts = useMemo(() => {
+8 -1
View File
@@ -11,6 +11,7 @@ import { WidgetsPanel } from './widgets/WidgetsPanel';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useMediaQuery } from '../../hooks/useMediaQuery';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
@@ -150,10 +151,16 @@ export function Room() {
(isDesktop || (!galleryOpen && !widgetsOpen));
// Desktop: the persisted drawer preference. Mobile: a transient panel the
// user opened from the header menu (never the desktop default).
// [Gitea #218] Three right-hand columns (timeline + thread/gallery/widgets
// panel + member drawer) don't fit under ~1500 px — both composers shrank to
// a few characters. Like Element, the member drawer yields to the content
// panel on narrower desktops and comes back when it closes.
const narrowDesktop = useMediaQuery('(max-width: 1500px)');
const contentPanelOpen = showThreadPanel || showGallery || showWidgets || showThreadsList;
const showMembers =
!callView &&
(isDesktop
? isDrawer
? isDrawer && !(narrowDesktop && contentPanelOpen)
: mobileMembersOpen && !activeThreadId && !galleryOpen && !widgetsOpen && !threadsListOpen);
// Leaving a room on a phone closes its member panel.
+13 -2
View File
@@ -169,10 +169,21 @@ interface RoomInputProps {
// message" handler). Threads pass a distinct name so the main timeline's handler
// doesn't fire for the thread composer, and vice-versa.
editableName?: string;
// [Gitea #218] Force the one-row "+ | input | emoji | send" layout regardless
// of viewport — the 360 px thread panel can't fit the full toolbar.
compactLayout?: boolean;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
(
{ editor, fileDropContainerRef, roomId, room, threadRootId, editableName = 'RoomInput' },
{
editor,
fileDropContainerRef,
roomId,
room,
threadRootId,
editableName = 'RoomInput',
compactLayout = false,
},
ref,
) => {
const mx = useMatrixClient();
@@ -254,7 +265,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
// ten controls inline and clips the Send button behind the editor's
// overflow:hidden. Touch-sized (44px) targets stay UA-gated: a narrow
// desktop window still has a mouse.
const compact = isMobile || screenSize === ScreenSize.Mobile;
const compact = isMobile || screenSize === ScreenSize.Mobile || compactLayout;
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
// Both gates on purpose: the class covers phone-width viewports (matches
// VoiceMessageRecorder's idle button, which lives in the same row), the
@@ -0,0 +1,73 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomMediaTimeline } from '../../hooks/useRoomMediaTimeline';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { Lightbox, toLightboxItems } from './MediaGallery';
// How many media pages to walk back looking for the clicked event before
// settling for a single-item viewer.
const MAX_SEARCH_PAGES = 6;
type RoomMediaLightboxProps = {
room: Room;
eventId: string;
onClose: () => void;
};
/**
* [Gitea #219] The room timeline's image viewer is the gallery lightbox,
* opened at the clicked event: same dark backdrop, sender + date, 1/N counter,
* ←/→ across the room's media, zoom keys, download and "jump to message".
*
* Items come from the same detached media timeline the gallery uses (#163).
* It starts at the live end, so an older image may not be loaded yet: we page
* back a bounded number of times looking for it and meanwhile show the clicked
* image on its own, so the viewer never waits on the network to open.
*/
export function RoomMediaLightbox({ room, eventId, onClose }: RoomMediaLightboxProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { navigateRoom } = useRoomNavigate();
const { events, loadMore, loading, canLoadMore } = useRoomMediaTimeline(mx, room);
const items = useMemo(() => toLightboxItems(room, events), [room, events]);
const index = items.findIndex((it) => it.eventId === eventId);
const pagesRef = useRef(0);
useEffect(() => {
if (index !== -1 || loading || !canLoadMore || pagesRef.current >= MAX_SEARCH_PAGES) return;
pagesRef.current += 1;
loadMore();
}, [index, loading, canLoadMore, loadMore]);
// Until the event is in the loaded window, show just the clicked media.
const fallbackItems = useMemo(() => {
const ev: MatrixEvent | undefined = room.findEventById(eventId) ?? undefined;
return ev ? toLightboxItems(room, [ev]) : [];
}, [room, eventId]);
const handleJump = useCallback(
(id: string) => {
onClose();
navigateRoom(room.roomId, id);
},
[onClose, navigateRoom, room.roomId],
);
const found = index !== -1;
const shownItems = found ? items : fallbackItems;
if (shownItems.length === 0) return null;
return (
<Lightbox
// Remount when the full list arrives so the index and zoom reset cleanly.
key={found ? 'room' : 'single'}
items={shownItems}
initialIndex={found ? index : 0}
useAuthentication={useAuthentication}
onClose={onClose}
onJump={handleJump}
/>
);
}
+93
View File
@@ -14,6 +14,7 @@ import {
Direction,
EventTimeline,
EventTimelineSet,
EventType,
EventTimelineSetHandlerMap,
IContent,
MatrixClient,
@@ -115,6 +116,7 @@ import { RetentionContent, isExpired } from '../../utils/retention';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { RoomMediaLightbox } from './RoomMediaLightbox';
import { Image } from '../../components/media';
import { ImageViewer } from '../../components/image-viewer';
import { roomToParentsAtom } from '../../state/room/roomToParents';
@@ -523,6 +525,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
const [editId, setEditId] = useState<string>();
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
// [Gitea #219] Timeline images open the shared media lightbox at that event.
const [lightboxEventId, setLightboxEventId] = useState<string | undefined>();
const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
@@ -1231,6 +1235,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId}
onOpenImageViewer={() => setLightboxEventId(mEventId)}
mEvent={mEvent}
/>
)}
</Message>
@@ -1360,6 +1366,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId}
onOpenImageViewer={() => setLightboxEventId(mEventId)}
mEvent={mEvent}
/>
);
}
@@ -1901,6 +1909,84 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Event>
);
},
// [Gitea #162] A ring (m.rtc.notification) that we neither answered nor
// declined, and whose ring window has passed, renders as a local-only
// "Missed call from X" line. Derived from events already in the
// timeline; nothing is sent. Disappears if our own call membership
// follows within the ring window (we picked up).
[EventType.RTCNotification]: (mEventId, mEvent, item) => {
const senderId = mEvent.getSender() ?? '';
if (!senderId || senderId === mx.getUserId()) return null;
const content = mEvent.getContent();
const mention =
content['m.mentions']?.room ||
content['m.mentions']?.user_ids?.includes(mx.getSafeUserId());
if (!mention) return null;
const lifetime = Math.min(
typeof content.lifetime === 'number' ? content.lifetime : 30000,
120000,
);
const liveEvents = room.getLiveTimeline().getEvents();
const memberEventsAfter = liveEvents.filter(
(ev) => ev.getType() === StateEvent.GroupCallMemberPrefix && ev.getTs() >= mEvent.getTs(),
);
// The ring is over once its window lapsed or the caller hung up.
const callerEndTs = memberEventsAfter
.find((ev) => ev.getSender() === senderId && !ev.getContent().application)
?.getTs();
if (callerEndTs === undefined && Date.now() < mEvent.getTs() + lifetime) return null;
// "Answered" = our own join between the ring and the caller hanging up
// (a later, separate call must not retro-actively answer this one).
const windowEnd = Math.min(
mEvent.getTs() + lifetime + 60_000,
callerEndTs !== undefined ? callerEndTs + 5_000 : Infinity,
);
const answered = memberEventsAfter.some(
(ev) =>
ev.getSender() === mx.getUserId() &&
ev.getTs() <= windowEnd &&
!!ev.getContent().application,
);
if (answered) return null;
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={false}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.PhoneDown}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" style={{ color: color.Critical.Main }}>
Missed {content['m.call.intent'] === 'video' ? 'video ' : ''}call from{' '}
<b>{senderName}</b>
</Text>
</Box>
}
/>
</Event>
);
},
[StateEvent.GroupCallMemberPrefix]: (mEventId, mEvent, item) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
@@ -2317,6 +2403,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
onClose={() => setEditHistoryEvent(undefined)}
/>
)}
{lightboxEventId && (
<RoomMediaLightbox
room={room}
eventId={lightboxEventId}
onClose={() => setLightboxEventId(undefined)}
/>
)}
</>
);
}
@@ -200,6 +200,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
editor={editor}
editableName="ThreadInput"
fileDropContainerRef={fileDropContainerRef}
compactLayout
/>
</Box>
</>
@@ -72,6 +72,7 @@ import { useSetting } from '../../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../../state/settings';
import { Message, Reactions, EncryptedContent } from '../message';
import { RenderMessageContent } from '../../../components/RenderMessageContent';
import { RoomMediaLightbox } from '../RoomMediaLightbox';
import { Image } from '../../../components/media';
import { ImageViewer } from '../../../components/image-viewer';
import * as css from './ThreadTimeline.css';
@@ -309,6 +310,8 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const [editId, setEditId] = useState<string>();
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
// [Gitea #219] Thread images open the room's shared media lightbox too.
const [lightboxEventId, setLightboxEventId] = useState<string | undefined>();
const linkifyOpts = useMemo<LinkifyOpts>(
() => ({
@@ -711,6 +714,8 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId}
onOpenImageViewer={() => setLightboxEventId(mEventId)}
mEvent={mEvent}
/>
);
};
@@ -1051,6 +1056,13 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
onClose={() => setEditHistoryEvent(undefined)}
/>
)}
{lightboxEventId && (
<RoomMediaLightbox
room={room}
eventId={lightboxEventId}
onClose={() => setLightboxEventId(undefined)}
/>
)}
</Box>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useCallback, useEffect, useRef } from 'react';
import { useSetAtom } from 'jotai';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import { CallEmbed, useCallControlState } from '../plugins/call';
import { useCallMembersChange, useCallSession } from './useCall';
import { useMatrixClient } from './useMatrixClient';
import { getMemberName } from '../utils/room';
import { callAnnouncementAtom } from '../state/callAnnouncement';
const BURST_MS = 1500;
const listNames = (names: string[], verb: string): string => {
if (names.length === 0) return '';
if (names.length === 1) return `${names[0]} ${verb}`;
if (names.length === 2) return `${names[0]} and ${names[1]} ${verb}`;
return `${names.length} people ${verb}`;
};
/**
* [Gitea #168] Screen-reader announcements for call events, via the
* aria-live region CallEmbedProvider renders. Nothing visible, nothing
* audible for anyone else. Joins/leaves within 1.5 s are batched ("3 people
* joined"); your own mute / deafen / screenshare changes are announced as
* they happen; "Call ended" fires when the embed goes away.
*/
export function useCallAnnouncements(embed: CallEmbed, joined: boolean): void {
const mx = useMatrixClient();
const setAnnouncement = useSetAtom(callAnnouncementAtom);
const session = useCallSession(embed.room);
const { microphone, sound, screenshare } = useCallControlState(embed.control);
const announce = useCallback(
(text: string) => {
// Re-announce identical text by nudging it: aria-live only speaks changes.
setAnnouncement((prev) => (prev === text ? `${text} ` : text));
},
[setAnnouncement],
);
// --- joins / leaves, batched per user
const prevUsersRef = useRef<Set<string> | null>(null);
const pending = useRef<{ joined: Set<string>; left: Set<string>; timer?: number }>({
joined: new Set(),
left: new Set(),
});
useEffect(() => {
prevUsersRef.current = new Set(session.memberships.map((m) => m.sender ?? ''));
}, [session]);
const flush = useCallback(() => {
const { joined: j, left: l } = pending.current;
const room = embed.room;
const parts = [
listNames(
Array.from(j, (u) => getMemberName(room, u)),
'joined',
),
listNames(
Array.from(l, (u) => getMemberName(room, u)),
'left',
),
].filter(Boolean);
j.clear();
l.clear();
pending.current.timer = undefined;
if (parts.length) announce(parts.join('. '));
}, [announce, embed.room]);
useCallMembersChange(
session,
useCallback(
(members: CallMembership[]) => {
const next = new Set(members.map((m) => m.sender ?? ''));
const prev = prevUsersRef.current ?? next;
prevUsersRef.current = next;
if (!joined) return;
const me = mx.getSafeUserId();
next.forEach((u) => {
if (!prev.has(u) && u !== me) {
pending.current.left.delete(u);
pending.current.joined.add(u);
}
});
prev.forEach((u) => {
if (!next.has(u) && u !== me) {
pending.current.joined.delete(u);
pending.current.left.add(u);
}
});
if (pending.current.timer === undefined) {
pending.current.timer = window.setTimeout(flush, BURST_MS);
}
},
[joined, mx, flush],
),
);
// --- own state
const first = useRef(true);
const prevOwn = useRef({ microphone, sound, screenshare });
useEffect(() => {
const was = prevOwn.current;
prevOwn.current = { microphone, sound, screenshare };
if (!joined) return;
if (first.current) {
first.current = false;
return;
}
if (was.microphone !== microphone) announce(microphone ? 'You are unmuted' : 'You are muted');
if (was.sound !== sound) announce(sound ? 'You are undeafened' : 'You are deafened');
if (was.screenshare !== screenshare) {
announce(screenshare ? 'You started sharing your screen' : 'You stopped sharing your screen');
}
}, [microphone, sound, screenshare, joined, announce]);
// --- call ended
const wasJoined = useRef(false);
useEffect(() => {
if (joined) wasJoined.current = true;
}, [joined]);
useEffect(
() => () => {
if (pending.current.timer !== undefined) window.clearTimeout(pending.current.timer);
if (wasJoined.current) announce('Call ended');
},
[announce],
);
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, useState } from 'react';
const read = (query: string): boolean =>
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia(query).matches;
/** Reactive `window.matchMedia(query).matches`. SSR-safe (false). */
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(() => read(query));
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return undefined;
}
const mql = window.matchMedia(query);
const onChange = (event: MediaQueryListEvent) => setMatches(event.matches);
setMatches(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, [query]);
return matches;
}
@@ -31,6 +31,8 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
import { useSearchCacheInvalidation } from '../../utils/searchCacheInvalidation';
import { ClockSkewMonitor } from '../../utils/clockSkew';
import { clockSkewAtom } from '../../state/clockSkew';
import {
getDirectRoomPath,
getHomeRoomPath,
@@ -934,6 +936,39 @@ function SearchCacheInvalidationFeature(): null {
return null;
}
/**
* [Gitea #158] Feeds every LIVE timeline event (origin_server_ts + unsigned.age
* vs the SDK's localTimestamp) to the skew monitor. `data.liveEvent` is false
* for events replayed from the IndexedDB cache — whose `age` is stale — so
* only network deliveries (including the initial sync) count.
*/
function ClockSkewFeature() {
const mx = useMatrixClient();
const setSkew = useSetAtom(clockSkewAtom);
useEffect(() => {
const monitor = new ClockSkewMonitor();
const unsub = monitor.subscribe(setSkew);
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (
mEvent,
_room,
_toStart,
_removed,
data,
) => {
if (!data.liveEvent) return;
monitor.sample(mEvent.getTs(), mEvent.getAge(), mEvent.localTimestamp);
};
mx.on(RoomEvent.Timeline, onTimeline);
return () => {
mx.off(RoomEvent.Timeline, onTimeline);
unsub();
};
}, [mx, setSkew]);
return null;
}
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return (
<>
@@ -947,6 +982,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<PwaInstallFeature />
<FaviconUpdater />
<PresenceUpdater />
<ClockSkewFeature />
<MuteTimerRestore />
<StatusExpiryMonitor />
<InviteNotifications />
+2
View File
@@ -43,6 +43,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { ClockSkewBanner } from './ClockSkewBanner';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { pushSessionToSW } from '../../../sw-session';
@@ -246,6 +247,7 @@ export function ClientRoot({ children }: ClientRootProps) {
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
<SpecVersions baseUrl={baseUrl!}>
{mx && !syncError && <SyncStatus mx={mx} />}
{mx && !syncError && <ClockSkewBanner />}
{loading && <ClientRootOptions mx={mx} />}
{(loadState.status === AsyncStatus.Error ||
startState.status === AsyncStatus.Error ||
+82
View File
@@ -0,0 +1,82 @@
import React, { useCallback, useState } from 'react';
import { useAtomValue } from 'jotai';
import { Box, Button, config, Line, Text } from 'folds';
import { clockSkewAtom } from '../../state/clockSkew';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { clockFixHint, describeSkewVsServer, detectClockFixPlatform } from '../../utils/clockSkew';
const DISMISS_KEY = 'lotus-clock-skew-dismissed-until';
const DISMISS_MS = 24 * 60 * 60 * 1000;
const readDismissedUntil = (): number => {
try {
const raw = localStorage.getItem(DISMISS_KEY);
const n = raw ? Number(raw) : 0;
return Number.isFinite(n) ? n : 0;
} catch {
return 0;
}
};
/**
* [Gitea #158] "Your computer'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.
*/
export function ClockSkewBanner() {
const { skewMs, warning } = useAtomValue(clockSkewAtom);
const [dismissedUntil, setDismissedUntil] = useState(readDismissedUntil);
const [showHint, setShowHint] = useState(false);
const dismiss = useCallback(() => {
const until = Date.now() + DISMISS_MS;
try {
localStorage.setItem(DISMISS_KEY, String(until));
} catch {
// storage unavailable — dismiss for this session only
}
setDismissedUntil(until);
}, []);
if (!warning || skewMs === null || Date.now() < dismissedUntil) return null;
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: 'Warning' })}
style={{ padding: `${config.space.S100} ${config.space.S300}` }}
direction="Column"
alignItems="Center"
gap="100"
role="alert"
>
<Box alignItems="Center" gap="300" wrap="Wrap" justifyContent="Center">
<Text size="L400" align="Center">
Your computer&apos;s clock is <b>{describeSkewVsServer(skewMs)}</b>. Encrypted messages
and voice calls will fail until it is fixed.
</Text>
<Button
size="300"
variant="Warning"
fill="Soft"
radii="300"
onClick={() => setShowHint((v) => !v)}
aria-expanded={showHint}
>
<Text size="B300">How to fix</Text>
</Button>
<Button size="300" variant="Warning" fill="None" radii="300" onClick={dismiss}>
<Text size="B300">Dismiss for 24 h</Text>
</Button>
</Box>
{showHint && (
<Text size="T200" align="Center">
{clockFixHint(detectClockFixPlatform(navigator.userAgent))}
</Text>
)}
</Box>
<Line variant="Warning" size="300" />
</Box>
);
}
+20 -3
View File
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useAtom } from 'jotai';
import {
Badge,
Box,
@@ -23,7 +24,8 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { nameInitials } from '../../../utils/common';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Settings } from '../../../features/settings';
import { Settings, SettingsPages } from '../../../features/settings';
import { settingsRequestAtom } from '../../../state/settingsRequest';
import { useUserProfile } from '../../../hooks/useUserProfile';
import { Modal500 } from '../../../components/Modal500';
import { useSetting } from '../../../state/hooks/settings';
@@ -172,6 +174,21 @@ export function SettingsTab() {
const profile = useUserProfile(userId);
const [settingsOpen, setSettingsOpen] = useState(false);
// [Gitea #159] Programmatic "open settings at page X" requests.
const [settingsRequest, setSettingsRequest] = useAtom(settingsRequestAtom);
const [initialPage, setInitialPage] = useState<SettingsPages | undefined>();
useEffect(() => {
if (!settingsRequest) return;
const page = {
general: SettingsPages.GeneralPage,
account: SettingsPages.AccountPage,
notifications: SettingsPages.NotificationPage,
devices: SettingsPages.DevicesPage,
}[settingsRequest];
setInitialPage(page);
setSettingsOpen(true);
setSettingsRequest(null);
}, [settingsRequest, setSettingsRequest]);
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
@@ -201,7 +218,7 @@ export function SettingsTab() {
<PresencePicker />
{settingsOpen && (
<Modal500 requestClose={() => setSettingsOpen(false)}>
<Settings requestClose={() => setSettingsOpen(false)} />
<Settings initialPage={initialPage} requestClose={() => setSettingsOpen(false)} />
</Modal500>
)}
</SidebarItem>
+8
View File
@@ -0,0 +1,8 @@
import { atom } from 'jotai';
/**
* [Gitea #168] Latest screen-reader-only call announcement ("alice joined",
* "You are muted", "Call ended"). Rendered by CallEmbedProvider in an
* aria-live region that outlives the call embed, so "Call ended" is heard.
*/
export const callAnnouncementAtom = atom<string>('');
+5
View File
@@ -0,0 +1,5 @@
import { atom } from 'jotai';
import { ClockSkewState } from '../utils/clockSkew';
/** [Gitea #158] Latest local-vs-homeserver clock skew reading. */
export const clockSkewAtom = atom<ClockSkewState>({ skewMs: null, warning: false });
+17 -4
View File
@@ -6,7 +6,9 @@ import assert from 'node:assert/strict';
// hoist above the mock).
const removed: string[] = [];
const store = new Map<string, string>();
(globalThis as { localStorage?: unknown }).localStorage = {
// A Proxy so `Object.keys(localStorage)` (used by the prefix sweeps) sees the
// stored keys, like the real Storage object.
const api = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => {
store.set(k, v);
@@ -16,6 +18,13 @@ const store = new Map<string, string>();
store.delete(k);
},
};
(globalThis as { localStorage?: unknown }).localStorage = new Proxy(api, {
ownKeys: () => Array.from(store.keys()),
getOwnPropertyDescriptor: (target, key) =>
typeof key === 'string' && store.has(key)
? { value: store.get(key), enumerable: true, configurable: true, writable: true }
: Object.getOwnPropertyDescriptor(target, key),
});
const { clearPlaintextCaches } = await import('./plaintextCaches');
@@ -56,15 +65,19 @@ test('clearPlaintextCaches clears the per-user nav-path store only when given a
assert.ok(removed.includes('navToActivePath@me:server'), 'nav path cleared with userId');
});
test('clearPlaintextCaches does NOT touch drafts or session keys', () => {
test('clearPlaintextCaches wipes drafts (#41) and the status message (#204) but not session keys', () => {
store.clear();
store.set('draft-msg-!room:server', '{"body":"unsent"}');
store.set('lotus-status-msg-@me:server', 'at the dentist');
store.set('lotus-status-expiry-@me:server', '123');
store.set('cinny_session', '{"accessToken":"x"}');
removed.length = 0;
clearPlaintextCaches('@me:server');
assert.ok(!removed.includes('draft-msg-!room:server'), 'draft preserved (N98)');
assert.ok(removed.includes('draft-msg-!room:server'), 'draft cleared');
assert.ok(removed.includes('lotus-status-msg-@me:server'), 'status message cleared');
assert.ok(removed.includes('lotus-status-expiry-@me:server'), 'status expiry cleared');
assert.ok(!removed.includes('cinny_session'), 'session key not this module’s concern');
assert.ok(store.has('draft-msg-!room:server'));
assert.ok(store.has('cinny_session'));
});
+27 -2
View File
@@ -54,12 +54,36 @@ const clearMsgDrafts = (): void => {
* bookmarks, user notes, status presets — themselves plaintext) → wiped by
* `mx.clearStores()` on both logout paths
* - the opt-in encrypted-search index (IndexedDB) → `deleteSearchCacheDatabase()`
* - the presence status message (`lotus-status-msg-*`) is deliberately
* preserved across a normal logout; clearing it is a separate product decision
* - (the presence status message + expiry, `lotus-status-msg-*` /
* `lotus-status-expiry-*`, used to be preserved; since [Gitea #204] they are
* swept with the rest — the server-side presence status survives, so a
* re-login loses nothing)
* - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed
* nav/space categories, `cinny_oidc_dynamic_clients`) is treated as
* preferences, not swept here
*/
/**
* [Gitea #204] The local mirror of the user's status message (+ its expiry) is
* PII in plaintext; the authoritative copy lives in server presence.
*/
const clearStatusMessage = (): void => {
let keys: string[];
try {
keys = Object.keys(localStorage);
} catch {
return;
}
keys.forEach((key) => {
if (key.startsWith('lotus-status-msg-') || key.startsWith('lotus-status-expiry-')) {
try {
localStorage.removeItem(key);
} catch {
// best-effort
}
}
});
};
export const clearPlaintextCaches = (userId?: string): void => {
clearTranslationCache();
clearScheduledMessages();
@@ -68,5 +92,6 @@ export const clearPlaintextCaches = (userId?: string): void => {
clearRecentGifs();
clearRecentStickers();
clearMsgDrafts();
clearStatusMessage();
if (userId) clearNavToActivePathStore(userId);
};
+10
View File
@@ -0,0 +1,10 @@
import { atom } from 'jotai';
export type SettingsRequestPage = 'general' | 'account' | 'notifications' | 'devices';
/**
* Ask the sidebar to open User Settings at a page from anywhere in the app
* (e.g. an undecryptable message's "Set up key backup" button, #159).
* SettingsTab consumes and clears it.
*/
export const settingsRequestAtom = atom<SettingsRequestPage | null>(null);
+83
View File
@@ -0,0 +1,83 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
ClockSkewMonitor,
clockFixHint,
describeSkewVsServer,
detectClockFixPlatform,
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);
test('needs three samples, then reports the median with direction', () => {
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(s.warning, true);
assert.equal(formatSkew(s.skewMs!), '60 seconds ahead');
});
test('one bad sample cannot trip the warning (median) and hysteresis clears only under 15 s', () => {
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);
const w = new ClockSkewMonitor();
[40_000, 41_000, 39_000, 40_000, 40_000].forEach((s) => feed(w, s));
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));
assert.equal(w.getState().warning, true);
[10_000, 10_000, 10_000, 10_000, 10_000].forEach((s) => feed(w, s));
assert.equal(w.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);
assert.equal(m.getState().skewMs, null);
});
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);
assert.deepEqual(seen, [-120_000]);
assert.equal(formatSkew(-120_000), '2 minutes behind');
m.reset();
assert.deepEqual(seen, [-120_000, null]);
});
test('formatSkew picks a sensible unit', () => {
assert.equal(formatSkew(45_000), '45 seconds ahead');
assert.equal(formatSkew(-14 * 60_000), '14 minutes behind');
assert.equal(formatSkew(3 * 3_600_000), '3 hours ahead');
assert.equal(formatSkew(2 * 86_400_000), '2 days ahead');
assert.equal(describeSkewVsServer(-3 * 3_600_000), '3 hours behind the server');
assert.equal(describeSkewVsServer(14 * 60_000), '14 minutes ahead of the server');
});
test('platform hint', () => {
assert.equal(detectClockFixPlatform('Mozilla/5.0 (Windows NT 10.0; Win64; x64)'), 'windows');
assert.equal(
detectClockFixPlatform('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)'),
'ios',
);
assert.equal(detectClockFixPlatform('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)'), 'mac');
assert.match(clockFixHint('windows'), /Sync now/);
});
+145
View File
@@ -0,0 +1,145 @@
/**
* [Gitea #158] Local-clock-vs-homeserver skew detection.
*
* Incident 2026-09-17: a wrong Windows clock made every MatrixRTC membership
* look expired locally (calls failed, media keys rejected) while the server
* answered 200 to everything, and nothing in the UI hinted at the cause.
*
* Measurement needs no extra requests and no CORS-exposed headers: every event
* a `/sync` delivers carries `origin_server_ts` (stamped by its origin server)
* and `unsigned.age` (= OUR server's `now − origin_server_ts` at the moment it
* built the response). So `origin_server_ts + age` is our server's clock at
* response time, and `receivedAt − (origin_server_ts + age)` is our skew plus
* the download latency (tens of ms; ignored). matrix-js-sdk already computes
* `localTimestamp = Date.now() − age` at event construction, so a sample is
* simply `localTimestamp − origin_server_ts`.
*
* Only LIVE events count (`RoomEvent.Timeline` data.liveEvent, which the SDK
* already sets false for events replayed from the IndexedDB cache — those carry
* a stale `age` that would read as hours of skew). The initial network sync's
* events qualify, so a wrong clock is flagged within seconds of startup.
*/
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;
/**
* 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
* own age) — this only rejects garbage.
*/
export const SKEW_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
export type ClockSkewState = {
/** Median of the recent samples, ms; positive = local clock is AHEAD. */
skewMs: number | null;
/** Over the threshold (with hysteresis). */
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;
};
export class ClockSkewMonitor {
private samples: number[] = [];
private state: ClockSkewState = { skewMs: null, warning: false };
private listeners = new Set<(state: ClockSkewState) => void>();
public getState(): ClockSkewState {
return this.state;
}
public subscribe(cb: (state: ClockSkewState) => void): () => void {
this.listeners.add(cb);
return () => {
this.listeners.delete(cb);
};
}
/**
* Feed one live event. `originServerTs` + `age` come from the event;
* `localTimestamp` is the SDK's `Date.now() − age` at construction.
* Returns the new state (unchanged object when nothing moved).
*/
public sample(
originServerTs: number,
age: number | undefined,
localTimestamp: number,
): 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();
if (this.samples.length < SKEW_MIN_SAMPLES) return this.state;
const skewMs = median(this.samples);
const abs = Math.abs(skewMs);
const warning = this.state.warning ? abs >= SKEW_CLEAR_MS : abs > SKEW_WARN_MS;
if (skewMs === this.state.skewMs && warning === this.state.warning) return this.state;
this.state = { skewMs, warning };
this.listeners.forEach((cb) => cb(this.state));
return this.state;
}
public reset(): void {
this.samples = [];
if (this.state.skewMs !== null || this.state.warning) {
this.state = { skewMs: null, warning: false };
this.listeners.forEach((cb) => cb(this.state));
}
}
}
/** "14 minutes ahead" / "2 hours behind" / "45 seconds ahead". */
export const formatSkew = (skewMs: number): string => {
const abs = Math.abs(skewMs);
const dir = skewMs > 0 ? 'ahead' : 'behind';
const unit = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
if (abs >= 36 * 60 * 60 * 1000)
return `${unit(Math.round(abs / (24 * 60 * 60 * 1000)), 'day')} ${dir}`;
if (abs >= 90 * 60 * 1000) return `${unit(Math.round(abs / (60 * 60 * 1000)), 'hour')} ${dir}`;
if (abs >= 90 * 1000) return `${unit(Math.round(abs / 60_000), 'minute')} ${dir}`;
return `${unit(Math.round(abs / 1000), 'second')} ${dir}`;
};
/** "14 minutes ahead of the server" / "3 hours behind the server". */
export const describeSkewVsServer = (skewMs: number): string =>
formatSkew(skewMs)
.replace(/ ahead$/, ' ahead of the server')
.replace(/ behind$/, ' behind the server');
export type ClockFixPlatform = 'windows' | 'mac' | 'linux' | 'ios' | 'android' | 'other';
export const detectClockFixPlatform = (ua: string): ClockFixPlatform => {
if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
if (/Android/i.test(ua)) return 'android';
if (/Windows/i.test(ua)) return 'windows';
if (/Mac OS X|Macintosh/i.test(ua)) return 'mac';
if (/Linux|X11/i.test(ua)) return 'linux';
return 'other';
};
export const clockFixHint = (platform: ClockFixPlatform): string => {
switch (platform) {
case 'windows':
return 'Windows: Settings → Time & language → Date & time → turn on "Set time automatically", then Sync now.';
case 'mac':
return 'macOS: System Settings → General → Date & Time → turn on "Set time and date automatically".';
case 'linux':
return "Linux: enable NTP (e.g. `timedatectl set-ntp true`) or your desktop's Date & Time → Automatic.";
case 'ios':
return 'iOS: Settings → General → Date & Time → Set Automatically.';
case 'android':
return 'Android: Settings → System → Date & time → Set time automatically.';
default:
return "Turn on automatic (network) time in your operating system's date & time settings.";
}
};
+44
View File
@@ -0,0 +1,44 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DecryptionFailureCode } from 'matrix-js-sdk/lib/crypto-api';
import { describeDecryptionFailure } from './decryptionReason';
test('every SDK failure code maps to a sentence without the raw code in it', () => {
Object.values(DecryptionFailureCode).forEach((code) => {
const r = describeDecryptionFailure(code);
assert.ok(r.text.length > 10, code);
assert.ok(!r.text.includes(code), `raw code leaked for ${code}`);
assert.ok(!/[A-Z_]{8,}/.test(r.text), `shouty code-like text for ${code}: ${r.text}`);
if (r.action !== 'none') assert.ok(r.actionLabel, `action without label for ${code}`);
});
});
test('the fixable cases carry the right action', () => {
assert.equal(
describeDecryptionFailure(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP).action,
'setup-backup',
);
assert.equal(
describeDecryptionFailure(DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED).action,
'verify-session',
);
assert.equal(
describeDecryptionFailure(DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP).action,
'retry',
);
assert.equal(
describeDecryptionFailure(DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE)
.action,
'verify-session',
);
assert.equal(
describeDecryptionFailure(DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID).action,
'retry',
);
assert.equal(
describeDecryptionFailure(DecryptionFailureCode.UNKNOWN_SENDER_DEVICE).action,
'none',
);
assert.equal(describeDecryptionFailure(undefined).action, 'none');
assert.equal(describeDecryptionFailure('SOMETHING_NEW').text, 'Unable to decrypt this message.');
});
+75
View File
@@ -0,0 +1,75 @@
import { DecryptionFailureCode } from 'matrix-js-sdk/lib/crypto-api';
/**
* [Gitea #159] What to tell the user about an undecryptable message, and the
* one thing that fixes it (if anything does). Raw codes never reach the UI
* text — they go in the tooltip for support.
*/
export type DecryptionAction = 'setup-backup' | 'retry' | 'verify-session' | 'none';
export type DecryptionReason = {
text: string;
action: DecryptionAction;
actionLabel?: string;
};
export const describeDecryptionFailure = (
code: DecryptionFailureCode | string | null | undefined,
): DecryptionReason => {
switch (code) {
case DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP:
// No backup exists on the server at all: this key is gone for this
// session (another signed-in device may still share it), and the fix is
// for the future.
return {
text: 'Sent before you signed in here, and no key backup exists — set one up so your next session can read history.',
action: 'setup-backup',
actionLabel: 'Set up key backup',
};
case DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED:
// A backup exists but this session can't open it (needs the recovery key
// / verification).
return {
text: "Sent before you signed in here. Your key backup exists but this session can't open it yet.",
action: 'verify-session',
actionLabel: 'Unlock key backup',
};
case DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP:
return {
text: 'Waiting for the key from your backup…',
action: 'retry',
actionLabel: 'Retry',
};
case DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED:
return { text: 'Sent before you joined this room.', action: 'none' };
case DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE:
return {
text: "The sender's device won't share keys with unverified sessions.",
action: 'verify-session',
actionLabel: 'Verify this session',
};
case DecryptionFailureCode.MEGOLM_KEY_WITHHELD:
return { text: 'The sender chose not to share the key for this message.', action: 'none' };
case DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID:
case DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX:
// rust-crypto re-requests the key from our other devices by itself
// (there is no public re-request API any more); a retry re-runs the
// decryption in case it has arrived since.
return {
text: "This session doesn't have the key yet — it is being requested from your other devices.",
action: 'retry',
actionLabel: 'Retry',
};
case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED:
return {
text: "The sender's identity changed since you verified them — re-verify to read this.",
action: 'none',
};
case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE:
return { text: "Sent from a device the sender hasn't verified.", action: 'none' };
case DecryptionFailureCode.UNKNOWN_SENDER_DEVICE:
return { text: "Sent from a device we don't know about.", action: 'none' };
default:
return { text: 'Unable to decrypt this message.', action: 'none' };
}
};