Compare commits
14 Commits
6dc478e989
..
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| ca9abb5363 | |||
| 21276a47fc | |||
| b7788cc79c | |||
| 13d08c3fd7 | |||
| a899d7d3a8 | |||
| dcd8201e16 | |||
| 41149db685 | |||
| 668bdaad7d | |||
| ee6bdd8241 | |||
| 0bbdd7ce94 | |||
| 7c85ad177f | |||
| bbf0800c19 | |||
| abd0753148 | |||
| 8192da5a12 |
@@ -675,6 +675,16 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view,
|
|||||||
|
|
||||||
## Outstanding verification backlog
|
## Outstanding verification backlog
|
||||||
|
|
||||||
|
**Windows rich toast (D6, 2026-07 — desktop/Windows build only):** get a message notification while the desktop app is backgrounded → the toast is attributed to **Lotus Chat** (not "PowerShell"/generic) and shows an inline **reply box + Send**; typing a reply + Send **posts it to that room**; clicking the toast body **opens the room**. Previously these silently fell back to a plain toast (no reply/click). If it still falls back, check that a `Lotus Chat.lnk` exists in the Start-Menu Programs folder.
|
||||||
|
|
||||||
|
**Invite QR is now generated LOCALLY (2026-07):** Room settings → Share Room → the QR code renders (a black-on-white SVG in a white box) with **no network request** to `api.qrserver.com` (check DevTools Network — there should be no external QR fetch, and it should work offline / behind strict CSP). **Scan it** with a phone camera / Matrix app → it opens the correct `matrix.to` room-invite link. (`api.qrserver.com` was removed from the prod CSP img-src, so a regression would make the QR blank rather than silently phone home.)
|
||||||
|
|
||||||
|
**Unread dot on federated rooms + avatar-decoration console storm (2026-07):**
|
||||||
|
|
||||||
|
- **Read receipts (regression guard — highest priority):** open several rooms and open the Home/Direct tabs (which mark all orphan rooms read on mount) → rooms **stay read**, unread dots clear and don't come back. (A prior attempt sent a receipt for the thread _root_ when a thread's replies weren't loaded, which the SDK treats as a main receipt at an old event and re-unread every room on every mark-read. Fixed + locked by `notifications.test.ts`.)
|
||||||
|
- **Thread dot:** a room with an unread reply in a thread whose replies are loaded → its dot clears on read; for a thread not yet loaded, the dot clears once you open/load the thread. (mark-as-read now sends a threaded receipt only for a genuine loaded reply, never the root.)
|
||||||
|
- With DevTools console open on federated rooms, the `io.lotus.avatar_decoration` `403`/`502` (and federated media) errors should **not** repeat on every scroll/mount — each failing user is now requested at most ~twice per session, so the storm (and its homeserver load) is gone.
|
||||||
|
|
||||||
**Custom Window Chrome (Beta) fix (2026-07):** on the desktop build, Settings → General → toggle **Custom Window Chrome** — it should reload and come up with the Lotus title bar and a normal, stable feed (no screen-expand / auto-scroll-into-the-past). Toggle back off → reloads to the native frame.
|
**Custom Window Chrome (Beta) fix (2026-07):** on the desktop build, Settings → General → toggle **Custom Window Chrome** — it should reload and come up with the Lotus title bar and a normal, stable feed (no screen-expand / auto-scroll-into-the-past). Toggle back off → reloads to the native frame.
|
||||||
|
|
||||||
_Ported from the retired `LOTUS_BUGS.md` (2026-07). Compact index of shipped-but-not-live-tested items; the detailed steps are in the lettered sections above._
|
_Ported from the retired `LOTUS_BUGS.md` (2026-07). Compact index of shipped-but-not-live-tested items; the detailed steps are in the lettered sections above._
|
||||||
|
|||||||
+152
-924
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -144,10 +144,16 @@ export default [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Test files commonly define several small mock/fake classes.
|
// Test files commonly define several small mock/fake classes and named
|
||||||
|
// function expressions used as constructor mocks (e.g.
|
||||||
|
// `setGlobal('AudioWorkletNode', function AudioWorkletNode(){})`), which must
|
||||||
|
// NOT be rewritten to arrows (arrows aren't constructable). Relax the
|
||||||
|
// stylistic class/callback rules here.
|
||||||
files: ['**/*.test.ts', '**/*.test.tsx'],
|
files: ['**/*.test.ts', '**/*.test.tsx'],
|
||||||
rules: {
|
rules: {
|
||||||
'max-classes-per-file': 'off',
|
'max-classes-per-file': 'off',
|
||||||
|
'lines-between-class-members': 'off',
|
||||||
|
'prefer-arrow-callback': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+10
@@ -57,6 +57,7 @@
|
|||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "5.7.284",
|
"pdfjs-dist": "5.7.284",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-aria": "3.48.0",
|
"react-aria": "3.48.0",
|
||||||
"react-blurhash": "0.3.0",
|
"react-blurhash": "0.3.0",
|
||||||
@@ -10758,6 +10759,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode.react": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/raf-schd": {
|
"node_modules/raf-schd": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "5.7.284",
|
"pdfjs-dist": "5.7.284",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-aria": "3.48.0",
|
"react-aria": "3.48.0",
|
||||||
"react-blurhash": "0.3.0",
|
"react-blurhash": "0.3.0",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
|||||||
import { getChatBg } from '../features/lotus/chatBackground';
|
import { getChatBg } from '../features/lotus/chatBackground';
|
||||||
import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
|
import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
|
||||||
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
||||||
|
import { useReducedMotion } from '../hooks/useReducedMotion';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { getStateEvent, getStateEvents, getMemberDisplayName } from '../utils/room';
|
import { getStateEvent, getStateEvents, getMemberDisplayName } from '../utils/room';
|
||||||
@@ -413,6 +414,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
||||||
const startCall = useCallStart(dm);
|
const startCall = useCallStart(dm);
|
||||||
|
|
||||||
|
// C-L6: handleTimelineEvent awaits decryption before calling setState; guard
|
||||||
|
// against the component unmounting during that await.
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
||||||
async (event, room, toStartOfTimeline, removed, data) => {
|
async (event, room, toStartOfTimeline, removed, data) => {
|
||||||
// only process rtc notification reference events.
|
// only process rtc notification reference events.
|
||||||
@@ -427,6 +438,9 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
await event.getDecryptionPromise();
|
await event.getDecryptionPromise();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C-L6: bail if we unmounted while awaiting decryption above.
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
// Caller-side: a participant declined a call we're hosting in this room.
|
// Caller-side: a participant declined a call we're hosting in this room.
|
||||||
// Without this the caller's UI keeps "ringing" until the notification
|
// Without this the caller's UI keeps "ringing" until the notification
|
||||||
// lifetime expires, with no indication the callee said no.
|
// lifetime expires, with no indication the callee said no.
|
||||||
@@ -706,9 +720,10 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.kind === ThemeKind.Dark;
|
const isDark = theme.kind === ThemeKind.Dark;
|
||||||
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
||||||
|
const reduced = useReducedMotion();
|
||||||
const wallpaperStyle = React.useMemo(
|
const wallpaperStyle = React.useMemo(
|
||||||
() => getChatBg(chatBackground, isDark),
|
() => getChatBg(chatBackground, isDark, reduced),
|
||||||
[chatBackground, isDark],
|
[chatBackground, isDark, reduced],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
|
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export function AvatarDecoration({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<img
|
<img
|
||||||
|
// Force a fresh element per slug so a recycled node whose previous slug
|
||||||
|
// 404'd (and was hidden in onError) can't leak `display:none` onto a
|
||||||
|
// valid decoration.
|
||||||
|
key={slug}
|
||||||
src={decorationUrl(slug)}
|
src={decorationUrl(slug)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -48,6 +52,9 @@ export function AvatarDecoration({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
onLoad={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.removeProperty('display');
|
||||||
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -99,9 +99,21 @@ export function AudioContent({
|
|||||||
const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1);
|
const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioRef.current) {
|
const audio = audioRef.current;
|
||||||
audioRef.current.playbackRate = playbackSpeed;
|
if (!audio) return undefined;
|
||||||
}
|
const applyRate = () => {
|
||||||
|
audio.playbackRate = playbackSpeed;
|
||||||
|
};
|
||||||
|
// Apply immediately, and re-apply whenever the media element (re)loads a new
|
||||||
|
// source — e.g. after async decrypt swaps in the blob URL — since the browser
|
||||||
|
// resets playbackRate to 1 on load, discarding the user's speed choice.
|
||||||
|
applyRate();
|
||||||
|
audio.addEventListener('loadedmetadata', applyRate);
|
||||||
|
audio.addEventListener('play', applyRate);
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('loadedmetadata', applyRate);
|
||||||
|
audio.removeEventListener('play', applyRate);
|
||||||
|
};
|
||||||
}, [playbackSpeed]);
|
}, [playbackSpeed]);
|
||||||
|
|
||||||
const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2];
|
const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||||
import { zIndices } from '../../styles/zIndex';
|
import { zIndices } from '../../styles/zIndex';
|
||||||
import { SeasonTheme } from './types';
|
import { SeasonTheme } from './types';
|
||||||
import { getActiveSeason } from './seasonSchedule';
|
import { getActiveSeason } from './seasonSchedule';
|
||||||
@@ -94,8 +95,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
|
|||||||
|
|
||||||
export function SeasonalEffect() {
|
export function SeasonalEffect() {
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const reduced =
|
const reduced = useReducedMotion();
|
||||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
||||||
|
|
||||||
const theme = useMemo<SeasonTheme | null>(() => {
|
const theme = useMemo<SeasonTheme | null>(() => {
|
||||||
const override = settings.seasonalThemeOverride ?? 'auto';
|
const override = settings.seasonalThemeOverride ?? 'auto';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
@@ -48,6 +50,7 @@ type CallControlsProps = {
|
|||||||
export function CallControls({ callEmbed }: CallControlsProps) {
|
export function CallControls({ callEmbed }: CallControlsProps) {
|
||||||
const controlRef = useRef<HTMLDivElement>(null);
|
const controlRef = useRef<HTMLDivElement>(null);
|
||||||
const callEmbedRef = useCallEmbedRef();
|
const callEmbedRef = useCallEmbedRef();
|
||||||
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
const [compact, setCompact] = useState(document.body.clientWidth < 500);
|
const [compact, setCompact] = useState(document.body.clientWidth < 500);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
@@ -175,22 +178,28 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
};
|
};
|
||||||
if (isEditable(target)) return;
|
if (isEditable(target)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState)
|
||||||
|
// doesn't treat this transient unmute as a user-initiated undeafen.
|
||||||
|
callEmbed.control.pttActive = true;
|
||||||
if (!microphoneRef.current) callEmbed.control.setMicrophone(true);
|
if (!microphoneRef.current) callEmbed.control.setMicrophone(true);
|
||||||
pttActiveRef.current = true;
|
pttActiveRef.current = true;
|
||||||
setPttActive(true);
|
setPttActive(true);
|
||||||
};
|
};
|
||||||
const onKeyUp = (e: KeyboardEvent) => {
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
if (e.code !== pttKey) return;
|
if (e.code !== pttKey) return;
|
||||||
|
callEmbed.control.pttActive = false;
|
||||||
callEmbed.control.setMicrophone(false);
|
callEmbed.control.setMicrophone(false);
|
||||||
pttActiveRef.current = false;
|
pttActiveRef.current = false;
|
||||||
setPttActive(false);
|
setPttActive(false);
|
||||||
};
|
};
|
||||||
const onBlur = () => {
|
const onBlur = () => {
|
||||||
|
callEmbed.control.pttActive = false;
|
||||||
callEmbed.control.setMicrophone(false);
|
callEmbed.control.setMicrophone(false);
|
||||||
pttActiveRef.current = false;
|
pttActiveRef.current = false;
|
||||||
setPttActive(false);
|
setPttActive(false);
|
||||||
};
|
};
|
||||||
const onFocus = () => {
|
const onFocus = () => {
|
||||||
|
callEmbed.control.pttActive = false;
|
||||||
callEmbed.control.setMicrophone(false);
|
callEmbed.control.setMicrophone(false);
|
||||||
pttActiveRef.current = false;
|
pttActiveRef.current = false;
|
||||||
setPttActive(false);
|
setPttActive(false);
|
||||||
@@ -215,6 +224,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
iframeWindow?.removeEventListener('focus', onFocus);
|
iframeWindow?.removeEventListener('focus', onFocus);
|
||||||
// BUG-8: if callEmbed changes while PTT is active, release mic on cleanup
|
// BUG-8: if callEmbed changes while PTT is active, release mic on cleanup
|
||||||
if (pttActiveRef.current) {
|
if (pttActiveRef.current) {
|
||||||
|
callEmbed.control.pttActive = false;
|
||||||
callEmbed.control.setMicrophone(false);
|
callEmbed.control.setMicrophone(false);
|
||||||
pttActiveRef.current = false;
|
pttActiveRef.current = false;
|
||||||
setPttActive(false);
|
setPttActive(false);
|
||||||
@@ -242,8 +252,15 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
callEmbed.control.toggleSound();
|
callEmbed.control.toggleSound();
|
||||||
};
|
};
|
||||||
|
// C-L4: also bind the EC iframe window so the deafen key works when focus is
|
||||||
|
// inside the iframe (mirrors the PTT binding above).
|
||||||
|
const iframeWindow = callEmbed.iframe.contentWindow;
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
iframeWindow?.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
|
iframeWindow?.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
}, [callEmbed, deafenKey]);
|
}, [callEmbed, deafenKey]);
|
||||||
|
|
||||||
const [hangupState, hangup] = useAsyncCallback(
|
const [hangupState, hangup] = useAsyncCallback(
|
||||||
@@ -252,6 +269,19 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
const exiting =
|
const exiting =
|
||||||
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
|
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
|
||||||
|
|
||||||
|
// C-M4: the normal teardown relies on EC echoing a Close/Hangup action after
|
||||||
|
// it ACKs HangupCall (useCallHangupEvent -> clears callEmbedAtom -> dispose).
|
||||||
|
// If EC ACKs but never echoes, the End button would spin forever. Fall back to
|
||||||
|
// disposing the embed a few seconds after a successful hangup send, unless it
|
||||||
|
// was already torn down by the normal path.
|
||||||
|
useEffect(() => {
|
||||||
|
if (hangupState.status !== AsyncStatus.Success) return undefined;
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
if (!callEmbed.disposed) setCallEmbed(undefined);
|
||||||
|
}, 4000);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [hangupState.status, callEmbed, setCallEmbed]);
|
||||||
|
|
||||||
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
|
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -64,6 +64,16 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
|
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
// C-L6: the play() flow schedules a 30s safety timeout that clears playingKey;
|
||||||
|
// guard those setState calls against the component unmounting first.
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const groups = useMemo(
|
const groups = useMemo(
|
||||||
() =>
|
() =>
|
||||||
packs
|
packs
|
||||||
@@ -86,7 +96,10 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
if (playingKey) return; // one at a time (fork also enforces this)
|
if (playingKey) return; // one at a time (fork also enforces this)
|
||||||
setPlayingKey(flat.key);
|
setPlayingKey(flat.key);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
const done = () => setPlayingKey((k) => (k === flat.key ? undefined : k));
|
const done = () => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
setPlayingKey((k) => (k === flat.key ? undefined : k));
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const url = await resolveClipObjectUrl(mx, flat.clip.url);
|
const url = await resolveClipObjectUrl(mx, flat.clip.url);
|
||||||
const vol = (flat.clip.volume / 100) * master;
|
const vol = (flat.clip.volume / 100) * master;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { Box, Button, color, config, Icon, Icons, Text } from 'folds';
|
import { Box, Button, config, Icon, Icons, Text } from 'folds';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
@@ -12,11 +13,9 @@ export function RoomShareInvite() {
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [qrError, setQrError] = useState(false);
|
|
||||||
|
|
||||||
const domain = mx.getDomain() ?? undefined;
|
const domain = mx.getDomain() ?? undefined;
|
||||||
const inviteUrl = getMatrixToRoom(room.roomId, domain ? [domain] : undefined);
|
const inviteUrl = getMatrixToRoom(room.roomId, domain ? [domain] : undefined);
|
||||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(inviteUrl)}`;
|
|
||||||
|
|
||||||
const handleCopy = useCallback(() => {
|
const handleCopy = useCallback(() => {
|
||||||
navigator.clipboard.writeText(inviteUrl).then(() => {
|
navigator.clipboard.writeText(inviteUrl).then(() => {
|
||||||
@@ -64,35 +63,19 @@ export function RoomShareInvite() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box justifyContent="Center">
|
<Box justifyContent="Center">
|
||||||
{qrError ? (
|
{/* Generated locally (qrcode.react) — no third-party service, works
|
||||||
<Box
|
offline + under strict CSP. White padded quiet-zone so the
|
||||||
direction="Column"
|
default black-on-white code scans on any theme. */}
|
||||||
alignItems="Center"
|
<Box
|
||||||
justifyContent="Center"
|
style={{
|
||||||
gap="100"
|
padding: config.space.S200,
|
||||||
style={{
|
background: '#ffffff',
|
||||||
width: 160,
|
borderRadius: config.radii.R300,
|
||||||
height: 160,
|
lineHeight: 0,
|
||||||
borderRadius: config.radii.R300,
|
}}
|
||||||
background: color.SurfaceVariant.Container,
|
>
|
||||||
}}
|
<QRCodeSVG value={inviteUrl} size={160} level="M" title="Room invite QR code" />
|
||||||
>
|
</Box>
|
||||||
<Icon size="400" src={Icons.Warning} />
|
|
||||||
<Text size="T200" priority="300" align="Center">
|
|
||||||
QR code unavailable
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={qrSrc}
|
|
||||||
alt="QR code for room invite link"
|
|
||||||
width={160}
|
|
||||||
height={160}
|
|
||||||
loading="lazy"
|
|
||||||
onError={() => setQrError(true)}
|
|
||||||
style={{ display: 'block', borderRadius: config.radii.R300 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</CutoutCard>
|
</CutoutCard>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
Text,
|
Text,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
RectCords,
|
RectCords,
|
||||||
PopOut,
|
PopOut,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -75,15 +77,16 @@ function PeekPermissions({ powerLevels, power, permissionGroups, children }: Pee
|
|||||||
const hasPower = requiredPower <= power;
|
const hasPower = requiredPower <= power;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Box
|
||||||
key={itemIndex}
|
key={itemIndex}
|
||||||
size="T200"
|
as="span"
|
||||||
style={{
|
alignItems="Center"
|
||||||
color: hasPower ? undefined : color.Critical.Main,
|
gap="100"
|
||||||
}}
|
style={{ color: hasPower ? undefined : color.Critical.Main }}
|
||||||
>
|
>
|
||||||
{hasPower ? '✅' : '❌'} {item.name}
|
<Icon size="50" src={hasPower ? Icons.Check : Icons.Cross} />
|
||||||
</Text>
|
<Text size="T200">{item.name}</Text>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -137,12 +137,13 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
|
|||||||
export const getChatBg = (
|
export const getChatBg = (
|
||||||
bg: ChatBackground,
|
bg: ChatBackground,
|
||||||
isDark: boolean,
|
isDark: boolean,
|
||||||
pauseAnimations?: boolean,
|
// Whether to strip animation (user "pause animations" setting OR OS
|
||||||
|
// prefers-reduced-motion). Supplied by the caller — e.g. via useReducedMotion —
|
||||||
|
// so this function stays pure and SSR-safe (no matchMedia read at call time).
|
||||||
|
suppressAnimation?: boolean,
|
||||||
): CSSProperties => {
|
): CSSProperties => {
|
||||||
const style = isDark ? DARK[bg] : LIGHT[bg];
|
const style = isDark ? DARK[bg] : LIGHT[bg];
|
||||||
const reducedMotion =
|
if (suppressAnimation && style.animation) {
|
||||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
||||||
if ((pauseAnimations || reducedMotion) && style.animation) {
|
|
||||||
const { animation: _anim, ...rest } = style;
|
const { animation: _anim, ...rest } = style;
|
||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { MouseEventHandler, forwardRef, useCallback, useRef, useState } from 'react';
|
import React, { MouseEventHandler, forwardRef, useCallback, useRef, useState } from 'react';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { MatrixClient, Room } from 'matrix-js-sdk';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -263,27 +263,46 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// localStorage key for timed mute timers
|
// localStorage key for timed mute timers
|
||||||
const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
|
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
|
||||||
|
|
||||||
type MuteTimerEntry = { roomId: string; unmuteAt: number };
|
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
|
||||||
|
// immediately. Clamp long delays to this max (~24.8 days).
|
||||||
|
export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
|
||||||
|
|
||||||
function loadMuteTimers(): MuteTimerEntry[] {
|
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
|
||||||
|
|
||||||
|
export function loadMuteTimers(): MuteTimerEntry[] {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveMuteTimers(timers: MuteTimerEntry[]): void {
|
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
|
||||||
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
|
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reverse a timed mute: restore the room's notification mode to Unset and drop
|
||||||
|
// its persisted timer. Shared by the in-session timer and the boot-time restore.
|
||||||
|
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
|
||||||
|
const { setRoomNotificationPreference } =
|
||||||
|
await import('../../hooks/useRoomsNotificationPreferences');
|
||||||
|
await setRoomNotificationPreference(
|
||||||
|
mx,
|
||||||
|
roomId,
|
||||||
|
RoomNotificationMode.Unset,
|
||||||
|
RoomNotificationMode.Mute,
|
||||||
|
).catch(() => {});
|
||||||
|
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
|
function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
|
||||||
const unmuteAt = Date.now() + durationMs;
|
const unmuteAt = Date.now() + durationMs;
|
||||||
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
|
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
|
||||||
saveMuteTimers([...existing, { roomId, unmuteAt }]);
|
saveMuteTimers([...existing, { roomId, unmuteAt }]);
|
||||||
setTimeout(onUnmute, durationMs);
|
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
|
||||||
}
|
}
|
||||||
|
|
||||||
type RoomNavItemMenuProps = {
|
type RoomNavItemMenuProps = {
|
||||||
@@ -338,13 +357,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
if (durationMs !== null) {
|
if (durationMs !== null) {
|
||||||
scheduleMuteTimer(room.roomId, durationMs, () => {
|
scheduleMuteTimer(room.roomId, durationMs, () => {
|
||||||
setRoomNotificationPreference(
|
unmuteRoom(mx, room.roomId);
|
||||||
mx,
|
|
||||||
room.roomId,
|
|
||||||
RoomNotificationMode.Unset,
|
|
||||||
RoomNotificationMode.Mute,
|
|
||||||
).catch(() => {});
|
|
||||||
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== room.roomId));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
requestClose();
|
requestClose();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Spinner, Text } from 'folds';
|
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Text } from 'folds';
|
||||||
import { EventType } from 'matrix-js-sdk';
|
import { EventType } from 'matrix-js-sdk';
|
||||||
import { Page, PageContent, PageHeader } from '../../components/page';
|
import { Page, PageContent, PageHeader } from '../../components/page';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
@@ -16,6 +16,12 @@ const FORMAT_LABELS: Record<ExportFormat, string> = {
|
|||||||
html: 'HTML',
|
html: 'HTML',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PAGE_LIMIT = 100;
|
||||||
|
// Hard cap on back-pagination requests. Without a fromDate, "export all" would
|
||||||
|
// otherwise decrypt and hold every message in the room, hammering the server and
|
||||||
|
// risking an OOM/freeze with no way to stop. 200 pages × 100 ≈ 20,000 events.
|
||||||
|
const MAX_EXPORT_PAGES = 200;
|
||||||
|
|
||||||
type ExportRoomHistoryProps = {
|
type ExportRoomHistoryProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
@@ -30,11 +36,28 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
const [toDate, setToDate] = useState('');
|
const [toDate, setToDate] = useState('');
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [exportCount, setExportCount] = useState(0);
|
const [exportCount, setExportCount] = useState(0);
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
const cancelledRef = useRef(false);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
cancelledRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Stop an in-flight export if the panel unmounts (closing settings mid-export
|
||||||
|
// would otherwise keep paginating + decrypting in the background).
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
cancelledRef.current = true;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(async () => {
|
||||||
if (exporting) return;
|
if (exporting) return;
|
||||||
|
cancelledRef.current = false;
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
setExportCount(0);
|
setExportCount(0);
|
||||||
|
setNotice('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null;
|
const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null;
|
||||||
@@ -55,6 +78,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const timeline = room.getLiveTimeline();
|
const timeline = room.getLiveTimeline();
|
||||||
let canLoadMore = true;
|
let canLoadMore = true;
|
||||||
|
// Track the oldest collected timestamp incrementally so the fromTs check
|
||||||
|
// doesn't rescan the whole `collected` array on every pagination step.
|
||||||
|
let oldestTs = Number.POSITIVE_INFINITY;
|
||||||
|
// Oldest RAW message ts paginated (tracked BEFORE the fromTs filter). The
|
||||||
|
// date-range early-break must use this — oldestTs only ever holds collected
|
||||||
|
// events (all >= fromTs), so it can never fall below fromTs and the export
|
||||||
|
// would over-paginate to the page cap and show a misleading "truncated".
|
||||||
|
let oldestRawTs = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
const addEvents = async (events: ReturnType<typeof timeline.getEvents>) => {
|
const addEvents = async (events: ReturnType<typeof timeline.getEvents>) => {
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
@@ -70,12 +101,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
if (ev.getType() !== EventType.RoomMessage) continue;
|
if (ev.getType() !== EventType.RoomMessage) continue;
|
||||||
if (ev.isDecryptionFailure()) continue;
|
if (ev.isDecryptionFailure()) continue;
|
||||||
const ts = ev.getTs();
|
const ts = ev.getTs();
|
||||||
|
if (ts < oldestRawTs) oldestRawTs = ts;
|
||||||
if (fromTs !== null && ts < fromTs) continue;
|
if (fromTs !== null && ts < fromTs) continue;
|
||||||
if (toTs !== null && ts > toTs) continue;
|
if (toTs !== null && ts > toTs) continue;
|
||||||
const content = ev.getContent();
|
const content = ev.getContent();
|
||||||
const body: string = content.body ?? '';
|
const body: string = content.body ?? '';
|
||||||
const msgtype: string = content.msgtype ?? '';
|
const msgtype: string = content.msgtype ?? '';
|
||||||
if (!body) continue;
|
if (!body) continue;
|
||||||
|
if (ts < oldestTs) oldestTs = ts;
|
||||||
collected.push({
|
collected.push({
|
||||||
ts,
|
ts,
|
||||||
sender: ev.getSender() ?? '',
|
sender: ev.getSender() ?? '',
|
||||||
@@ -89,25 +122,40 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
|
|
||||||
await addEvents(timeline.getEvents());
|
await addEvents(timeline.getEvents());
|
||||||
|
|
||||||
// Paginate backwards until start or date range exceeded
|
// Paginate backwards until start, date range exceeded, cap hit, or cancel
|
||||||
|
let pageCount = 0;
|
||||||
|
let truncated = false;
|
||||||
|
let cancelled = false;
|
||||||
while (canLoadMore) {
|
while (canLoadMore) {
|
||||||
// If we have a fromTs, check whether the oldest collected event is already
|
if (cancelledRef.current) {
|
||||||
// before it — if so we don't need to paginate further.
|
cancelled = true;
|
||||||
if (fromTs !== null && collected.length > 0) {
|
break;
|
||||||
const oldestTs = Math.min(...collected.map((r) => r.ts));
|
|
||||||
if (oldestTs < fromTs) break;
|
|
||||||
}
|
}
|
||||||
|
// If we've paginated back past the fromTs boundary, there's nothing more
|
||||||
|
// in range to fetch (use the raw paginated ts, not the collected one).
|
||||||
|
if (fromTs !== null && oldestRawTs < fromTs) break;
|
||||||
|
// Hard cap so "export all" can't run away and OOM the tab.
|
||||||
|
if (pageCount >= MAX_EXPORT_PAGES) {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pageCount += 1;
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
canLoadMore = await mx.paginateEventTimeline(timeline, {
|
canLoadMore = await mx.paginateEventTimeline(timeline, {
|
||||||
backwards: true,
|
backwards: true,
|
||||||
limit: 100,
|
limit: PAGE_LIMIT,
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await addEvents(timeline.getEvents());
|
await addEvents(timeline.getEvents());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
setNotice(`Export cancelled after ${collected.length} messages.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Sort chronologically (oldest first)
|
// Sort chronologically (oldest first)
|
||||||
collected.sort((a, b) => a.ts - b.ts);
|
collected.sort((a, b) => a.ts - b.ts);
|
||||||
|
|
||||||
@@ -191,6 +239,12 @@ ${msgRows}
|
|||||||
a.download = `export-${safeRoomName}-${dateStr}.${ext}`;
|
a.download = `export-${safeRoomName}-${dateStr}.${ext}`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
if (truncated) {
|
||||||
|
setNotice(
|
||||||
|
`Export truncated to ${collected.length} messages (reached the ${MAX_EXPORT_PAGES}-page limit). Narrow the date range to export older history.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
@@ -297,24 +351,35 @@ ${msgRows}
|
|||||||
? `Exporting… ${exportCount} messages`
|
? `Exporting… ${exportCount} messages`
|
||||||
: 'Export will download automatically.'}
|
: 'Export will download automatically.'}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
{exporting ? (
|
||||||
size="400"
|
<Button
|
||||||
variant="Primary"
|
size="400"
|
||||||
fill="Solid"
|
variant="Critical"
|
||||||
radii="300"
|
fill="Soft"
|
||||||
disabled={exporting}
|
radii="300"
|
||||||
onClick={handleExport}
|
onClick={handleCancel}
|
||||||
before={
|
before={<Icon src={Icons.Cross} size="100" />}
|
||||||
exporting ? (
|
>
|
||||||
<Spinner size="200" />
|
<Text size="B400">Cancel</Text>
|
||||||
) : (
|
</Button>
|
||||||
<Icon src={Icons.Download} size="100" />
|
) : (
|
||||||
)
|
<Button
|
||||||
}
|
size="400"
|
||||||
>
|
variant="Primary"
|
||||||
<Text size="B400">{exporting ? 'Exporting…' : 'Export'}</Text>
|
fill="Solid"
|
||||||
</Button>
|
radii="300"
|
||||||
|
onClick={handleExport}
|
||||||
|
before={<Icon src={Icons.Download} size="100" />}
|
||||||
|
>
|
||||||
|
<Text size="B400">Export</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
{notice && (
|
||||||
|
<Text size="T200" priority="400">
|
||||||
|
{notice}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ function isGlob(entity: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function recommendationLabel(rec: string): string {
|
function recommendationLabel(rec: string): string {
|
||||||
if (rec === 'm.ban') return 'Ban';
|
// `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy
|
||||||
|
// (pre-stabilization) recommendation still emitted by older bots.
|
||||||
|
if (rec === 'm.ban' || rec === 'org.matrix.mjolnir.ban') return 'Ban';
|
||||||
return rec;
|
return rec;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,9 +105,11 @@ function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
|
|||||||
<Text size="T200">glob</Text>
|
<Text size="T200">glob</Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge variant="Critical" fill="Soft" radii="Pill">
|
{entry.recommendation && (
|
||||||
<Text size="T200">{recommendationLabel(entry.recommendation)}</Text>
|
<Badge variant="Critical" fill="Soft" radii="Pill">
|
||||||
</Badge>
|
<Text size="T200">{recommendationLabel(entry.recommendation)}</Text>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{entry.reason && (
|
{entry.reason && (
|
||||||
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
|
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
|
|||||||
if (membership === 'join') {
|
if (membership === 'join') {
|
||||||
if (
|
if (
|
||||||
prevMembership === 'invite' ||
|
prevMembership === 'invite' ||
|
||||||
|
prevMembership === 'knock' ||
|
||||||
prevMembership === undefined ||
|
prevMembership === undefined ||
|
||||||
prevMembership === null
|
prevMembership === null
|
||||||
) {
|
) {
|
||||||
@@ -115,6 +116,19 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
|
|||||||
filter: 'members',
|
filter: 'members',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// sender !== stateKey and the target was only invited → the inviter (or a
|
||||||
|
// moderator) retracted the invite; this is not a kick.
|
||||||
|
if (prevMembership === 'invite') {
|
||||||
|
return {
|
||||||
|
text: (
|
||||||
|
<>
|
||||||
|
<strong>{senderName}</strong> withdrew the invite to <strong>{targetName}</strong>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
iconSrc: Icons.User,
|
||||||
|
filter: 'members',
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
text: (
|
text: (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -115,10 +115,16 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
|||||||
const totalMessages = [...msgCounts.values()].reduce((a, b) => a + b, 0);
|
const totalMessages = [...msgCounts.values()].reduce((a, b) => a + b, 0);
|
||||||
const uniqueParticipants = msgCounts.size;
|
const uniqueParticipants = msgCounts.size;
|
||||||
|
|
||||||
const msgEvents = events.filter((ev) => ev.getType() === EventType.RoomMessage);
|
// Single-pass min/max — `Math.min(...allTs)` spreads one arg per message and
|
||||||
const allTs = msgEvents.map((ev) => ev.getTs());
|
// overflows the call stack (RangeError) on a large paginated timeline.
|
||||||
const oldestTs = allTs.length > 0 ? Math.min(...allTs) : null;
|
let oldestTs: number | null = null;
|
||||||
const newestTs = allTs.length > 0 ? Math.max(...allTs) : null;
|
let newestTs: number | null = null;
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.getType() !== EventType.RoomMessage) continue;
|
||||||
|
const ts = ev.getTs();
|
||||||
|
if (oldestTs === null || ts < oldestTs) oldestTs = ts;
|
||||||
|
if (newestTs === null || ts > newestTs) newestTs = ts;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top5,
|
top5,
|
||||||
|
|||||||
@@ -3,16 +3,22 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Dialog,
|
||||||
|
Header,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
Input,
|
Input,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
Scroll,
|
Scroll,
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
color,
|
color,
|
||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { Page, PageContent, PageHeader } from '../../components/page';
|
import { Page, PageContent, PageHeader } from '../../components/page';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
@@ -24,6 +30,8 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -42,20 +50,52 @@ const DEFAULT_ACL: ServerAclContent = {
|
|||||||
// ── Validation ────────────────────────────────────────────────────────────────
|
// ── Validation ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a server name or wildcard pattern.
|
* Validate a server-name glob for an ACL entry.
|
||||||
* Allowed forms:
|
*
|
||||||
* - plain hostname / IP: letters, digits, hyphens, dots
|
* Matrix ACL `allow`/`deny` entries are globs where `*` (any run of chars) and
|
||||||
* - wildcard prefix: *.example.com (asterisk only at the very start)
|
* `?` (single char) may appear ANYWHERE — e.g. `*`, `*.example.com`,
|
||||||
* The Matrix spec allows `*` on its own (match-all wildcard).
|
* `1.2.3.*`, `10.0.0.?`, `*.evil.*`, `*bad*`. We therefore validate the *glob*
|
||||||
|
* rather than a concrete hostname:
|
||||||
|
* - reject empty / whitespace-only
|
||||||
|
* - allow only hostname/IP chars plus the wildcards `*` and `?`
|
||||||
|
* (letters, digits, dots, hyphens, colons for ports/IPv6 — NO underscore)
|
||||||
|
* - reject consecutive/leading/trailing dots (`...`, `.foo`, `foo.`)
|
||||||
|
* - reject entries with no alphanumeric or wildcard char (bare `-`, lone `:`)
|
||||||
*/
|
*/
|
||||||
function isValidServerPattern(value: string): boolean {
|
function isValidServerPattern(value: string): boolean {
|
||||||
if (value === '*') return true;
|
const v = value.trim();
|
||||||
// Strip leading wildcard
|
if (!v) return false;
|
||||||
const rest = value.startsWith('*.') ? value.slice(2) : value;
|
// Only hostname/IP glob chars — wildcards may appear at any position.
|
||||||
// Must not be empty after stripping wildcard
|
if (!/^[A-Za-z0-9.:*?-]+$/.test(v)) return false;
|
||||||
if (!rest) return false;
|
// Structural rules for the dotted parts.
|
||||||
// Remaining part: only letters, digits, dots, hyphens, colons (for IPv6/ports)
|
if (v.startsWith('.') || v.endsWith('.') || v.includes('..')) return false;
|
||||||
return /^[A-Za-z0-9.:_-]+$/.test(rest);
|
// Must carry actual signal — reject pure punctuation like `-`, `:` or `-.-`.
|
||||||
|
if (!/[A-Za-z0-9*?]/.test(v)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an ACL glob (`*` = any run, `?` = single char) to an anchored RegExp,
|
||||||
|
* escaping every other regex metacharacter. Used only for local self-ban
|
||||||
|
* detection — never sent to the server.
|
||||||
|
*/
|
||||||
|
function globToRegExp(glob: string): RegExp {
|
||||||
|
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||||
|
// Case-INsensitive: Synapse's glob_to_regex uses IGNORECASE and hostnames are
|
||||||
|
// case-insensitive, so a deny like `MATRIX.foo.org` must still be detected as
|
||||||
|
// self-banning `matrix.foo.org` (otherwise the warning is a false negative).
|
||||||
|
return new RegExp(`^${pattern}$`, 'i');
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAnyGlob(domain: string, globs: string[]): boolean {
|
||||||
|
return globs.some((glob) => {
|
||||||
|
try {
|
||||||
|
return globToRegExp(glob).test(domain);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Server list sub-component ─────────────────────────────────────────────────
|
// ── Server list sub-component ─────────────────────────────────────────────────
|
||||||
@@ -78,7 +118,7 @@ function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProp
|
|||||||
if (!value) return;
|
if (!value) return;
|
||||||
|
|
||||||
if (!isValidServerPattern(value)) {
|
if (!isValidServerPattern(value)) {
|
||||||
setError('Invalid server pattern. Use a hostname or *.example.com');
|
setError('Invalid pattern. Use a hostname, IP, or glob (e.g. *.evil.com, 1.2.3.*, 10.0.0.?)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
@@ -181,6 +221,7 @@ type RoomServerACLProps = {
|
|||||||
export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
|
|
||||||
// Power level checks
|
// Power level checks
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
@@ -221,6 +262,26 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
|||||||
const saveError =
|
const saveError =
|
||||||
saveState.status === AsyncStatus.Error ? 'Failed to save ACL. Please try again.' : undefined;
|
saveState.status === AsyncStatus.Error ? 'Failed to save ACL. Please try again.' : undefined;
|
||||||
|
|
||||||
|
// ── Save guards ───────────────────────────────────────────────────────────
|
||||||
|
// #1 Empty allow list denies EVERY server (allow: [] is not "allow all") and
|
||||||
|
// partitions the room from all federation irreversibly — block the save.
|
||||||
|
const emptyAllow = allowList.length === 0;
|
||||||
|
|
||||||
|
// #2 Self-ban: the local homeserver must match at least one allow glob and no
|
||||||
|
// deny glob, otherwise applying this ACL removes our own server from the room.
|
||||||
|
const localDomain = mx.getDomain() ?? '';
|
||||||
|
const selfBanned =
|
||||||
|
localDomain.length > 0 &&
|
||||||
|
(!matchesAnyGlob(localDomain, allowList) || matchesAnyGlob(localDomain, denyList));
|
||||||
|
|
||||||
|
// #4 Gate the destructive write behind a confirmation dialog.
|
||||||
|
const [prompt, setPrompt] = useState(false);
|
||||||
|
|
||||||
|
const handleConfirmSave = () => {
|
||||||
|
setPrompt(false);
|
||||||
|
save();
|
||||||
|
};
|
||||||
|
|
||||||
// Required power level for this state event
|
// Required power level for this state event
|
||||||
const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl);
|
const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl);
|
||||||
const myPL = readPowerLevel.user(powerLevels, myUserId);
|
const myPL = readPowerLevel.user(powerLevels, myUserId);
|
||||||
@@ -242,8 +303,8 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
|||||||
variant="Primary"
|
variant="Primary"
|
||||||
fill="Solid"
|
fill="Solid"
|
||||||
radii="300"
|
radii="300"
|
||||||
disabled={saving || !isDirty}
|
disabled={saving || !isDirty || emptyAllow}
|
||||||
onClick={() => save()}
|
onClick={() => setPrompt(true)}
|
||||||
before={saving ? <Spinner size="200" /> : <Icon src={Icons.Check} size="100" />}
|
before={saving ? <Spinner size="200" /> : <Icon src={Icons.Check} size="100" />}
|
||||||
>
|
>
|
||||||
<Text size="B400">{saving ? 'Saving…' : 'Save Changes'}</Text>
|
<Text size="B400">{saving ? 'Saving…' : 'Save Changes'}</Text>
|
||||||
@@ -290,6 +351,24 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* #1 Empty allow list guard — blocks save */}
|
||||||
|
{canEdit && emptyAllow && (
|
||||||
|
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||||
|
The allow list is empty. An empty allow list denies every server and partitions
|
||||||
|
this room from all federation permanently. Add at least one entry (use
|
||||||
|
"*" to allow all servers).
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* #2 Self-ban warning — save allowed but confirmation required */}
|
||||||
|
{canEdit && !emptyAllow && selfBanned && (
|
||||||
|
<Text size="T300" style={{ color: color.Warning.Main }}>
|
||||||
|
Warning: your own homeserver ({localDomain}) is not permitted by this ACL.
|
||||||
|
Applying it will remove your server from the room and you may lose the ability to
|
||||||
|
moderate it.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Allow IP literals toggle */}
|
{/* Allow IP literals toggle */}
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">IP Address Access</Text>
|
<Text size="L400">IP Address Access</Text>
|
||||||
@@ -352,6 +431,82 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
|||||||
</PageContent>
|
</PageContent>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* #4 Confirmation dialog — surfaces the empty-allow (#1) and self-ban (#2)
|
||||||
|
warnings and keeps a safe save one extra click. */}
|
||||||
|
{prompt && (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setPrompt(false),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog
|
||||||
|
variant="Surface"
|
||||||
|
aria-labelledby="server-acl-confirm-title"
|
||||||
|
style={modalStyle}
|
||||||
|
>
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text as="h2" size="H4" id="server-acl-confirm-title">
|
||||||
|
Apply Server ACL
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
onClick={() => setPrompt(false)}
|
||||||
|
radii="300"
|
||||||
|
aria-label="Cancel"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text priority="400">
|
||||||
|
Server ACL changes take effect immediately and control which servers can
|
||||||
|
participate in this room. This cannot be undone by other servers once they are
|
||||||
|
removed.
|
||||||
|
</Text>
|
||||||
|
{emptyAllow && (
|
||||||
|
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||||
|
The allow list is empty — this would deny every server and partition the
|
||||||
|
room from all federation permanently.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!emptyAllow && selfBanned && (
|
||||||
|
<Text size="T300" style={{ color: color.Warning.Main }}>
|
||||||
|
Warning: your own homeserver ({localDomain}) is not permitted by this ACL.
|
||||||
|
Applying it will remove your server from the room and you may lose the
|
||||||
|
ability to moderate it.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant={selfBanned ? 'Critical' : 'Primary'}
|
||||||
|
onClick={handleConfirmSave}
|
||||||
|
disabled={emptyAllow}
|
||||||
|
>
|
||||||
|
<Text size="B400">Apply ACL</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,18 @@ function getSenderName(room: Room, userId: string): string {
|
|||||||
return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId;
|
return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the thumbnail/display MXC for an image/video event, mirroring the
|
||||||
|
// grid's preference order (encrypted thumb > file > thumbnail_url > url). Both
|
||||||
|
// the grid and the lightbox must use this so their positional indices stay in
|
||||||
|
// lockstep — otherwise a tile skipped for lack of a thumb would shift the
|
||||||
|
// lightbox and open the wrong media.
|
||||||
|
function getThumbMxc(mEvent: MatrixEvent): string | undefined {
|
||||||
|
const c = mEvent.getContent();
|
||||||
|
const isEnc = !!c.file;
|
||||||
|
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
|
||||||
|
return isEnc ? (info?.thumbnail_file?.url ?? c.file?.url) : (info?.thumbnail_url ?? c.url);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Lightbox ──────────────────────────────────────────────────────────────────
|
// ── Lightbox ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type LightboxItem = {
|
type LightboxItem = {
|
||||||
@@ -585,7 +597,10 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
const lightboxItems: LightboxItem[] = events
|
const lightboxItems: LightboxItem[] = events
|
||||||
.filter((ev) => {
|
.filter((ev) => {
|
||||||
const c = ev.getContent();
|
const c = ev.getContent();
|
||||||
return c.msgtype === MsgType.Image || c.msgtype === MsgType.Video;
|
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) => {
|
.map((ev) => {
|
||||||
const c = ev.getContent();
|
const c = ev.getContent();
|
||||||
@@ -712,9 +727,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
|
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
|
||||||
|
|
||||||
// Prefer thumbnail_file (encrypted thumb) > file > thumbnail_url > url
|
// Prefer thumbnail_file (encrypted thumb) > file > thumbnail_url > url
|
||||||
const thumbMxc: string | undefined = isEnc
|
const thumbMxc: string | undefined = getThumbMxc(mEvent);
|
||||||
? (info?.thumbnail_file?.url ?? c.file?.url)
|
|
||||||
: (info?.thumbnail_url ?? c.url);
|
|
||||||
const thumbEnc: IEncryptedFile | undefined = isEnc
|
const thumbEnc: IEncryptedFile | undefined = isEnc
|
||||||
? (info?.thumbnail_file ?? c.file)
|
? (info?.thumbnail_file ?? c.file)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -456,12 +456,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
|
|
||||||
if (compressionResult) {
|
if (compressionResult) {
|
||||||
const originalFile = fileItem.originalFile as File;
|
const originalFile = fileItem.originalFile as File;
|
||||||
const compressedFile = new File([compressionResult.blob], originalFile.name, {
|
// compressImage re-encodes as JPEG; swap the extension so the file
|
||||||
type: 'image/jpeg',
|
// name and MIME type agree (avoids e.g. a JPEG named "photo.png").
|
||||||
|
const compressedType = compressionResult.type;
|
||||||
|
const compressedName = `${originalFile.name.replace(/\.[^./\\]+$/, '')}.jpg`;
|
||||||
|
const compressedFile = new File([compressionResult.blob], compressedName, {
|
||||||
|
type: compressedType,
|
||||||
});
|
});
|
||||||
const uploadRes = await mx.uploadContent(compressedFile, {
|
const uploadRes = await mx.uploadContent(compressedFile, {
|
||||||
name: originalFile.name,
|
name: compressedName,
|
||||||
type: 'image/jpeg',
|
type: compressedType,
|
||||||
});
|
});
|
||||||
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
|
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
|
||||||
if (compressedMxc) {
|
if (compressedMxc) {
|
||||||
@@ -538,6 +542,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
}
|
}
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
|
setCharCount(0);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -579,6 +584,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
mx.sendMessage(roomId, threadRootId ?? null, content as any);
|
mx.sendMessage(roomId, threadRootId ?? null, content as any);
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
|
setCharCount(0);
|
||||||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Page } from '../../components/page';
|
|||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useTheme, ThemeKind } from '../../hooks/useTheme';
|
import { useTheme, ThemeKind } from '../../hooks/useTheme';
|
||||||
|
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||||
import { getChatBg } from '../lotus/chatBackground';
|
import { getChatBg } from '../lotus/chatBackground';
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
import { editableActiveElement } from '../../utils/dom';
|
import { editableActiveElement } from '../../utils/dom';
|
||||||
@@ -65,6 +66,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||||||
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.kind === ThemeKind.Dark;
|
const isDark = theme.kind === ThemeKind.Dark;
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
|
||||||
@@ -102,10 +104,11 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||||||
// Background.Container color. SidebarNav mirrors it onto document.body separately
|
// Background.Container color. SidebarNav mirrors it onto document.body separately
|
||||||
// so the glassmorphism sidebar can blur through it.
|
// so the glassmorphism sidebar can blur through it.
|
||||||
const chatBgStyle = useMemo(() => {
|
const chatBgStyle = useMemo(() => {
|
||||||
if (chatBackground !== 'none') return getChatBg(chatBackground, isDark, pauseAnimations);
|
if (chatBackground !== 'none')
|
||||||
if (lotusTerminal) return getChatBg('tactical', isDark, pauseAnimations);
|
return getChatBg(chatBackground, isDark, pauseAnimations || reduced);
|
||||||
|
if (lotusTerminal) return getChatBg('tactical', isDark, pauseAnimations || reduced);
|
||||||
return {};
|
return {};
|
||||||
}, [chatBackground, lotusTerminal, isDark, pauseAnimations]);
|
}, [chatBackground, lotusTerminal, isDark, pauseAnimations, reduced]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page ref={roomViewRef} style={chatBgStyle}>
|
<Page ref={roomViewRef} style={chatBgStyle}>
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export function buildForwardContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete content['m.relates_to'];
|
delete content['m.relates_to'];
|
||||||
|
// Drop intentional mentions so forwarding a message doesn't re-ping the
|
||||||
|
// originally-mentioned users (they're not in the destination room's context).
|
||||||
|
delete content['m.mentions'];
|
||||||
if (typeof content.body === 'string') {
|
if (typeof content.body === 'string') {
|
||||||
content.body = trimReplyFromBody(content.body);
|
content.body = trimReplyFromBody(content.body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -460,12 +460,17 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
|||||||
}, [scrollToBottomCount]);
|
}, [scrollToBottomCount]);
|
||||||
|
|
||||||
const handleJumpToBottom = useCallback(() => {
|
const handleJumpToBottom = useCallback(() => {
|
||||||
|
// Re-anchor the virtual window at the thread tail first. While scrolled up,
|
||||||
|
// live replies deliberately don't extend the window, so without this the chip
|
||||||
|
// would scroll to the bottom of the STALE window (a mid/old event) instead of
|
||||||
|
// the newest reply. Mirrors the main timeline's handleJumpToLatest.
|
||||||
|
setTimeline(getInitialThreadTimeline(thread, getLinkedTimelines(thread.liveTimeline)));
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
scrollToBottomRef.current.smooth = true;
|
scrollToBottomRef.current.smooth = true;
|
||||||
// Flip atBottom so the layout effect re-runs (count re-read) and live
|
// Flip atBottom so the layout effect re-runs (count re-read) and live
|
||||||
// events resume sticking to the bottom.
|
// events resume sticking to the bottom.
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
}, []);
|
}, [thread]);
|
||||||
|
|
||||||
// Scroll in-place editor into view.
|
// Scroll in-place editor into view.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { ReceiptType } from 'matrix-js-sdk';
|
||||||
|
import { markThreadAsRead } from './threadReceipt';
|
||||||
|
|
||||||
|
// The regression this guards: sending a receipt for the thread ROOT (when
|
||||||
|
// replies aren't loaded, lastReply() is null / equals the root) becomes a MAIN
|
||||||
|
// receipt at an old event and drags the room's read marker backwards. It must
|
||||||
|
// only ever receipt a genuine loaded reply.
|
||||||
|
|
||||||
|
const evt = (id: string, sending = false) => ({ getId: () => id, isSending: () => sending }) as any;
|
||||||
|
|
||||||
|
const setup = (lastReply: any) => {
|
||||||
|
const calls: Array<{ eventId: string; type: ReceiptType }> = [];
|
||||||
|
const thread = { id: '$root', lastReply: () => lastReply } as any;
|
||||||
|
const mx = {
|
||||||
|
sendReadReceipt: async (e: any, type: ReceiptType) => {
|
||||||
|
calls.push({ eventId: e.getId(), type });
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
return { mx, thread, calls };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('REGRESSION: no loaded reply (lastReply null) → NO receipt (never the root)', async () => {
|
||||||
|
const { mx, thread, calls } = setup(null);
|
||||||
|
await markThreadAsRead(mx, thread, false);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('REGRESSION: lastReply IS the root → NO receipt', async () => {
|
||||||
|
const { mx, thread, calls } = setup(evt('$root'));
|
||||||
|
await markThreadAsRead(mx, thread, false);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('genuine loaded reply → threaded receipt at that reply', async () => {
|
||||||
|
const { mx, thread, calls } = setup(evt('$reply'));
|
||||||
|
await markThreadAsRead(mx, thread, false);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].eventId, '$reply');
|
||||||
|
assert.equal(calls[0].type, ReceiptType.Read);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sending reply is skipped', async () => {
|
||||||
|
const { mx, thread, calls } = setup(evt('$reply', true));
|
||||||
|
await markThreadAsRead(mx, thread, false);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('private flag uses ReadPrivate', async () => {
|
||||||
|
const { mx, thread, calls } = setup(evt('$reply'));
|
||||||
|
await markThreadAsRead(mx, thread, true);
|
||||||
|
assert.equal(calls[0].type, ReceiptType.ReadPrivate);
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { MatrixClient, ReceiptType, Thread } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a threaded read receipt for a thread, clearing its per-thread unread
|
||||||
|
* count.
|
||||||
|
*
|
||||||
|
* CRITICAL: never receipt the thread ROOT. A thread's liveTimeline is
|
||||||
|
* `[root, reply1, …]`, so the latest event IS the root when replies aren't
|
||||||
|
* loaded yet (common — the thread panel fires this on mount before replies
|
||||||
|
* fetch). The root is "in the main timeline", so a receipt for it is written by
|
||||||
|
* the SDK with `thread_id:"main"` at the old root, dragging the room's MAIN read
|
||||||
|
* marker backwards (`getEventReadUpTo` → an old/unloaded event) and re-lighting
|
||||||
|
* the whole room. We only receipt a genuine loaded reply (`thread.lastReply()`);
|
||||||
|
* if none is loaded we bail (the per-thread count clears when the reply loads
|
||||||
|
* and this runs again). Mirrors the root guard in `utils/notifications.ts`.
|
||||||
|
*
|
||||||
|
* Pure (no React/CSS) so it can be unit-tested — see `threadReceipt.test.ts`.
|
||||||
|
*/
|
||||||
|
export const markThreadAsRead = async (
|
||||||
|
mx: MatrixClient,
|
||||||
|
thread: Thread,
|
||||||
|
privateReceipt: boolean,
|
||||||
|
): Promise<void> => {
|
||||||
|
const lastReply = thread.lastReply();
|
||||||
|
if (!lastReply || lastReply.isSending() || lastReply.getId() === thread.id) return;
|
||||||
|
|
||||||
|
await mx.sendReadReceipt(lastReply, privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read);
|
||||||
|
};
|
||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
EventTimeline,
|
EventTimeline,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
ReceiptType,
|
|
||||||
Room,
|
Room,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
RoomEventHandlerMap,
|
RoomEventHandlerMap,
|
||||||
@@ -146,32 +145,6 @@ export const useThreadPendingEvents = (
|
|||||||
return pending;
|
return pending;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// markThreadAsRead moved to ./threadReceipt (pure + unit-tested); re-exported
|
||||||
* Send a threaded read receipt up to the latest confirmed event in the thread.
|
// here for existing import sites.
|
||||||
*
|
export { markThreadAsRead } from './threadReceipt';
|
||||||
* The receipt is threaded by default (scoped to this thread), which clears the
|
|
||||||
* per-thread unread count. Mirrors the latest-valid-event scan in
|
|
||||||
* `utils/notifications.ts`.
|
|
||||||
*/
|
|
||||||
export const markThreadAsRead = async (
|
|
||||||
mx: MatrixClient,
|
|
||||||
thread: Thread,
|
|
||||||
privateReceipt: boolean,
|
|
||||||
): Promise<void> => {
|
|
||||||
const events = thread.liveTimeline.getEvents();
|
|
||||||
|
|
||||||
let latestEvent: MatrixEvent | undefined;
|
|
||||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
||||||
const evt = events[i];
|
|
||||||
if (evt && !evt.isSending()) {
|
|
||||||
latestEvent = evt;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!latestEvent) return;
|
|
||||||
|
|
||||||
await mx.sendReadReceipt(
|
|
||||||
latestEvent,
|
|
||||||
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ import { SequenceCard } from '../../../components/sequence-card';
|
|||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { UserAvatar } from '../../../components/user-avatar';
|
import { UserAvatar } from '../../../components/user-avatar';
|
||||||
@@ -319,8 +322,8 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
||||||
const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
|
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
|
||||||
|
|
||||||
const CLEAR_AFTER_OPTIONS = [
|
const CLEAR_AFTER_OPTIONS = [
|
||||||
{ label: 'Never', value: '0' },
|
{ label: 'Never', value: '0' },
|
||||||
@@ -347,6 +350,8 @@ function ProfileStatus() {
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const userId = mx.getUserId()!;
|
const userId = mx.getUserId()!;
|
||||||
const presence = useUserPresence(userId);
|
const presence = useUserPresence(userId);
|
||||||
|
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
|
||||||
|
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
|
||||||
|
|
||||||
const [statusMsg, setStatusMsg] = useState<string>(
|
const [statusMsg, setStatusMsg] = useState<string>(
|
||||||
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
|
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
|
||||||
@@ -357,12 +362,6 @@ function ProfileStatus() {
|
|||||||
const [clearAfter, setClearAfter] = useState('0');
|
const [clearAfter, setClearAfter] = useState('0');
|
||||||
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
||||||
|
|
||||||
// Initialise expiry from localStorage so timer survives page reload
|
|
||||||
const [expiryTs, setExpiryTs] = useState<number>(() => {
|
|
||||||
const stored = localStorage.getItem(STATUS_EXPIRY_KEY(userId));
|
|
||||||
return stored ? parseInt(stored, 10) : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync input when another device changes the status.
|
// Sync input when another device changes the status.
|
||||||
// Skipped while the user has unsaved local edits to avoid clobbering
|
// Skipped while the user has unsaved local edits to avoid clobbering
|
||||||
// mid-flight input (e.g. an emoji being inserted).
|
// mid-flight input (e.g. an emoji being inserted).
|
||||||
@@ -373,32 +372,16 @@ function ProfileStatus() {
|
|||||||
}
|
}
|
||||||
}, [presence?.status, userId]);
|
}, [presence?.status, userId]);
|
||||||
|
|
||||||
// Drive the auto-clear timer off expiryTs so re-saving cancels the old timer
|
|
||||||
useEffect(() => {
|
|
||||||
if (!expiryTs) return undefined;
|
|
||||||
const remaining = expiryTs - Date.now();
|
|
||||||
const clearStatus = () => {
|
|
||||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
|
||||||
setExpiryTs(0);
|
|
||||||
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
|
|
||||||
};
|
|
||||||
if (remaining <= 0) {
|
|
||||||
clearStatus();
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const timer = window.setTimeout(clearStatus, remaining);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [expiryTs, userId, mx]);
|
|
||||||
|
|
||||||
const [saveState, saveStatus] = useAsyncCallback(
|
const [saveState, saveStatus] = useAsyncCallback(
|
||||||
useCallback(
|
useCallback(
|
||||||
(msg: string) =>
|
(msg: string) =>
|
||||||
mx.setPresence({
|
mx.setPresence({
|
||||||
presence: 'online',
|
// Derive presence from the user's chosen setting so writing a status
|
||||||
|
// never overrides Invisible/DND/Idle (e.g. outing an Invisible user).
|
||||||
|
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||||
status_msg: msg,
|
status_msg: msg,
|
||||||
}),
|
}),
|
||||||
[mx],
|
[mx, presenceStatus, hidePresence],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const saving = saveState.status === AsyncStatus.Loading;
|
const saving = saveState.status === AsyncStatus.Loading;
|
||||||
@@ -429,12 +412,12 @@ function ProfileStatus() {
|
|||||||
|
|
||||||
const delayMs = getMsFromOption(clearAfter);
|
const delayMs = getMsFromOption(clearAfter);
|
||||||
if (msg && delayMs > 0) {
|
if (msg && delayMs > 0) {
|
||||||
|
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
|
||||||
|
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
|
||||||
const ts = Date.now() + delayMs;
|
const ts = Date.now() + delayMs;
|
||||||
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(ts));
|
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(ts));
|
||||||
setExpiryTs(ts);
|
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||||
setExpiryTs(0);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -443,8 +426,11 @@ function ProfileStatus() {
|
|||||||
setStatusMsg('');
|
setStatusMsg('');
|
||||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||||
setExpiryTs(0);
|
// Preserve the user's chosen presence when clearing the status message.
|
||||||
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
|
mx.setPresence({
|
||||||
|
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||||
|
status_msg: '',
|
||||||
|
}).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasChanges = statusMsg !== (presence?.status ?? '');
|
const hasChanges = statusMsg !== (presence?.status ?? '');
|
||||||
@@ -751,10 +737,22 @@ function ProfileTimezone() {
|
|||||||
const [saveState, saveTimezone] = useAsyncCallback(
|
const [saveState, saveTimezone] = useAsyncCallback(
|
||||||
useCallback(
|
useCallback(
|
||||||
(value: string) =>
|
(value: string) =>
|
||||||
(mx as any).setAccountData('im.lotus.timezone', { timezone: value }).then(() => {
|
Promise.all([
|
||||||
|
// Self-fallback: account data is readable by useExtendedProfile for the
|
||||||
|
// own user even on servers without extended-profile (m.tz) support.
|
||||||
|
(mx as any).setAccountData('im.lotus.timezone', { timezone: value }),
|
||||||
|
// Mirror the pronouns write path so OTHER users can read the timezone
|
||||||
|
// via the m.tz profile field. Best-effort: standard Synapse rejects
|
||||||
|
// unknown profile fields, so a failure here must not fail the save.
|
||||||
|
mx.http
|
||||||
|
.authedRequest(Method.Put, `/profile/${encodeURIComponent(userId)}/m.tz`, undefined, {
|
||||||
|
'm.tz': value,
|
||||||
|
})
|
||||||
|
.catch(() => undefined),
|
||||||
|
]).then(() => {
|
||||||
setSavedTimezone(value);
|
setSavedTimezone(value);
|
||||||
}),
|
}),
|
||||||
[mx],
|
[mx, userId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const saving = saveState.status === AsyncStatus.Loading;
|
const saving = saveState.status === AsyncStatus.Loading;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function DecorationPreviewCell({
|
|||||||
<img
|
<img
|
||||||
src={`${DECORATION_CDN}/${slug}.png`}
|
src={`${DECORATION_CDN}/${slug}.png`}
|
||||||
alt={name}
|
alt={name}
|
||||||
loading="eager"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
|||||||
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
||||||
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
||||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||||
|
import { useReducedMotion } from '../../../hooks/useReducedMotion';
|
||||||
import { playCallJoinSound } from '../../../utils/callSounds';
|
import { playCallJoinSound } from '../../../utils/callSounds';
|
||||||
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
||||||
import { DenoiseTester } from './DenoiseTester';
|
import { DenoiseTester } from './DenoiseTester';
|
||||||
@@ -2054,6 +2055,7 @@ function ChatBgGrid() {
|
|||||||
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.kind === ThemeKind.Dark;
|
const isDark = theme.kind === ThemeKind.Dark;
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -2079,7 +2081,7 @@ function ChatBgGrid() {
|
|||||||
style={{
|
style={{
|
||||||
width: toRem(76),
|
width: toRem(76),
|
||||||
height: toRem(50),
|
height: toRem(50),
|
||||||
...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations),
|
...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations || reduced),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { Box, Button, Text, IconButton, Icon, Icons, Scroll, config, toRem } from 'folds';
|
import { Box, Button, Text, IconButton, Icon, Icons, IconSrc, Scroll, config, toRem } from 'folds';
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SystemNotification } from './SystemNotification';
|
import { SystemNotification } from './SystemNotification';
|
||||||
import { AllMessagesNotifications } from './AllMessages';
|
import { AllMessagesNotifications } from './AllMessages';
|
||||||
@@ -14,13 +14,13 @@ import { settingsAtom, Settings } from '../../../state/settings';
|
|||||||
|
|
||||||
const PRESETS: Array<{
|
const PRESETS: Array<{
|
||||||
label: string;
|
label: string;
|
||||||
emoji: string;
|
icon: IconSrc;
|
||||||
description: string;
|
description: string;
|
||||||
patch: Partial<Settings>;
|
patch: Partial<Settings>;
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
label: 'Gaming',
|
label: 'Gaming',
|
||||||
emoji: '🎮',
|
icon: Icons.Ball,
|
||||||
description: 'Notifications on, sounds off',
|
description: 'Notifications on, sounds off',
|
||||||
patch: {
|
patch: {
|
||||||
showNotifications: true,
|
showNotifications: true,
|
||||||
@@ -32,7 +32,7 @@ const PRESETS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Work',
|
label: 'Work',
|
||||||
emoji: '💼',
|
icon: Icons.Monitor,
|
||||||
description: 'All notifications and sounds on',
|
description: 'All notifications and sounds on',
|
||||||
patch: {
|
patch: {
|
||||||
showNotifications: true,
|
showNotifications: true,
|
||||||
@@ -44,7 +44,7 @@ const PRESETS: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Sleep',
|
label: 'Sleep',
|
||||||
emoji: '🌙',
|
icon: Icons.BellMute,
|
||||||
description: 'All notifications off',
|
description: 'All notifications off',
|
||||||
patch: {
|
patch: {
|
||||||
showNotifications: false,
|
showNotifications: false,
|
||||||
@@ -83,7 +83,7 @@ function NotificationPresets() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box direction="Column" alignItems="Center" gap="100">
|
<Box direction="Column" alignItems="Center" gap="100">
|
||||||
<span style={{ fontSize: toRem(24) }}>{preset.emoji}</span>
|
<Icon size="400" src={preset.icon} />
|
||||||
<Text size="T300" style={{ fontWeight: config.fontWeight.W600 }}>
|
<Text size="T300" style={{ fontWeight: config.fontWeight.W600 }}>
|
||||||
{preset.label}
|
{preset.label}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -4,84 +4,86 @@ import { CallEmbed, useCallControlState } from '../plugins/call';
|
|||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { toastQueueAtom } from '../state/toast';
|
import { toastQueueAtom } from '../state/toast';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
|
||||||
const SILENCE_RMS_THRESHOLD = 0.008;
|
|
||||||
const CHECK_INTERVAL_MS = 500;
|
const CHECK_INTERVAL_MS = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Monitors microphone audio while in a call. If the mic stays unmuted but
|
* Monitors microphone activity while in a call. If the mic stays unmuted but
|
||||||
* silent for longer than the configured timeout, the mic is muted and a toast
|
* the user is not speaking for longer than the configured timeout, the mic is
|
||||||
* is shown.
|
* muted and a toast is shown.
|
||||||
*
|
*
|
||||||
* The level-monitoring capture (`getUserMedia`) is opened ONLY while the mic is
|
* [C-H2] Activity is read from the EC fork's `io.lotus.call_state` stream
|
||||||
* unmuted — there is nothing to auto-mute once you are already muted, so
|
* (getLotusParticipants) — i.e. the VAD state of the user's ACTUAL published
|
||||||
* holding the capture would keep the OS recording indicator lit even though the
|
* track on their SELECTED input device. The previous implementation opened its
|
||||||
* UI shows you as muted (N95). Muting therefore releases our stream; unmuting
|
* own `getUserMedia({ audio: true })`, which captured the browser DEFAULT mic
|
||||||
* re-acquires it. The AudioContext + stream are also torn down on unmount.
|
* (not necessarily the device EC publishes from): it could measure silence
|
||||||
|
* while the user spoke on a different device (auto-muting an active speaker) and
|
||||||
|
* lit a second OS microphone indicator. Sourcing from the fork removes both
|
||||||
|
* problems and needs no extra capture.
|
||||||
|
*
|
||||||
|
* If the fork hasn't reported call-state yet (getLotusParticipants() === null —
|
||||||
|
* e.g. plain EC, or immediately after join), we cannot tell whether the user is
|
||||||
|
* publishing, so we fail SAFE and never auto-mute during that window.
|
||||||
*/
|
*/
|
||||||
export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
|
export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
|
||||||
|
const mx = useMatrixClient();
|
||||||
const [enabled] = useSetting(settingsAtom, 'afkAutoMute');
|
const [enabled] = useSetting(settingsAtom, 'afkAutoMute');
|
||||||
const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
||||||
const setToast = useSetAtom(toastQueueAtom);
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
const { microphone } = useCallControlState(callEmbed?.control);
|
const { microphone } = useCallControlState(callEmbed?.control);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only capture while in a call, enabled, AND unmuted (see N95 note above).
|
// Only monitor while in a call, enabled, AND unmuted — there is nothing to
|
||||||
|
// auto-mute once you are already muted.
|
||||||
if (!callEmbed || !enabled || !microphone) return undefined;
|
if (!callEmbed || !enabled || !microphone) return undefined;
|
||||||
|
|
||||||
let stream: MediaStream | undefined;
|
const localUserId = mx.getSafeUserId();
|
||||||
let audioCtx: AudioContext | undefined;
|
const timeoutMs = timeoutMinutes * 60 * 1000;
|
||||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
|
||||||
let silenceStart: number | null = null;
|
let silenceStart: number | null = null;
|
||||||
let active = true;
|
let active = true;
|
||||||
const timeoutMs = timeoutMinutes * 60 * 1000;
|
|
||||||
|
|
||||||
navigator.mediaDevices
|
// undefined = fork hasn't reported call-state yet (can't tell — fail safe).
|
||||||
.getUserMedia({ audio: true, video: false })
|
const isLocalSpeaking = (): boolean | undefined => {
|
||||||
.then((s) => {
|
const participants = callEmbed.getLotusParticipants();
|
||||||
if (!active) {
|
// null = fork not reported; [] = malformed/spurious payload (CallEmbed
|
||||||
s.getTracks().forEach((t) => t.stop());
|
// stores [] for a non-array). You are ALWAYS present in your own joined
|
||||||
return;
|
// call, so an empty list means "no usable data", NOT "silent" — matching
|
||||||
}
|
// useCallSpeakers / useRemoteAllMuted. Treating [] as silent would let the
|
||||||
stream = s;
|
// timer mute an active speaker. Fail safe on both.
|
||||||
audioCtx = new AudioContext();
|
if (participants === null || participants.length === 0) return undefined;
|
||||||
const source = audioCtx.createMediaStreamSource(stream);
|
return participants.some((p) => p.userId === localUserId && p.audioEnabled && p.speaking);
|
||||||
const analyser = audioCtx.createAnalyser();
|
};
|
||||||
analyser.fftSize = 256;
|
|
||||||
source.connect(analyser);
|
|
||||||
const buffer = new Float32Array(analyser.fftSize);
|
|
||||||
|
|
||||||
intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
analyser.getFloatTimeDomainData(buffer);
|
const speaking = isLocalSpeaking();
|
||||||
const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length);
|
|
||||||
|
|
||||||
if (rms > SILENCE_RMS_THRESHOLD) {
|
if (speaking === undefined) {
|
||||||
// Audio detected — reset the silence timer.
|
// No usable signal — don't risk muting an active speaker.
|
||||||
silenceStart = null;
|
silenceStart = null;
|
||||||
} else if (silenceStart === null) {
|
} else if (speaking) {
|
||||||
// Mic is unmuted (effect only runs while unmuted) but silent — start the timer.
|
// Voice detected on the published track — reset the silence timer.
|
||||||
silenceStart = Date.now();
|
silenceStart = null;
|
||||||
} else if (Date.now() - silenceStart >= timeoutMs) {
|
} else if (silenceStart === null) {
|
||||||
callEmbed.control.setMicrophone(false);
|
// Mic is unmuted (effect only runs while unmuted) but silent — start the timer.
|
||||||
setToast({
|
silenceStart = Date.now();
|
||||||
id: `afk-mute-${Date.now()}`,
|
} else if (Date.now() - silenceStart >= timeoutMs) {
|
||||||
displayName: 'Lotus Chat',
|
callEmbed.control.setMicrophone(false);
|
||||||
body: 'Your microphone was muted after inactivity.',
|
setToast({
|
||||||
roomName: 'Voice call',
|
id: `afk-mute-${Date.now()}`,
|
||||||
roomId: callEmbed.roomId,
|
displayName: 'Lotus Chat',
|
||||||
});
|
body: 'Your microphone was muted after inactivity.',
|
||||||
silenceStart = null;
|
roomName: 'Voice call',
|
||||||
}
|
roomId: callEmbed.roomId,
|
||||||
}, CHECK_INTERVAL_MS);
|
});
|
||||||
})
|
silenceStart = null;
|
||||||
.catch(() => undefined);
|
}
|
||||||
|
}, CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
if (intervalId !== undefined) clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
stream?.getTracks().forEach((t) => t.stop());
|
|
||||||
audioCtx?.close().catch(() => undefined);
|
|
||||||
};
|
};
|
||||||
}, [callEmbed, enabled, timeoutMinutes, setToast, microphone]);
|
}, [callEmbed, enabled, timeoutMinutes, setToast, microphone, mx]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
|||||||
const cache = new Map<string, string | null>();
|
const cache = new Map<string, string | null>();
|
||||||
// Callbacks waiting for a userId's result
|
// Callbacks waiting for a userId's result
|
||||||
const pending = new Map<string, Array<(val: string | null) => void>>();
|
const pending = new Map<string, Array<(val: string | null) => void>>();
|
||||||
|
// Transient-failure attempt counts (userId → n) so a flaky federated lookup
|
||||||
|
// can retry a couple of times, then gives up for the session.
|
||||||
|
const failures = new Map<string, number>();
|
||||||
|
|
||||||
function fetchDecoration(
|
function fetchDecoration(
|
||||||
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
|
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
|
||||||
@@ -33,16 +36,23 @@ function fetchDecoration(
|
|||||||
return val;
|
return val;
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
// A 404 (M_NOT_FOUND) means the field is genuinely unset → cache "no
|
|
||||||
// decoration". A transient failure (429 rate-limit, 5xx, network) must
|
|
||||||
// NOT be cached: doing so permanently hides the user's decoration for the
|
|
||||||
// whole session. This matters most for the member list and timeline, which
|
|
||||||
// mount many avatars at once and can trip homeserver rate limits — a
|
|
||||||
// single 429 in that burst would otherwise wipe the decoration until a
|
|
||||||
// full reload. Leaving the cache unset lets the next mount retry.
|
|
||||||
const status = err instanceof MatrixError ? err.httpStatus : undefined;
|
const status = err instanceof MatrixError ? err.httpStatus : undefined;
|
||||||
if (status === 404) {
|
// Definitive rejections — the field is unset (404) or the server won't
|
||||||
|
// serve it (400/403). This is the common case for FEDERATED users whose
|
||||||
|
// homeserver doesn't support extended profiles / rejects the field. Cache
|
||||||
|
// "no decoration" so we never refetch: otherwise every avatar mount
|
||||||
|
// re-requests and floods our homeserver with failing federated profile
|
||||||
|
// lookups (the 403/502 console storm + real HS load).
|
||||||
|
if (status === 404 || status === 403 || status === 400) {
|
||||||
cache.set(userId, null);
|
cache.set(userId, null);
|
||||||
|
} else {
|
||||||
|
// Transient (429 rate-limit / 5xx / network). Allow a couple of retries
|
||||||
|
// — a single 429 in a member-list burst shouldn't permanently hide a
|
||||||
|
// decoration — then give up for the session so a persistently-failing
|
||||||
|
// federated link (e.g. a 502'ing remote server) can't loop forever.
|
||||||
|
const attempts = (failures.get(userId) ?? 0) + 1;
|
||||||
|
failures.set(userId, attempts);
|
||||||
|
if (attempts >= 2) cache.set(userId, null);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { MatrixClient } from 'matrix-js-sdk';
|
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
|
||||||
|
|
||||||
export type Bookmark = {
|
export type Bookmark = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -25,6 +24,75 @@ function readBookmarks(mx: MatrixClient): Bookmark[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Module-scoped serialization state.
|
||||||
|
//
|
||||||
|
// useBookmarks() is mounted once per message row (dozens of live instances), so
|
||||||
|
// a per-instance latest/queue would only serialize writes within a single row —
|
||||||
|
// bookmarking message A then message B from different rows (before the server
|
||||||
|
// echo lands) would let B compute from a stale snapshot and clobber A
|
||||||
|
// (setAccountData replaces the whole content, no server merge). We therefore
|
||||||
|
// keep a single shared latest ref + write queue, keyed off the active client.
|
||||||
|
type BookmarksModuleState = {
|
||||||
|
mx: MatrixClient;
|
||||||
|
latest: Bookmark[];
|
||||||
|
writeQueue: Promise<unknown>;
|
||||||
|
listeners: Set<(list: Bookmark[]) => void>;
|
||||||
|
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
|
||||||
|
};
|
||||||
|
|
||||||
|
let moduleState: BookmarksModuleState | null = null;
|
||||||
|
|
||||||
|
// Lazily initialize the shared state for the given client. On a client change
|
||||||
|
// (login/logout swaps the MatrixClient) we tear down the old subscription and
|
||||||
|
// re-initialize against the new client so we never leak or double-subscribe.
|
||||||
|
function ensureModuleState(mx: MatrixClient): BookmarksModuleState {
|
||||||
|
if (moduleState && moduleState.mx === mx) {
|
||||||
|
return moduleState;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moduleState) {
|
||||||
|
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: BookmarksModuleState = {
|
||||||
|
mx,
|
||||||
|
latest: readBookmarks(mx),
|
||||||
|
writeQueue: Promise.resolve(),
|
||||||
|
listeners: new Set(),
|
||||||
|
// Reassigned below once `state` is captured.
|
||||||
|
onAccountData: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.onAccountData = (evt) => {
|
||||||
|
if (evt.getType() === BOOKMARKS_KEY) {
|
||||||
|
const list = evt.getContent<BookmarksContent>()?.bookmarks ?? [];
|
||||||
|
state.latest = list;
|
||||||
|
state.listeners.forEach((listener) => listener(list));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on(ClientEvent.AccountData, state.onAccountData);
|
||||||
|
moduleState = state;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueBookmarkWrite(
|
||||||
|
mx: MatrixClient,
|
||||||
|
compute: (current: Bookmark[]) => Bookmark[],
|
||||||
|
): Promise<void> {
|
||||||
|
const state = ensureModuleState(mx);
|
||||||
|
const run = state.writeQueue.then(async () => {
|
||||||
|
const next = compute(state.latest);
|
||||||
|
state.latest = next;
|
||||||
|
state.listeners.forEach((listener) => listener(next));
|
||||||
|
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
|
||||||
|
});
|
||||||
|
// Keep the chain alive even if one write rejects, but propagate the
|
||||||
|
// rejection to this caller so it can react (e.g. retry).
|
||||||
|
state.writeQueue = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
export function useBookmarks(): {
|
export function useBookmarks(): {
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
addBookmark: (b: Bookmark) => Promise<void>;
|
addBookmark: (b: Bookmark) => Promise<void>;
|
||||||
@@ -32,45 +100,37 @@ export function useBookmarks(): {
|
|||||||
isBookmarked: (eventId: string) => boolean;
|
isBookmarked: (eventId: string) => boolean;
|
||||||
} {
|
} {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => readBookmarks(mx));
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => ensureModuleState(mx).latest);
|
||||||
|
|
||||||
useAccountDataCallback(
|
// Subscribe to the shared module state. A single AccountData listener is
|
||||||
mx,
|
// installed per client (in ensureModuleState); each hook instance only
|
||||||
useCallback(
|
// registers a local setter and unregisters it on unmount / client change.
|
||||||
(evt) => {
|
|
||||||
if (evt.getType() === BOOKMARKS_KEY) {
|
|
||||||
setBookmarks(evt.getContent<BookmarksContent>()?.bookmarks ?? []);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setBookmarks],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-read on mx change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBookmarks(readBookmarks(mx));
|
const state = ensureModuleState(mx);
|
||||||
|
setBookmarks(state.latest);
|
||||||
|
state.listeners.add(setBookmarks);
|
||||||
|
return () => {
|
||||||
|
state.listeners.delete(setBookmarks);
|
||||||
|
};
|
||||||
}, [mx]);
|
}, [mx]);
|
||||||
|
|
||||||
const addBookmark = useCallback(
|
const addBookmark = useCallback(
|
||||||
async (b: Bookmark) => {
|
(b: Bookmark) =>
|
||||||
const current = readBookmarks(mx);
|
enqueueBookmarkWrite(mx, (current) => {
|
||||||
// Avoid duplicates
|
// Avoid duplicates
|
||||||
const filtered = current.filter((bk) => bk.eventId !== b.eventId);
|
const filtered = current.filter((bk) => bk.eventId !== b.eventId);
|
||||||
let next = [b, ...filtered];
|
let next = [b, ...filtered];
|
||||||
if (next.length > MAX_BOOKMARKS) {
|
if (next.length > MAX_BOOKMARKS) {
|
||||||
next = next.slice(0, MAX_BOOKMARKS);
|
next = next.slice(0, MAX_BOOKMARKS);
|
||||||
}
|
}
|
||||||
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
|
return next;
|
||||||
},
|
}),
|
||||||
[mx],
|
[mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeBookmark = useCallback(
|
const removeBookmark = useCallback(
|
||||||
async (eventId: string) => {
|
(eventId: string) =>
|
||||||
const current = readBookmarks(mx);
|
enqueueBookmarkWrite(mx, (current) => current.filter((bk) => bk.eventId !== eventId)),
|
||||||
const next = current.filter((bk) => bk.eventId !== eventId);
|
|
||||||
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
|
|
||||||
},
|
|
||||||
[mx],
|
[mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,25 @@ import { settingsAtom } from '../state/settings';
|
|||||||
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
const ACTIVITY_THROTTLE_MS = 1000;
|
const ACTIVITY_THROTTLE_MS = 1000;
|
||||||
|
|
||||||
|
export type PresenceSetting = 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
|
||||||
|
export type PresenceState = 'online' | 'unavailable' | 'offline';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for mapping the user's presence preference to the
|
||||||
|
* Matrix presence value: auto/online → 'online', idle/dnd → 'unavailable',
|
||||||
|
* invisible (or the hidePresence override) → 'offline'. Shared with the Profile
|
||||||
|
* status writer so setting/clearing a status message never overrides the user's
|
||||||
|
* chosen presence (e.g. outing an Invisible user as online).
|
||||||
|
*/
|
||||||
|
export function presenceStateFromSetting(
|
||||||
|
presenceStatus: PresenceSetting,
|
||||||
|
hidePresence: boolean,
|
||||||
|
): PresenceState {
|
||||||
|
if (hidePresence || presenceStatus === 'invisible') return 'offline';
|
||||||
|
if (presenceStatus === 'idle' || presenceStatus === 'dnd') return 'unavailable';
|
||||||
|
return 'online';
|
||||||
|
}
|
||||||
|
|
||||||
export function usePresenceUpdater() {
|
export function usePresenceUpdater() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
|
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||||
|
|
||||||
|
const readReducedMotion = (): boolean =>
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof window.matchMedia === 'function' &&
|
||||||
|
window.matchMedia(REDUCED_MOTION_QUERY).matches;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactively tracks the OS `prefers-reduced-motion: reduce` setting.
|
||||||
|
*
|
||||||
|
* Unlike a one-off `window.matchMedia(...).matches` read, this subscribes to the
|
||||||
|
* media query's `change` event, so toggling the OS setting mid-session updates
|
||||||
|
* the returned value (and any animation gated on it) without a page reload.
|
||||||
|
* SSR/undefined-safe: returns `false` when `window`/`matchMedia` is unavailable.
|
||||||
|
*/
|
||||||
|
export function useReducedMotion(): boolean {
|
||||||
|
const [reduced, setReduced] = useState<boolean>(readReducedMotion);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const mql = window.matchMedia(REDUCED_MOTION_QUERY);
|
||||||
|
const onChange = (event: MediaQueryListEvent) => setReduced(event.matches);
|
||||||
|
// Re-sync in case the setting changed between the initial render and this effect.
|
||||||
|
setReduced(mql.matches);
|
||||||
|
mql.addEventListener('change', onChange);
|
||||||
|
return () => mql.removeEventListener('change', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { MatrixClient } from 'matrix-js-sdk';
|
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
|
||||||
|
|
||||||
export type Reminder = {
|
export type Reminder = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -23,6 +22,75 @@ function readReminders(mx: MatrixClient): Reminder[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Module-scoped serialization state.
|
||||||
|
//
|
||||||
|
// The latest snapshot and the write queue must be shared across every hook
|
||||||
|
// instance: ReminderMonitor (auto-removes fired reminders) and RemindMeDialog
|
||||||
|
// (adds reminders) mount separate hooks, and a per-instance queue would let a
|
||||||
|
// remove and an add race across instances and clobber each other (setAccountData
|
||||||
|
// replaces the whole content, no server merge). We therefore keep a single
|
||||||
|
// shared queue + latest ref, keyed off the active MatrixClient.
|
||||||
|
type ReminderModuleState = {
|
||||||
|
mx: MatrixClient;
|
||||||
|
latest: Reminder[];
|
||||||
|
writeQueue: Promise<unknown>;
|
||||||
|
listeners: Set<(list: Reminder[]) => void>;
|
||||||
|
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
|
||||||
|
};
|
||||||
|
|
||||||
|
let moduleState: ReminderModuleState | null = null;
|
||||||
|
|
||||||
|
// Lazily initialize the shared state for the given client. On a client change
|
||||||
|
// (login/logout swaps the MatrixClient) we tear down the old subscription and
|
||||||
|
// re-initialize against the new client so we never leak or double-subscribe.
|
||||||
|
function ensureModuleState(mx: MatrixClient): ReminderModuleState {
|
||||||
|
if (moduleState && moduleState.mx === mx) {
|
||||||
|
return moduleState;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moduleState) {
|
||||||
|
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: ReminderModuleState = {
|
||||||
|
mx,
|
||||||
|
latest: readReminders(mx),
|
||||||
|
writeQueue: Promise.resolve(),
|
||||||
|
listeners: new Set(),
|
||||||
|
// Reassigned below once `state` is captured.
|
||||||
|
onAccountData: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.onAccountData = (evt) => {
|
||||||
|
if (evt.getType() === REMINDERS_KEY) {
|
||||||
|
const list = evt.getContent<RemindersContent>()?.reminders ?? [];
|
||||||
|
state.latest = list;
|
||||||
|
state.listeners.forEach((listener) => listener(list));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on(ClientEvent.AccountData, state.onAccountData);
|
||||||
|
moduleState = state;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueReminderWrite(
|
||||||
|
mx: MatrixClient,
|
||||||
|
compute: (current: Reminder[]) => Reminder[],
|
||||||
|
): Promise<void> {
|
||||||
|
const state = ensureModuleState(mx);
|
||||||
|
const run = state.writeQueue.then(async () => {
|
||||||
|
const next = compute(state.latest);
|
||||||
|
state.latest = next;
|
||||||
|
state.listeners.forEach((listener) => listener(next));
|
||||||
|
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
|
||||||
|
});
|
||||||
|
// Keep the chain alive even if one write rejects, but propagate the
|
||||||
|
// rejection to this caller so it can react (e.g. retry).
|
||||||
|
state.writeQueue = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
export function useReminders(): {
|
export function useReminders(): {
|
||||||
reminders: Reminder[];
|
reminders: Reminder[];
|
||||||
addReminder: (r: Reminder) => Promise<void>;
|
addReminder: (r: Reminder) => Promise<void>;
|
||||||
@@ -30,69 +98,34 @@ export function useReminders(): {
|
|||||||
getReminders: () => Reminder[];
|
getReminders: () => Reminder[];
|
||||||
} {
|
} {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [reminders, setReminders] = useState<Reminder[]>(() => readReminders(mx));
|
const [reminders, setReminders] = useState<Reminder[]>(() => ensureModuleState(mx).latest);
|
||||||
|
|
||||||
// Authoritative local snapshot used to compute mutations. Reading
|
// Subscribe to the shared module state. A single AccountData listener is
|
||||||
// mx.getAccountData() per-mutation is racy: two quick add/remove calls both
|
// installed per client (in ensureModuleState); each hook instance only
|
||||||
// read the same stale baseline and the second write clobbers the first
|
// registers a local setter and unregisters it on unmount / client change.
|
||||||
// (N113). We instead mutate from this ref, kept in sync with server echoes.
|
|
||||||
const latestRef = useRef<Reminder[]>(reminders);
|
|
||||||
// Serialize writes so overlapping setAccountData calls can't land out of
|
|
||||||
// order on the server (last-write-wins would otherwise drop data).
|
|
||||||
const writeQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
|
||||||
|
|
||||||
const applyServerState = useCallback((list: Reminder[]) => {
|
|
||||||
latestRef.current = list;
|
|
||||||
setReminders(list);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useAccountDataCallback(
|
|
||||||
mx,
|
|
||||||
useCallback(
|
|
||||||
(evt) => {
|
|
||||||
if (evt.getType() === REMINDERS_KEY) {
|
|
||||||
applyServerState(evt.getContent<RemindersContent>()?.reminders ?? []);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[applyServerState],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-read on mx change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyServerState(readReminders(mx));
|
const state = ensureModuleState(mx);
|
||||||
}, [mx, applyServerState]);
|
setReminders(state.latest);
|
||||||
|
state.listeners.add(setReminders);
|
||||||
const enqueueWrite = useCallback(
|
return () => {
|
||||||
(compute: (current: Reminder[]) => Reminder[]): Promise<void> => {
|
state.listeners.delete(setReminders);
|
||||||
const run = writeQueueRef.current.then(async () => {
|
};
|
||||||
const next = compute(latestRef.current);
|
}, [mx]);
|
||||||
latestRef.current = next;
|
|
||||||
setReminders(next);
|
|
||||||
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
|
|
||||||
});
|
|
||||||
// Keep the chain alive even if one write rejects, but propagate the
|
|
||||||
// rejection to this caller so it can react (e.g. retry).
|
|
||||||
writeQueueRef.current = run.catch(() => undefined);
|
|
||||||
return run;
|
|
||||||
},
|
|
||||||
[mx],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addReminder = useCallback(
|
const addReminder = useCallback(
|
||||||
(r: Reminder) => enqueueWrite((current) => [...current, r]),
|
(r: Reminder) => enqueueReminderWrite(mx, (current) => [...current, r]),
|
||||||
[enqueueWrite],
|
[mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeReminder = useCallback(
|
const removeReminder = useCallback(
|
||||||
(eventId: string, timestamp: number) =>
|
(eventId: string, timestamp: number) =>
|
||||||
enqueueWrite((current) =>
|
enqueueReminderWrite(mx, (current) =>
|
||||||
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
|
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
|
||||||
),
|
),
|
||||||
[enqueueWrite],
|
[mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getReminders = useCallback(() => reminders, [reminders]);
|
const getReminders = useCallback(() => ensureModuleState(mx).latest, [mx]);
|
||||||
|
|
||||||
return { reminders, addReminder, removeReminder, getReminders };
|
return { reminders, addReminder, removeReminder, getReminders };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import {
|
||||||
|
MatrixEvent,
|
||||||
|
MatrixEventEvent,
|
||||||
|
MatrixEventHandlerMap,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomEventHandlerMap,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
@@ -45,11 +52,20 @@ export const useRoomLatestRenderedEvent = (room: Room) => {
|
|||||||
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = () => {
|
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = () => {
|
||||||
setLatestEvent(getLatestEvent());
|
setLatestEvent(getLatestEvent());
|
||||||
};
|
};
|
||||||
|
// An E2EE message often arrives as an undecrypted placeholder and is decrypted
|
||||||
|
// shortly after — decryption does NOT re-fire RoomEvent.Timeline, so without this
|
||||||
|
// the DM preview stays stale ("Encrypted message") until the next timeline event.
|
||||||
|
const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (event) => {
|
||||||
|
if (event.getRoomId() !== room.roomId) return;
|
||||||
|
setLatestEvent(getLatestEvent());
|
||||||
|
};
|
||||||
setLatestEvent(getLatestEvent());
|
setLatestEvent(getLatestEvent());
|
||||||
|
|
||||||
room.on(RoomEvent.Timeline, handleTimelineEvent);
|
room.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
|
room.client.on(MatrixEventEvent.Decrypted, handleDecrypted);
|
||||||
return () => {
|
return () => {
|
||||||
room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
|
room.client.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
|
||||||
};
|
};
|
||||||
}, [room, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]);
|
}, [room, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { manualDndAtom } from '../state/manualDnd';
|
import { manualDndAtom } from '../state/manualDnd';
|
||||||
import { useTauriEvent } from './useTauri';
|
import { tauriInvoke, useTauriEvent } from './useTauri';
|
||||||
|
|
||||||
/** Detail shape of the `lotus-dnd-changed` event emitted by the native side. */
|
/** Detail shape of the `lotus-dnd-changed` event emitted by the native side. */
|
||||||
type DndChangedDetail = {
|
type DndChangedDetail = {
|
||||||
@@ -18,4 +19,17 @@ export function useTauriDnd(): void {
|
|||||||
const setDnd = useSetAtom(manualDndAtom);
|
const setDnd = useSetAtom(manualDndAtom);
|
||||||
|
|
||||||
useTauriEvent<DndChangedDetail>('lotus-dnd-changed', ({ active }) => setDnd(active));
|
useTauriEvent<DndChangedDetail>('lotus-dnd-changed', ({ active }) => setDnd(active));
|
||||||
|
|
||||||
|
// Re-hydrate on mount. The tray CheckMenuItem persists its checkstate, but
|
||||||
|
// `manualDndAtom` is in-memory and resets to false on every reload (the
|
||||||
|
// custom-chrome toggle, logout). Without this the tray could show DND ON while
|
||||||
|
// notifications resume firing. Query the native tray state (`get_tray_dnd`) so
|
||||||
|
// they stay in sync. No-op in the browser.
|
||||||
|
useEffect(() => {
|
||||||
|
tauriInvoke()?.('get_tray_dnd')
|
||||||
|
.then((active) => {
|
||||||
|
if (typeof active === 'boolean') setDnd(active);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [setDnd]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export function useTauriNotificationBadge() {
|
|||||||
|
|
||||||
let totalHighlights = 0;
|
let totalHighlights = 0;
|
||||||
roomToUnread.forEach((unread) => {
|
roomToUnread.forEach((unread) => {
|
||||||
|
// Sum only leaf rooms (from === null); roomToUnread also holds per-ancestor
|
||||||
|
// space aggregates (from = Set), so counting all entries double-counts a
|
||||||
|
// space-nested room. Mirrors the favicon fix in ClientNonUIFeatures.
|
||||||
|
if (unread.from !== null) return;
|
||||||
totalHighlights += unread.highlight;
|
totalHighlights += unread.highlight;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export function useTauriUpdater() {
|
|||||||
setStatus({ state: 'installing' });
|
setStatus({ state: 'installing' });
|
||||||
try {
|
try {
|
||||||
await invoke('install_update');
|
await invoke('install_update');
|
||||||
|
// On a successful install the native side calls app.restart(), so this
|
||||||
|
// resolve is only reached when nothing was installed (no update found) —
|
||||||
|
// don't leave the UI stuck on "installing".
|
||||||
|
setStatus({ state: 'up-to-date' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus({ state: 'error', message: String(e) });
|
setStatus({ state: 'error', message: String(e) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { MatrixClient } from 'matrix-js-sdk';
|
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
|
||||||
|
|
||||||
const NOTES_KEY = 'io.lotus.user_notes';
|
const NOTES_KEY = 'io.lotus.user_notes';
|
||||||
export const USER_NOTE_MAX_LENGTH = 500;
|
export const USER_NOTE_MAX_LENGTH = 500;
|
||||||
@@ -12,39 +11,108 @@ function readNotes(mx: MatrixClient): UserNotesContent {
|
|||||||
return (mx.getAccountData(NOTES_KEY as any)?.getContent() as UserNotesContent | undefined) ?? {};
|
return (mx.getAccountData(NOTES_KEY as any)?.getContent() as UserNotesContent | undefined) ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Module-scoped serialization state.
|
||||||
|
//
|
||||||
|
// useUserNotes() can be mounted by many components at once, so a per-instance
|
||||||
|
// latest/queue would only serialize writes within one instance. Notes for
|
||||||
|
// different users saved from different instances (before the server echo lands)
|
||||||
|
// would each compute from a stale snapshot and clobber each other, since
|
||||||
|
// setAccountData replaces the whole record with no server merge. We therefore
|
||||||
|
// keep a single shared latest record + write queue, keyed off the active client.
|
||||||
|
type UserNotesModuleState = {
|
||||||
|
mx: MatrixClient;
|
||||||
|
latest: UserNotesContent;
|
||||||
|
writeQueue: Promise<unknown>;
|
||||||
|
listeners: Set<(record: UserNotesContent) => void>;
|
||||||
|
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
|
||||||
|
};
|
||||||
|
|
||||||
|
let moduleState: UserNotesModuleState | null = null;
|
||||||
|
|
||||||
|
// Lazily initialize the shared state for the given client. On a client change
|
||||||
|
// (login/logout swaps the MatrixClient) we tear down the old subscription and
|
||||||
|
// re-initialize against the new client so we never leak or double-subscribe.
|
||||||
|
function ensureModuleState(mx: MatrixClient): UserNotesModuleState {
|
||||||
|
if (moduleState && moduleState.mx === mx) {
|
||||||
|
return moduleState;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moduleState) {
|
||||||
|
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: UserNotesModuleState = {
|
||||||
|
mx,
|
||||||
|
latest: readNotes(mx),
|
||||||
|
writeQueue: Promise.resolve(),
|
||||||
|
listeners: new Set(),
|
||||||
|
// Reassigned below once `state` is captured.
|
||||||
|
onAccountData: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.onAccountData = (evt) => {
|
||||||
|
if (evt.getType() === NOTES_KEY) {
|
||||||
|
const record = evt.getContent<UserNotesContent>() ?? {};
|
||||||
|
state.latest = record;
|
||||||
|
state.listeners.forEach((listener) => listener(record));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on(ClientEvent.AccountData, state.onAccountData);
|
||||||
|
moduleState = state;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueNotesWrite(
|
||||||
|
mx: MatrixClient,
|
||||||
|
compute: (current: UserNotesContent) => UserNotesContent,
|
||||||
|
): Promise<void> {
|
||||||
|
const state = ensureModuleState(mx);
|
||||||
|
const run = state.writeQueue.then(async () => {
|
||||||
|
const next = compute(state.latest);
|
||||||
|
state.latest = next;
|
||||||
|
state.listeners.forEach((listener) => listener(next));
|
||||||
|
await (mx as any).setAccountData(NOTES_KEY, next);
|
||||||
|
});
|
||||||
|
// Keep the chain alive even if one write rejects, but propagate the
|
||||||
|
// rejection to this caller so it can react (e.g. retry).
|
||||||
|
state.writeQueue = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
export function useUserNotes(): {
|
export function useUserNotes(): {
|
||||||
getNote: (userId: string) => string;
|
getNote: (userId: string) => string;
|
||||||
setNote: (userId: string, note: string) => Promise<void>;
|
setNote: (userId: string, note: string) => Promise<void>;
|
||||||
} {
|
} {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [notes, setNotes] = useState<UserNotesContent>(() => readNotes(mx));
|
const [notes, setNotes] = useState<UserNotesContent>(() => ensureModuleState(mx).latest);
|
||||||
|
|
||||||
useAccountDataCallback(
|
|
||||||
mx,
|
|
||||||
useCallback((evt) => {
|
|
||||||
if (evt.getType() === NOTES_KEY) {
|
|
||||||
setNotes(evt.getContent<UserNotesContent>() ?? {});
|
|
||||||
}
|
|
||||||
}, []),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// Subscribe to the shared module state. A single AccountData listener is
|
||||||
|
// installed per client (in ensureModuleState); each hook instance only
|
||||||
|
// registers a local setter and unregisters it on unmount / client change.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNotes(readNotes(mx));
|
const state = ensureModuleState(mx);
|
||||||
|
setNotes(state.latest);
|
||||||
|
state.listeners.add(setNotes);
|
||||||
|
return () => {
|
||||||
|
state.listeners.delete(setNotes);
|
||||||
|
};
|
||||||
}, [mx]);
|
}, [mx]);
|
||||||
|
|
||||||
const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]);
|
const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]);
|
||||||
|
|
||||||
const setNote = useCallback(
|
const setNote = useCallback(
|
||||||
async (userId: string, note: string) => {
|
(userId: string, note: string) => {
|
||||||
const current = readNotes(mx);
|
|
||||||
const updated = { ...current };
|
|
||||||
const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH);
|
const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH);
|
||||||
if (trimmed) {
|
return enqueueNotesWrite(mx, (current) => {
|
||||||
updated[userId] = trimmed;
|
const updated = { ...current };
|
||||||
} else {
|
if (trimmed) {
|
||||||
delete updated[userId];
|
updated[userId] = trimmed;
|
||||||
}
|
} else {
|
||||||
await (mx as any).setAccountData(NOTES_KEY, updated);
|
delete updated[userId];
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[mx],
|
[mx],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
|
|||||||
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
|
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Re-seed when the User object appears/changes after first render — the
|
||||||
|
// useState initializer only ran if `user` already existed at mount, so a
|
||||||
|
// late-arriving user would otherwise show no presence until the next event.
|
||||||
|
if (user) setPresence(getUserPresence(user));
|
||||||
// Subscribe on mx (MatrixClient) rather than on individual User objects.
|
// Subscribe on mx (MatrixClient) rather than on individual User objects.
|
||||||
// User objects have a default 10-listener limit; the same user can appear
|
// User objects have a default 10-listener limit; the same user can appear
|
||||||
// in many components simultaneously (avatars, member list, etc.) and
|
// in many components simultaneously (avatars, member list, etc.) and
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { focusAssistActiveAtom } from '../../state/focusAssist';
|
import { focusAssistActiveAtom } from '../../state/focusAssist';
|
||||||
import { manualDndAtom } from '../../state/manualDnd';
|
import { manualDndAtom } from '../../state/manualDnd';
|
||||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||||
import LogoSVG from '../../../../public/res/lotus.png';
|
import LogoSVG from '../../../../public/res/lotus.png';
|
||||||
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
||||||
import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
|
import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
|
||||||
@@ -32,12 +32,19 @@ import {
|
|||||||
getUnreadInfo,
|
getUnreadInfo,
|
||||||
isNotificationEvent,
|
isNotificationEvent,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
import { NotificationType } from '../../../types/matrix/room';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
||||||
|
import {
|
||||||
|
MAX_MUTE_TIMEOUT_MS,
|
||||||
|
MuteTimerEntry,
|
||||||
|
loadMuteTimers,
|
||||||
|
unmuteRoom,
|
||||||
|
} from '../../features/room-nav/RoomNavItem';
|
||||||
|
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
|
||||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||||
import { toastQueueAtom } from '../../state/toast';
|
import { toastQueueAtom } from '../../state/toast';
|
||||||
import { useReminders } from '../../hooks/useReminders';
|
import { useReminders } from '../../hooks/useReminders';
|
||||||
@@ -96,6 +103,11 @@ function FaviconUpdater() {
|
|||||||
let totalNotif = 0;
|
let totalNotif = 0;
|
||||||
let totalHighlight = 0;
|
let totalHighlight = 0;
|
||||||
roomToUnread.forEach((unread) => {
|
roomToUnread.forEach((unread) => {
|
||||||
|
// roomToUnread holds BOTH leaf rooms and per-ancestor space aggregates
|
||||||
|
// (leaves have `from === null`, aggregates a Set). Sum only leaves —
|
||||||
|
// otherwise a space-nested room is counted once as the leaf and again in
|
||||||
|
// every ancestor space, inflating the tab title / favicon count.
|
||||||
|
if (unread.from !== null) return;
|
||||||
totalNotif += unread.total;
|
totalNotif += unread.total;
|
||||||
totalHighlight += unread.highlight;
|
totalHighlight += unread.highlight;
|
||||||
});
|
});
|
||||||
@@ -230,9 +242,95 @@ function PresenceUpdater() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restores timed-mute timers persisted by RoomNavItem across reloads. Bare
|
||||||
|
// setTimeouts don't survive a page reload, so without this a scheduled unmute is
|
||||||
|
// lost and the room stays muted forever. On boot: unmute anything already
|
||||||
|
// past-due and re-arm a timer for each future entry (clamped to setTimeout's max).
|
||||||
|
function MuteTimerRestore() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timers = loadMuteTimers();
|
||||||
|
if (timers.length === 0) return undefined;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const pastDue: MuteTimerEntry[] = [];
|
||||||
|
const future: MuteTimerEntry[] = [];
|
||||||
|
timers.forEach((entry) => (entry.unmuteAt <= now ? pastDue : future).push(entry));
|
||||||
|
|
||||||
|
pastDue.forEach((entry) => {
|
||||||
|
unmuteRoom(mx, entry.roomId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handles = future.map((entry) =>
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
unmuteRoom(mx, entry.roomId);
|
||||||
|
},
|
||||||
|
Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
handles.forEach(clearTimeout);
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fires the custom-status auto-clear even when Settings→Profile is closed. The
|
||||||
|
// expiry setTimeout used to live in ProfileStatus, which unmounts on close, so
|
||||||
|
// the status never cleared. This always-mounted watcher polls the persisted
|
||||||
|
// expiry key and clears (preserving the user's chosen presence) when due.
|
||||||
|
function StatusExpiryMonitor() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
|
||||||
|
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
|
||||||
|
// Read latest settings via refs so the poll interval isn't torn down/restarted
|
||||||
|
// (resetting its countdown) whenever the presence setting changes.
|
||||||
|
const presenceStatusRef = useRef(presenceStatus);
|
||||||
|
presenceStatusRef.current = presenceStatus;
|
||||||
|
const hidePresenceRef = useRef(hidePresence);
|
||||||
|
hidePresenceRef.current = hidePresence;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
if (!userId) return undefined;
|
||||||
|
const expiryKey = STATUS_EXPIRY_KEY(userId);
|
||||||
|
const msgKey = STATUS_MSG_KEY(userId);
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
const stored = localStorage.getItem(expiryKey);
|
||||||
|
if (!stored) return;
|
||||||
|
const ts = parseInt(stored, 10);
|
||||||
|
if (!ts || Date.now() < ts) return;
|
||||||
|
localStorage.removeItem(msgKey);
|
||||||
|
localStorage.removeItem(expiryKey);
|
||||||
|
mx.setPresence({
|
||||||
|
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
|
||||||
|
status_msg: '',
|
||||||
|
}).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
check();
|
||||||
|
const interval = setInterval(check, 30_000);
|
||||||
|
const onVisible = () => {
|
||||||
|
if (document.visibilityState === 'visible') check();
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function MessageNotifications() {
|
function MessageNotifications() {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
|
const lastNotifiedEventRef = useRef<Map<string, string>>(new Map());
|
||||||
// Per-thread dedupe: threadId -> last notified eventId.
|
// Per-thread dedupe: threadId -> last notified eventId.
|
||||||
const lastNotifiedThreadRef = useRef<Map<string, string>>(new Map());
|
const lastNotifiedThreadRef = useRef<Map<string, string>>(new Map());
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -367,17 +465,21 @@ function MessageNotifications() {
|
|||||||
const eventId = mEvent.getId();
|
const eventId = mEvent.getId();
|
||||||
if (!sender || !eventId) return;
|
if (!sender || !eventId) return;
|
||||||
|
|
||||||
const unreadInfo = getUnreadInfo(room);
|
// Dedupe on the event id (per room): the same event can re-fire (decryption,
|
||||||
const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId);
|
// edit, thread repopulation). This replaces the old unread-COUNT dedupe,
|
||||||
unreadCacheRef.current.set(room.roomId, unreadInfo);
|
// which suppressed a genuinely-new message whenever its post-read count
|
||||||
|
// matched the previously-notified count — i.e. "read a DM, next message
|
||||||
|
// never notifies/sounds" (the common one-at-a-time cadence).
|
||||||
|
if (lastNotifiedEventRef.current.get(room.roomId) === eventId) return;
|
||||||
|
|
||||||
if (unreadInfo.total === 0) return;
|
// Main-timeline path respects push rules: don't notify when the room has no
|
||||||
if (
|
// notification count (e.g. a non-mention in a Mentions-only room). The
|
||||||
cachedUnreadInfo &&
|
// thread path is already gated by shouldNotifyThreadReply, so it must NOT
|
||||||
unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo))
|
// re-gate on the room count — otherwise an explicit per-thread "All replies"
|
||||||
) {
|
// override in a Mentions-only room is silently dropped.
|
||||||
return;
|
if (!threadId && getUnreadInfo(room).total === 0) return;
|
||||||
}
|
|
||||||
|
lastNotifiedEventRef.current.set(room.roomId, eventId);
|
||||||
|
|
||||||
const quietActive =
|
const quietActive =
|
||||||
focusAssistActive ||
|
focusAssistActive ||
|
||||||
@@ -666,6 +768,8 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<PageZoomFeature />
|
<PageZoomFeature />
|
||||||
<FaviconUpdater />
|
<FaviconUpdater />
|
||||||
<PresenceUpdater />
|
<PresenceUpdater />
|
||||||
|
<MuteTimerRestore />
|
||||||
|
<StatusExpiryMonitor />
|
||||||
<InviteNotifications />
|
<InviteNotifications />
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
<ReminderMonitor />
|
<ReminderMonitor />
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
logoutClient,
|
logoutClient,
|
||||||
startClient,
|
startClient,
|
||||||
} from '../../../client/initMatrix';
|
} from '../../../client/initMatrix';
|
||||||
|
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
|
||||||
import { SplashScreen } from '../../components/splash-screen';
|
import { SplashScreen } from '../../components/splash-screen';
|
||||||
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
||||||
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
||||||
@@ -43,6 +44,8 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||||||
import { SyncStatus } from './SyncStatus';
|
import { SyncStatus } from './SyncStatus';
|
||||||
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||||
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
|
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
|
||||||
|
import { pushSessionToSW } from '../../../sw-session';
|
||||||
|
import { revokeOidcTokens } from '../../../client/oidcLogout';
|
||||||
import { useSessionSync } from '../../hooks/useSessionSync';
|
import { useSessionSync } from '../../hooks/useSessionSync';
|
||||||
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
|
import { installCryptoDiagLog } from '../../utils/cryptoDiagLog';
|
||||||
import { AutoDiscovery } from './AutoDiscovery';
|
import { AutoDiscovery } from './AutoDiscovery';
|
||||||
@@ -142,8 +145,23 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
|
|||||||
const useLogoutListener = (mx?: MatrixClient) => {
|
const useLogoutListener = (mx?: MatrixClient) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
|
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
|
||||||
|
// Clear the SW's cached bearer token so it stops attaching the now-revoked
|
||||||
|
// token to media fetches (mirrors the manual logoutClient path).
|
||||||
|
pushSessionToSW();
|
||||||
mx?.stopClient();
|
mx?.stopClient();
|
||||||
|
// Best-effort issuer revocation for OIDC sessions (the token is already
|
||||||
|
// server-revoked here, but revoke the refresh token too). Before we drop
|
||||||
|
// the stored session below.
|
||||||
|
const loggedOutSession = getFallbackSession();
|
||||||
|
if (loggedOutSession?.oidc) {
|
||||||
|
await revokeOidcTokens(loggedOutSession).catch(() => undefined);
|
||||||
|
}
|
||||||
await mx?.clearStores();
|
await mx?.clearStores();
|
||||||
|
// The opt-in local search index holds DECRYPTED message plaintext. Wipe it
|
||||||
|
// on server-forced logout too (token expiry / remote sign-out / password
|
||||||
|
// change) — the manual logout path already does, but this path didn't, so
|
||||||
|
// the plaintext survived on disk (and persist() makes it non-evictable).
|
||||||
|
await deleteSearchCacheDatabase();
|
||||||
// Remove only the session credential keys — NOT settings, drafts, and
|
// Remove only the session credential keys — NOT settings, drafts, and
|
||||||
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
|
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
|
||||||
// window.localStorage.clear() is reserved for the explicit reset path.
|
// window.localStorage.clear() is reserved for the explicit reset path.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { CreateTab } from './sidebar/CreateTab';
|
|||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useTheme, ThemeKind } from '../../hooks/useTheme';
|
import { useTheme, ThemeKind } from '../../hooks/useTheme';
|
||||||
|
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||||
import { getChatBg } from '../../features/lotus/chatBackground';
|
import { getChatBg } from '../../features/lotus/chatBackground';
|
||||||
|
|
||||||
export function SidebarNav() {
|
export function SidebarNav() {
|
||||||
@@ -34,6 +35,7 @@ export function SidebarNav() {
|
|||||||
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.kind === ThemeKind.Dark;
|
const isDark = theme.kind === ThemeKind.Dark;
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
|
||||||
// backdrop-filter only blurs content directly behind the element in the z-axis.
|
// backdrop-filter only blurs content directly behind the element in the z-axis.
|
||||||
// The sidebar is a flex sibling of the room view, so nothing sits behind it by default.
|
// The sidebar is a flex sibling of the room view, so nothing sits behind it by default.
|
||||||
@@ -53,17 +55,26 @@ export function SidebarNav() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effectiveBg = chatBackground !== 'none' ? chatBackground : 'tactical';
|
const effectiveBg = chatBackground !== 'none' ? chatBackground : 'tactical';
|
||||||
const bgStyle = getChatBg(effectiveBg, isDark, pauseAnimations);
|
const bgStyle = getChatBg(effectiveBg, isDark, pauseAnimations || reduced);
|
||||||
style.backgroundImage = (bgStyle.backgroundImage as string | undefined) ?? '';
|
style.backgroundImage = (bgStyle.backgroundImage as string | undefined) ?? '';
|
||||||
style.backgroundColor = (bgStyle.backgroundColor as string | undefined) ?? '';
|
style.backgroundColor = (bgStyle.backgroundColor as string | undefined) ?? '';
|
||||||
style.backgroundSize = (bgStyle.backgroundSize as string | undefined) ?? '';
|
style.backgroundSize = (bgStyle.backgroundSize as string | undefined) ?? '';
|
||||||
style.backgroundPosition = (bgStyle.backgroundPosition as string | undefined) ?? '';
|
style.backgroundPosition = (bgStyle.backgroundPosition as string | undefined) ?? '';
|
||||||
style.animation = (bgStyle.animation as string | undefined) ?? '';
|
// The animated body mirror (animation + will-change) exists solely so the
|
||||||
// Promote animated backgrounds to their own compositor layer so the browser
|
// glassmorphism sidebar can blur through document.body. When glass is OFF nothing
|
||||||
// doesn't repaint the overlaid text/UI content on every animation frame.
|
// samples this layer, yet SidebarNav is always mounted, so writing an animated bg +
|
||||||
if (bgStyle.animation) {
|
// will-change here would leave a permanent invisible animated compositor layer
|
||||||
style.willChange = 'background-position, background-size';
|
// app-wide. Only mirror the animation when glass is on; the static background above
|
||||||
|
// (needed by lotusTerminal / non-animated cases) is still written regardless.
|
||||||
|
if (glassmorphismSidebar) {
|
||||||
|
style.animation = (bgStyle.animation as string | undefined) ?? '';
|
||||||
|
if (bgStyle.animation) {
|
||||||
|
style.willChange = 'background-position, background-size';
|
||||||
|
} else {
|
||||||
|
style.removeProperty('will-change');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
style.removeProperty('animation');
|
||||||
style.removeProperty('will-change');
|
style.removeProperty('will-change');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +86,7 @@ export function SidebarNav() {
|
|||||||
style.removeProperty('animation');
|
style.removeProperty('animation');
|
||||||
style.removeProperty('will-change');
|
style.removeProperty('will-change');
|
||||||
};
|
};
|
||||||
}, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark, pauseAnimations]);
|
}, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark, pauseAnimations, reduced]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar className={classNames(glassmorphismSidebar && SidebarGlass)}>
|
<Sidebar className={classNames(glassmorphismSidebar && SidebarGlass)}>
|
||||||
|
|||||||
@@ -321,11 +321,7 @@ export function Direct() {
|
|||||||
const selected = selectedRoomId === roomId;
|
const selected = selectedRoomId === roomId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VirtualTile
|
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
|
||||||
virtualItem={vItem}
|
|
||||||
key={vItem.index}
|
|
||||||
ref={virtualizer.measureElement}
|
|
||||||
>
|
|
||||||
<RoomNavItem
|
<RoomNavItem
|
||||||
room={room}
|
room={room}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
|
|||||||
@@ -275,15 +275,27 @@ export function Home() {
|
|||||||
return { favoriteRooms: favs, otherRooms: others };
|
return { favoriteRooms: favs, otherRooms: others };
|
||||||
}, [mx, rooms]);
|
}, [mx, rooms]);
|
||||||
|
|
||||||
const sortedFavoriteRooms = useMemo(
|
const sortedFavoriteRooms = useMemo(() => {
|
||||||
() =>
|
const isClosed = closedCategories.has(FAVORITES_CATEGORY_ID);
|
||||||
Array.from(favoriteRooms).sort(
|
const items = Array.from(favoriteRooms).sort(
|
||||||
closedCategories.has(FAVORITES_CATEGORY_ID)
|
isClosed ? factoryRoomIdByActivity(mx) : factoryRoomIdByAtoZ(mx),
|
||||||
? factoryRoomIdByActivity(mx)
|
);
|
||||||
: factoryRoomIdByAtoZ(mx),
|
if (isClosed) {
|
||||||
),
|
return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
|
||||||
[mx, favoriteRooms, closedCategories],
|
}
|
||||||
);
|
return items;
|
||||||
|
}, [mx, favoriteRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
|
||||||
|
|
||||||
|
const filteredFavoriteRooms = useMemo(() => {
|
||||||
|
if (!filterQuery.trim()) return sortedFavoriteRooms;
|
||||||
|
const query = filterQuery.toLowerCase();
|
||||||
|
const localNames = getLocalRoomNamesContent(mx);
|
||||||
|
return sortedFavoriteRooms.filter((rId) => {
|
||||||
|
const localName = localNames.rooms[rId];
|
||||||
|
const matrixName = mx.getRoom(rId)?.name ?? '';
|
||||||
|
return (localName ?? matrixName).toLowerCase().includes(query);
|
||||||
|
});
|
||||||
|
}, [mx, sortedFavoriteRooms, filterQuery]);
|
||||||
|
|
||||||
const sortedRooms = useMemo(() => {
|
const sortedRooms = useMemo(() => {
|
||||||
const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID);
|
const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID);
|
||||||
@@ -324,7 +336,7 @@ export function Home() {
|
|||||||
}, [mx, sortedRooms, filterQuery]);
|
}, [mx, sortedRooms, filterQuery]);
|
||||||
|
|
||||||
const favVirtualizer = useVirtualizer({
|
const favVirtualizer = useVirtualizer({
|
||||||
count: sortedFavoriteRooms.length,
|
count: filteredFavoriteRooms.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
estimateSize: () => 38,
|
estimateSize: () => 38,
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
@@ -453,7 +465,7 @@ export function Home() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
{sortedFavoriteRooms.length > 0 && (
|
{favoriteRooms.length > 0 && (
|
||||||
<NavCategory>
|
<NavCategory>
|
||||||
<NavCategoryHeader>
|
<NavCategoryHeader>
|
||||||
<RoomNavCategoryButton
|
<RoomNavCategoryButton
|
||||||
@@ -466,13 +478,13 @@ export function Home() {
|
|||||||
</NavCategoryHeader>
|
</NavCategoryHeader>
|
||||||
<div style={{ position: 'relative', height: favVirtualizer.getTotalSize() }}>
|
<div style={{ position: 'relative', height: favVirtualizer.getTotalSize() }}>
|
||||||
{favVirtualizer.getVirtualItems().map((vItem) => {
|
{favVirtualizer.getVirtualItems().map((vItem) => {
|
||||||
const roomId = sortedFavoriteRooms[vItem.index];
|
const roomId = filteredFavoriteRooms[vItem.index];
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
return (
|
return (
|
||||||
<VirtualTile
|
<VirtualTile
|
||||||
virtualItem={vItem}
|
virtualItem={vItem}
|
||||||
key={vItem.index}
|
key={roomId}
|
||||||
ref={favVirtualizer.measureElement}
|
ref={favVirtualizer.measureElement}
|
||||||
>
|
>
|
||||||
<RoomNavItem
|
<RoomNavItem
|
||||||
@@ -611,11 +623,7 @@ export function Home() {
|
|||||||
const selected = selectedRoomId === roomId;
|
const selected = selectedRoomId === roomId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VirtualTile
|
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
|
||||||
virtualItem={vItem}
|
|
||||||
key={vItem.index}
|
|
||||||
ref={virtualizer.measureElement}
|
|
||||||
>
|
|
||||||
<RoomNavItem
|
<RoomNavItem
|
||||||
room={room}
|
room={room}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
|
|||||||
@@ -29,8 +29,22 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
|
|
||||||
private controlMutationObserver: MutationObserver;
|
private controlMutationObserver: MutationObserver;
|
||||||
|
|
||||||
|
// C-H3: coalesces bursts of body-subtree mutations into a single debounced
|
||||||
|
// re-observe pass so a busy EC re-render doesn't thrash the control observer.
|
||||||
|
private bodyMutationTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
private _pipMode = false;
|
private _pipMode = false;
|
||||||
|
|
||||||
|
// C-M3: last quality payload requested via setQuality(). Held so we can (re)send
|
||||||
|
// it once joined (io.lotus.set_quality must not be sent before call-join — a
|
||||||
|
// pre-join send pends to a 10s widget timeout, mirroring the deafen gate).
|
||||||
|
private lastQuality: LotusQualityPayload | null = null;
|
||||||
|
|
||||||
|
// C-M5: set true by CallControls while a push-to-talk key is held. A PTT hold
|
||||||
|
// unmutes the mic transiently, and onMediaState() must NOT treat that as a
|
||||||
|
// user-initiated unmute that auto-undeafens the user.
|
||||||
|
public pttActive = false;
|
||||||
|
|
||||||
// P6-2: mirrors CallEmbed.joined. Set true from forceState(), which CallEmbed
|
// P6-2: mirrors CallEmbed.joined. Set true from forceState(), which CallEmbed
|
||||||
// invokes only from onCallJoined(). Gates io.lotus.set_deafen so we never send
|
// invokes only from onCallJoined(). Gates io.lotus.set_deafen so we never send
|
||||||
// before the fork's widget handler mounts (pre-join sends pend to a 10s
|
// before the fork's widget handler mounts (pre-join sends pend to a 10s
|
||||||
@@ -153,19 +167,43 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
// this.joined was still false, so it was gated — this is the first send.)
|
// this.joined was still false, so it was gated — this is the first send.)
|
||||||
this.joined = true;
|
this.joined = true;
|
||||||
this.sendDeafenState();
|
this.sendDeafenState();
|
||||||
|
this.sendQuality();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* C-H1 / C-M3: re-push the sticky fork-side state (deafen + quality) after an
|
||||||
|
* EC reconnect. Unlike forceState() this does NOT touch mic/video, so a
|
||||||
|
* reconnect can't clobber the user's live media state — it only re-arms the
|
||||||
|
* fork handlers that remount on reconnect.
|
||||||
|
*/
|
||||||
|
public resendForkState(): void {
|
||||||
|
this.sendDeafenState();
|
||||||
|
this.sendQuality();
|
||||||
}
|
}
|
||||||
|
|
||||||
public startObserving() {
|
public startObserving() {
|
||||||
if (!this.document) return;
|
if (!this.document) return;
|
||||||
|
|
||||||
|
// C-H3: watch the whole body subtree (not just direct children) so we
|
||||||
|
// re-bind the control observer when EC re-renders its controls deeper in the
|
||||||
|
// tree. Debounced via onBodyMutation() to avoid thrashing on busy renders.
|
||||||
this.bodyMutationObserver.observe(this.document.body, {
|
this.bodyMutationObserver.observe(this.document.body, {
|
||||||
childList: true,
|
childList: true,
|
||||||
subtree: false, // only direct children of body
|
subtree: true,
|
||||||
});
|
});
|
||||||
this.onBodyMutation();
|
this.applyBodyMutation();
|
||||||
}
|
}
|
||||||
|
|
||||||
private onBodyMutation() {
|
private onBodyMutation() {
|
||||||
|
// C-H3: coalesce a burst of subtree mutations into one debounced pass.
|
||||||
|
if (this.bodyMutationTimer !== undefined) return;
|
||||||
|
this.bodyMutationTimer = setTimeout(() => {
|
||||||
|
this.bodyMutationTimer = undefined;
|
||||||
|
this.applyBodyMutation();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyBodyMutation() {
|
||||||
if (!this.document) return;
|
if (!this.document) return;
|
||||||
|
|
||||||
this.document.body.style.setProperty('background', 'none', 'important');
|
this.document.body.style.setProperty('background', 'none', 'important');
|
||||||
@@ -266,22 +304,43 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
this.state = state;
|
this.state = state;
|
||||||
this.emitStateUpdate();
|
this.emitStateUpdate();
|
||||||
|
|
||||||
if (this.microphone && !this.sound) {
|
// C-M5: auto-undeafen when the mic turns on, but NOT for a transient
|
||||||
|
// push-to-talk unmute — a PTT tap while deafened must not silently
|
||||||
|
// un-deafen the user.
|
||||||
|
if (this.microphone && !this.sound && !this.pttActive) {
|
||||||
this.toggleSound();
|
this.toggleSound();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private onControlMutation() {
|
private onControlMutation() {
|
||||||
|
const wasScreensharing = this.screenshare;
|
||||||
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
|
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
|
||||||
const spotlight: boolean = this.spotlightButton?.checked ?? false;
|
const spotlight: boolean = this.spotlightButton?.checked ?? false;
|
||||||
|
|
||||||
|
// C-M6: when a screenshare stops, clear the screenshare-audio mute so a
|
||||||
|
// later screenshare doesn't start pre-muted.
|
||||||
|
const screenshareAudioMuted =
|
||||||
|
wasScreensharing && !screenshare ? false : this.screenshareAudioMuted;
|
||||||
|
|
||||||
|
// C-H3: the body observer now watches subtree:true, so this fires on any DOM
|
||||||
|
// churn in EC's controls. Only re-emit (→ re-render every consumer) when one
|
||||||
|
// of the values this method derives actually changed — microphone/video/sound
|
||||||
|
// are copied unchanged from the current state here.
|
||||||
|
if (
|
||||||
|
this.state.screenshare === screenshare &&
|
||||||
|
this.state.spotlight === spotlight &&
|
||||||
|
this.state.screenshareAudioMuted === screenshareAudioMuted
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.state = new CallControlState(
|
this.state = new CallControlState(
|
||||||
this.microphone,
|
this.microphone,
|
||||||
this.video,
|
this.video,
|
||||||
this.sound,
|
this.sound,
|
||||||
screenshare,
|
screenshare,
|
||||||
spotlight,
|
spotlight,
|
||||||
this.screenshareAudioMuted,
|
screenshareAudioMuted,
|
||||||
);
|
);
|
||||||
this.emitStateUpdate();
|
this.emitStateUpdate();
|
||||||
}
|
}
|
||||||
@@ -423,10 +482,25 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
* clamped fork-side, so out-of-range input can't brick the encoder.
|
* clamped fork-side, so out-of-range input can't brick the encoder.
|
||||||
*/
|
*/
|
||||||
public setQuality(settings: LotusQualityPayload): void {
|
public setQuality(settings: LotusQualityPayload): void {
|
||||||
this.call.transport.send('io.lotus.set_quality', settings).catch(() => undefined);
|
// C-M3: remember the request and only send once joined; sendQuality() gates
|
||||||
|
// on this.joined so a pre-join call is a no-op that we replay on join.
|
||||||
|
this.lastQuality = settings;
|
||||||
|
this.sendQuality();
|
||||||
|
}
|
||||||
|
|
||||||
|
// C-M3: push the last-requested quality to the fork. Gated on this.joined so
|
||||||
|
// we never send io.lotus.set_quality before the fork's handler mounts (a
|
||||||
|
// pre-join send would pend to a 10s widget timeout).
|
||||||
|
private sendQuality(): void {
|
||||||
|
if (!this.joined || !this.lastQuality) return;
|
||||||
|
this.call.transport.send('io.lotus.set_quality', this.lastQuality).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose() {
|
public dispose() {
|
||||||
|
if (this.bodyMutationTimer !== undefined) {
|
||||||
|
clearTimeout(this.bodyMutationTimer);
|
||||||
|
this.bodyMutationTimer = undefined;
|
||||||
|
}
|
||||||
this.bodyMutationObserver.disconnect();
|
this.bodyMutationObserver.disconnect();
|
||||||
this.controlMutationObserver.disconnect();
|
this.controlMutationObserver.disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ export class CallEmbed {
|
|||||||
|
|
||||||
public joined = false;
|
public joined = false;
|
||||||
|
|
||||||
|
// C-M4: set once dispose() has run so the hangup fallback timer can tell
|
||||||
|
// whether the embed was already torn down by the normal Close/Hangup echo.
|
||||||
|
public disposed = false;
|
||||||
|
|
||||||
// [lotus #2] Latest per-participant state from io.lotus.call_state, or null
|
// [lotus #2] Latest per-participant state from io.lotus.call_state, or null
|
||||||
// until the fork sends the first one. When non-null, the speaker/mute hooks
|
// until the fork sends the first one. When non-null, the speaker/mute hooks
|
||||||
// read it instead of scraping the EC iframe DOM.
|
// read it instead of scraping the EC iframe DOM.
|
||||||
@@ -403,6 +407,8 @@ export class CallEmbed {
|
|||||||
* @param opts
|
* @param opts
|
||||||
*/
|
*/
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.disposed = true;
|
||||||
this.disposables.forEach((disposable) => {
|
this.disposables.forEach((disposable) => {
|
||||||
disposable();
|
disposable();
|
||||||
});
|
});
|
||||||
@@ -501,9 +507,19 @@ export class CallEmbed {
|
|||||||
|
|
||||||
private onCallJoined(): void {
|
private onCallJoined(): void {
|
||||||
this.settleLoad();
|
this.settleLoad();
|
||||||
this.joined = true;
|
|
||||||
this.applyStyles();
|
this.applyStyles();
|
||||||
this.control.startObserving();
|
this.control.startObserving();
|
||||||
|
|
||||||
|
// C-H1: EC fires JoinCall again on an EC reconnect (this action has no
|
||||||
|
// once-guard). forceState() would reset live mic/video/deafen back to the
|
||||||
|
// join-time snapshot, so only run it on the FIRST join. On a rejoin we just
|
||||||
|
// re-apply styles/observers (above) and re-push the sticky fork state
|
||||||
|
// (deafen/quality), leaving the user's live media state untouched.
|
||||||
|
if (this.joined) {
|
||||||
|
this.control.resendForkState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.joined = true;
|
||||||
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
|
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
|
||||||
this.control.forceState(this.initialState);
|
this.control.forceState(this.initialState);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
getUnreadInfo,
|
getUnreadInfo,
|
||||||
getUnreadInfos,
|
getUnreadInfos,
|
||||||
isNotificationEvent,
|
isNotificationEvent,
|
||||||
|
roomHaveUnread,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { roomToParentsAtom } from './roomToParents';
|
import { roomToParentsAtom } from './roomToParents';
|
||||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||||
@@ -82,7 +83,9 @@ const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, r
|
|||||||
allParents.forEach((parentId) => {
|
allParents.forEach((parentId) => {
|
||||||
const oldParentUnread = roomToUnread.get(parentId);
|
const oldParentUnread = roomToUnread.get(parentId);
|
||||||
if (!oldParentUnread) return;
|
if (!oldParentUnread) return;
|
||||||
const newFrom = new Set([...(oldParentUnread.from ?? roomId)]);
|
// `from` is always a Set for parent aggregates; the fallback must be an
|
||||||
|
// iterable of ids, NOT the roomId string (which would spread into chars).
|
||||||
|
const newFrom = new Set([...(oldParentUnread.from ?? [])]);
|
||||||
newFrom.delete(roomId);
|
newFrom.delete(roomId);
|
||||||
if (newFrom.size === 0) {
|
if (newFrom.size === 0) {
|
||||||
roomToUnread.delete(parentId);
|
roomToUnread.delete(parentId);
|
||||||
@@ -253,7 +256,20 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (isMyReceipt) {
|
if (isMyReceipt) {
|
||||||
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
// Don't blanket-DELETE the room's unread on any receipt: a THREADED
|
||||||
|
// receipt (reading one thread) would wipe the room's still-valid
|
||||||
|
// main-timeline badge, and if the room was already read no
|
||||||
|
// UnreadNotifications PUT follows to restore it. Recompute instead —
|
||||||
|
// DELETE only when the room is genuinely fully read.
|
||||||
|
const info = getUnreadInfo(
|
||||||
|
room,
|
||||||
|
getMutedThreads(threadNotificationsRef.current, room.roomId),
|
||||||
|
);
|
||||||
|
if (info.total === 0 && info.highlight === 0 && !roomHaveUnread(mx, room)) {
|
||||||
|
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
||||||
|
} else {
|
||||||
|
setUnreadAtom({ type: 'PUT', unreadInfo: info });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
mx.on(RoomEvent.Receipt, handleReceipt);
|
mx.on(RoomEvent.Receipt, handleReceipt);
|
||||||
|
|||||||
@@ -264,7 +264,21 @@ export const removeFallbackSession = () => {
|
|||||||
// the next setFallbackSession then persists the blob. When both exist the blob
|
// the next setFallbackSession then persists the blob. When both exist the blob
|
||||||
// wins by construction.
|
// wins by construction.
|
||||||
export const getFallbackSession = (): Session | undefined => {
|
export const getFallbackSession = (): Session | undefined => {
|
||||||
const persisted = readSessionBlob() ?? readLegacyKeys();
|
const blob = readSessionBlob();
|
||||||
|
const legacy = readLegacyKeys();
|
||||||
|
// Prefer the atomic blob, EXCEPT when the legacy keys carry a later expiry: a
|
||||||
|
// pre-blob build's token refresh writes only the legacy keys, so a
|
||||||
|
// downgrade→upgrade can leave a stale blob newer than fresh legacy keys →
|
||||||
|
// booting on a dead token. Whichever has the later expiresAt wins.
|
||||||
|
let persisted = blob ?? legacy;
|
||||||
|
if (
|
||||||
|
blob &&
|
||||||
|
legacy &&
|
||||||
|
typeof legacy.expiresAt === 'number' &&
|
||||||
|
(typeof blob.expiresAt !== 'number' || legacy.expiresAt > blob.expiresAt)
|
||||||
|
) {
|
||||||
|
persisted = legacy;
|
||||||
|
}
|
||||||
if (!persisted) return undefined;
|
if (!persisted) return undefined;
|
||||||
return sessionFromPersisted(persisted);
|
return sessionFromPersisted(persisted);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export type CompressionResult = {
|
export type CompressionResult = {
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
|
/** MIME type of the produced blob (currently always image/jpeg). */
|
||||||
|
type: string;
|
||||||
originalSize: number;
|
originalSize: number;
|
||||||
compressedSize: number;
|
compressedSize: number;
|
||||||
width: number;
|
width: number;
|
||||||
@@ -17,22 +19,47 @@ export function isCompressible(file: File | Blob): boolean {
|
|||||||
return isCompressibleType(file.type);
|
return isCompressibleType(file.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const JPEG_OUTPUT_TYPE = 'image/jpeg';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compress an image file via canvas.toBlob → JPEG at the given quality.
|
* Compress an image file via canvas.toBlob → JPEG at the given quality.
|
||||||
* Returns null if the browser cannot render the image (e.g. unsupported codec).
|
* Returns null if the browser cannot render the image (e.g. unsupported codec)
|
||||||
|
* or if the source is left untouched to avoid data loss (see below).
|
||||||
|
*
|
||||||
|
* PNG is skipped entirely: it may carry an alpha channel, and re-encoding to
|
||||||
|
* JPEG composites transparency onto an opaque (black) background, corrupting the
|
||||||
|
* image. Returning null makes callers fall back to uploading the lossless
|
||||||
|
* original. The image is decoded with `imageOrientation: 'from-image'` so any
|
||||||
|
* EXIF orientation is baked into the pixels instead of being silently dropped.
|
||||||
*/
|
*/
|
||||||
export async function compressImage(
|
export async function compressImage(
|
||||||
file: File | Blob,
|
file: File | Blob,
|
||||||
quality = 0.82,
|
quality = 0.82,
|
||||||
): Promise<CompressionResult | null> {
|
): Promise<CompressionResult | null> {
|
||||||
if (!isCompressibleType(file.type)) return null;
|
if (!isCompressibleType(file.type)) return null;
|
||||||
|
// Skip PNG (potential alpha) — re-encoding to JPEG would flatten transparency.
|
||||||
|
if (file.type === 'image/png') return null;
|
||||||
|
|
||||||
const img = await loadImage(file);
|
let bitmap: ImageBitmap;
|
||||||
|
try {
|
||||||
|
bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
|
||||||
|
} catch {
|
||||||
|
// Corrupt/unsupported source: fall back to uploading the lossless original
|
||||||
|
// (the caller uses the original file on a null result) rather than rejecting,
|
||||||
|
// which would drop the file entirely from the Promise.allSettled upload.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { width, height } = bitmap;
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = img.naturalWidth;
|
canvas.width = width;
|
||||||
canvas.height = img.naturalHeight;
|
canvas.height = height;
|
||||||
const ctx = canvas.getContext('2d')!;
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.drawImage(img, 0, 0);
|
if (!ctx) {
|
||||||
|
bitmap.close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ctx.drawImage(bitmap, 0, 0);
|
||||||
|
bitmap.close();
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
@@ -43,31 +70,19 @@ export async function compressImage(
|
|||||||
}
|
}
|
||||||
resolve({
|
resolve({
|
||||||
blob,
|
blob,
|
||||||
|
type: JPEG_OUTPUT_TYPE,
|
||||||
originalSize: file.size,
|
originalSize: file.size,
|
||||||
compressedSize: blob.size,
|
compressedSize: blob.size,
|
||||||
width: img.naturalWidth,
|
width,
|
||||||
height: img.naturalHeight,
|
height,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
'image/jpeg',
|
JPEG_OUTPUT_TYPE,
|
||||||
quality,
|
quality,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadImage(file: File | Blob): Promise<HTMLImageElement> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
resolve(img);
|
|
||||||
};
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatFileSize(bytes: number): string {
|
export function formatFileSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { NotificationCountType, ReceiptType } from 'matrix-js-sdk';
|
||||||
|
import { markAsRead } from './notifications';
|
||||||
|
|
||||||
|
// markAsRead sends an unthreaded read receipt at the latest main-timeline event,
|
||||||
|
// plus a THREADED receipt at each unread thread's latest loaded reply. The
|
||||||
|
// regression these tests guard against: a thread whose replies aren't loaded
|
||||||
|
// (lastReply() === null) must NOT produce a receipt for the thread root — that
|
||||||
|
// resolves to a MAIN receipt at an old event and permanently unreads the room.
|
||||||
|
|
||||||
|
type ReceiptCall = { eventId: string; receiptType: ReceiptType; unthreaded?: boolean };
|
||||||
|
|
||||||
|
const evt = (id: string, sending = false) => ({ getId: () => id, isSending: () => sending }) as any;
|
||||||
|
|
||||||
|
const thread = (id: string, lastReply: any) => ({ id, lastReply: () => lastReply }) as any;
|
||||||
|
|
||||||
|
type RoomOpts = {
|
||||||
|
timeline?: any[];
|
||||||
|
readUpTo?: string | null;
|
||||||
|
threads?: any[];
|
||||||
|
threadUnread?: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setup = (opts: RoomOpts) => {
|
||||||
|
const calls: ReceiptCall[] = [];
|
||||||
|
const room = {
|
||||||
|
getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }),
|
||||||
|
getEventReadUpTo: () => opts.readUpTo ?? null,
|
||||||
|
getThreads: () => opts.threads ?? [],
|
||||||
|
getThreadUnreadNotificationCount: (threadId: string, _type: NotificationCountType) =>
|
||||||
|
opts.threadUnread?.[threadId] ?? 0,
|
||||||
|
};
|
||||||
|
const mx = {
|
||||||
|
getRoom: () => room,
|
||||||
|
getUserId: () => '@me:server',
|
||||||
|
sendReadReceipt: async (event: any, receiptType: ReceiptType, unthreaded?: boolean) => {
|
||||||
|
calls.push({ eventId: event.getId(), receiptType, unthreaded });
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
return { mx, calls };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('main timeline: unthreaded receipt at the latest event', async () => {
|
||||||
|
const { mx, calls } = setup({ timeline: [evt('a'), evt('b'), evt('c')], readUpTo: 'a' });
|
||||||
|
await markAsRead(mx, '!r:server', false);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.deepEqual(calls[0], { eventId: 'c', receiptType: ReceiptType.Read, unthreaded: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('REGRESSION: an unread thread with unloaded replies (lastReply null) sends NO root receipt', async () => {
|
||||||
|
const t = thread('$root', null); // replies not loaded
|
||||||
|
const { mx, calls } = setup({
|
||||||
|
timeline: [evt('a'), evt('b')],
|
||||||
|
readUpTo: 'a',
|
||||||
|
threads: [t],
|
||||||
|
threadUnread: { $root: 3 },
|
||||||
|
});
|
||||||
|
await markAsRead(mx, '!r:server', false);
|
||||||
|
// Only the main unthreaded receipt — never a receipt for the thread root.
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].eventId, 'b');
|
||||||
|
assert.equal(calls[0].unthreaded, true);
|
||||||
|
assert.ok(!calls.some((c) => c.eventId === '$root'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unread thread with a loaded reply sends a threaded receipt at that reply', async () => {
|
||||||
|
const t = thread('$root', evt('$reply'));
|
||||||
|
const { mx, calls } = setup({
|
||||||
|
timeline: [evt('a'), evt('b')],
|
||||||
|
readUpTo: 'a',
|
||||||
|
threads: [t],
|
||||||
|
threadUnread: { $root: 1 },
|
||||||
|
});
|
||||||
|
await markAsRead(mx, '!r:server', false);
|
||||||
|
const main = calls.find((c) => c.eventId === 'b');
|
||||||
|
const threaded = calls.find((c) => c.eventId === '$reply');
|
||||||
|
assert.ok(main && main.unthreaded === true);
|
||||||
|
assert.ok(threaded && threaded.unthreaded === false);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('main already read but a thread is unread: no main receipt, threaded receipt only', async () => {
|
||||||
|
const t = thread('$root', evt('$reply'));
|
||||||
|
const { mx, calls } = setup({
|
||||||
|
timeline: [evt('a'), evt('b')],
|
||||||
|
readUpTo: 'b', // latest main event already read → getLatestValidEvent() null
|
||||||
|
threads: [t],
|
||||||
|
threadUnread: { $root: 2 },
|
||||||
|
});
|
||||||
|
await markAsRead(mx, '!r:server', false);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].eventId, '$reply');
|
||||||
|
assert.equal(calls[0].unthreaded, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('everything read: no receipts sent', async () => {
|
||||||
|
const t = thread('$root', evt('$reply'));
|
||||||
|
const { mx, calls } = setup({
|
||||||
|
timeline: [evt('a'), evt('b')],
|
||||||
|
readUpTo: 'b',
|
||||||
|
threads: [t],
|
||||||
|
threadUnread: { $root: 0 }, // thread read too
|
||||||
|
});
|
||||||
|
await markAsRead(mx, '!r:server', false);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sending thread reply is skipped', async () => {
|
||||||
|
const t = thread('$root', evt('$reply', true)); // isSending → skip
|
||||||
|
const { mx, calls } = setup({
|
||||||
|
timeline: [evt('a'), evt('b')],
|
||||||
|
readUpTo: 'b',
|
||||||
|
threads: [t],
|
||||||
|
threadUnread: { $root: 1 },
|
||||||
|
});
|
||||||
|
await markAsRead(mx, '!r:server', false);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('private receipt flag uses ReadPrivate', async () => {
|
||||||
|
const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a' });
|
||||||
|
await markAsRead(mx, '!r:server', true);
|
||||||
|
assert.equal(calls[0].receiptType, ReceiptType.ReadPrivate);
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MatrixClient, ReceiptType } from 'matrix-js-sdk';
|
import { MatrixClient, NotificationCountType, ReceiptType } from 'matrix-js-sdk';
|
||||||
import { getSettings } from '../state/settings';
|
import { getSettings } from '../state/settings';
|
||||||
|
|
||||||
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
|
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
|
||||||
@@ -6,6 +6,9 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
|
|||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
|
||||||
|
const receiptType =
|
||||||
|
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read;
|
||||||
|
|
||||||
const timeline = room.getLiveTimeline().getEvents();
|
const timeline = room.getLiveTimeline().getEvents();
|
||||||
const readEventId = room.getEventReadUpTo(mx.getUserId()!);
|
const readEventId = room.getEventReadUpTo(mx.getUserId()!);
|
||||||
|
|
||||||
@@ -17,17 +20,39 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
if (timeline.length === 0) return;
|
|
||||||
const latestEvent = getLatestValidEvent();
|
|
||||||
if (latestEvent === null) return;
|
|
||||||
|
|
||||||
// Unthreaded receipt: with client threadSupport enabled the SDK would
|
const latestEvent = timeline.length > 0 ? getLatestValidEvent() : null;
|
||||||
// otherwise scope this to the main timeline (thread_id: "main"), leaving
|
if (latestEvent) {
|
||||||
// per-thread notification counts permanently unread. Unthreaded preserves
|
// Unthreaded receipt: with client threadSupport enabled the SDK would
|
||||||
// the pre-threads wire behavior — one receipt clears everything.
|
// otherwise scope this to the main timeline (thread_id: "main"). Unthreaded
|
||||||
await mx.sendReadReceipt(
|
// clears the main timeline + every event up to this one.
|
||||||
latestEvent,
|
await mx.sendReadReceipt(latestEvent, receiptType, true);
|
||||||
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read,
|
}
|
||||||
true,
|
|
||||||
|
// Clear per-thread notification counts too — the room's unread dot sums them,
|
||||||
|
// so an unread thread reply keeps the dot lit even after the main timeline is
|
||||||
|
// read (threadSupport moves thread replies out of the main timeline, so the
|
||||||
|
// unthreaded receipt above doesn't necessarily cover them).
|
||||||
|
//
|
||||||
|
// CRITICAL: only send for a GENUINE loaded thread reply, via thread.lastReply().
|
||||||
|
// NEVER fall back to the thread root: a root event is "in the main timeline",
|
||||||
|
// so sendReadReceipt(root, false) resolves (via threadIdForReceipt) to a MAIN
|
||||||
|
// receipt at that old root event. If the root isn't in the loaded timeline it
|
||||||
|
// moves the main read receipt onto an event we don't have -> getEventReadUpTo()
|
||||||
|
// returns null -> the room is reported unread on every mark-read call (this was
|
||||||
|
// the P6 regression, amplified by the bulk mark-all-orphan-rooms-read callers).
|
||||||
|
// If a thread's replies aren't loaded (lastReply() null), just skip it.
|
||||||
|
const threads = room.getThreads();
|
||||||
|
await Promise.all(
|
||||||
|
threads.map((thread) => {
|
||||||
|
const unread =
|
||||||
|
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
|
||||||
|
if (unread <= 0) return undefined;
|
||||||
|
const lastReply = thread.lastReply();
|
||||||
|
if (!lastReply || lastReply.isSending()) return undefined;
|
||||||
|
// Threaded receipt (unthreaded = false → the SDK scopes it to this thread
|
||||||
|
// via the reply's real threadRootId; it never touches the main marker).
|
||||||
|
return mx.sendReadReceipt(lastReply, receiptType, false).catch(() => undefined);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,7 +269,15 @@ export const getUnreadInfos = (
|
|||||||
|
|
||||||
if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
|
if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
|
||||||
const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined;
|
const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined;
|
||||||
unread.push(getUnreadInfo(room, mutedThreads));
|
const info = getUnreadInfo(room, mutedThreads);
|
||||||
|
// Skip a phantom {0,0} entry: a room whose ONLY unread is a muted thread has
|
||||||
|
// roomHaveNotification true (the server room total includes the muted
|
||||||
|
// thread's count), but getUnreadInfo subtracts it back to zero. Pushing it
|
||||||
|
// would still light the nav row + pollute "unread only" filters. Keep it
|
||||||
|
// only if there's real unread (count > 0) or a genuine unread marker.
|
||||||
|
if (info.total > 0 || info.highlight > 0 || roomHaveUnread(mx, room)) {
|
||||||
|
unread.push(info);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return unread;
|
return unread;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export async function scheduleMessage(
|
|||||||
content: IContent,
|
content: IContent,
|
||||||
sendAtMs: number,
|
sendAtMs: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
// A past/near target floors at 1000ms (send ~immediately) — an intentional,
|
||||||
|
// tested contract; the ScheduleMessageModal already guards ≥60s in the future.
|
||||||
const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));
|
const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));
|
||||||
const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||||
const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
|
const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
|
||||||
|
|||||||
@@ -298,9 +298,23 @@ export const deleteSearchCacheDatabase = async (): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const req = indexedDB.deleteDatabase(DB_NAME);
|
const req = indexedDB.deleteDatabase(DB_NAME);
|
||||||
req.onsuccess = () => resolve();
|
let settled = false;
|
||||||
req.onerror = () => resolve();
|
const done = () => {
|
||||||
req.onblocked = () => resolve();
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.onsuccess = done;
|
||||||
|
req.onerror = done;
|
||||||
|
req.onblocked = () => {
|
||||||
|
// Another tab still holds the DB open, so the delete is QUEUED, not done —
|
||||||
|
// resolving now would report a wipe that hasn't happened (plaintext still
|
||||||
|
// on disk). Wait for the real onsuccess (fires once the other tab closes;
|
||||||
|
// cross-tab logout reloads it shortly), but cap the wait so logout can't
|
||||||
|
// hang forever if a tab never releases.
|
||||||
|
setTimeout(done, 3000);
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user