Compare commits
9
Commits
464951edf4
...
e078a2cc10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e078a2cc10 | ||
|
|
c921f11521 | ||
|
|
0e2671891f | ||
|
|
6f25035341 | ||
|
|
f111b3c9af | ||
|
|
8b1c9fa610 | ||
|
|
8658ec05c3 | ||
|
|
2e7915d086 | ||
|
|
84c906fe33 |
@@ -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" />
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>('');
|
||||
@@ -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 });
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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.";
|
||||
}
|
||||
};
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user