Compare commits
12
Commits
9a85a48704
...
a29be7953b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a29be7953b | ||
|
|
26c70f5a1d | ||
|
|
fc68e0a769 | ||
|
|
fb14db6d50 | ||
|
|
6b9c8de393 | ||
|
|
32e5fec6f3 | ||
|
|
8210ee7046 | ||
|
|
d0dccdeb67 | ||
|
|
dddaa4183e | ||
|
|
c8e49d3855 | ||
|
|
9bdf4ff1fd | ||
|
|
23ee156f2f |
@@ -45,6 +45,8 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones';
|
||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||
import { useCallHotkeys } from '../hooks/useCallHotkeys';
|
||||
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
|
||||
import { useCallQuality } from '../hooks/useCallQuality';
|
||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||
@@ -609,9 +611,15 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
);
|
||||
}
|
||||
|
||||
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
|
||||
// [Gitea #9] PTT/deafen hotkeys and AFK auto-mute are bound here, for the
|
||||
// embed's whole lifetime, rather than in CallControls (which only renders
|
||||
// while the call room is selected) — so they keep working in PiP and behind
|
||||
// the mobile in-call chat. Both are gated on `joined`.
|
||||
useCallHotkeys(embed, joined);
|
||||
useAfkAutoMute(joined ? embed : undefined);
|
||||
useCallMemberSoundSync(embed);
|
||||
useCallJoinLeaveSounds(embed);
|
||||
useCallThemeSync(embed);
|
||||
@@ -1146,7 +1154,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
|
||||
return (
|
||||
<CallEmbedContextProvider value={callEmbed}>
|
||||
{callEmbed && <CallUtils embed={callEmbed} />}
|
||||
{callEmbed && <CallUtils embed={callEmbed} joined={joined} />}
|
||||
<CallEmbedRefContextProvider value={callEmbedRef}>
|
||||
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
||||
{children}
|
||||
|
||||
@@ -73,6 +73,32 @@ const formatClipSeconds = (seconds: number): string => {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #31] Pure running-count cap check for `handleFiles`: given how many
|
||||
* clips already exist (staged uploads included) before this batch starts,
|
||||
* decide which of the batch's files fit under `max`. Kept pure/exported so the
|
||||
* "running count, not a stale double-counted closure value" logic can be unit
|
||||
* tested without a DOM/MatrixClient.
|
||||
*/
|
||||
export function acceptClips<T>(
|
||||
currentCount: number,
|
||||
files: readonly T[],
|
||||
max: number,
|
||||
): { accepted: T[]; rejected: T[] } {
|
||||
const accepted: T[] = [];
|
||||
const rejected: T[] = [];
|
||||
let count = currentCount;
|
||||
files.forEach((file) => {
|
||||
if (count >= max) {
|
||||
rejected.push(file);
|
||||
} else {
|
||||
accepted.push(file);
|
||||
count += 1;
|
||||
}
|
||||
});
|
||||
return { accepted, rejected };
|
||||
}
|
||||
|
||||
type ClipDraft = {
|
||||
url: string;
|
||||
body: string;
|
||||
@@ -186,11 +212,19 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
||||
...existing.map((c) => c.shortcode),
|
||||
...uploads.map((u) => u.shortcode),
|
||||
]);
|
||||
for (let i = 0; i < files.length; i += 1) {
|
||||
const file = files[i];
|
||||
if (clipCount + uploads.length >= SOUNDBOARD_MAX_CLIPS) {
|
||||
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
|
||||
}
|
||||
// [Gitea #31] `clipCount` already includes staged `uploads`, so don't
|
||||
// add `uploads.length` again here (double-counting). And since
|
||||
// `setUploads` inside the loop doesn't update this closure's
|
||||
// `clipCount`, track the running total in a local variable that starts
|
||||
// from the real current total instead of re-reading a stale value for
|
||||
// every file in the batch.
|
||||
const { accepted, rejected } = acceptClips(
|
||||
clipCount,
|
||||
Array.from(files),
|
||||
SOUNDBOARD_MAX_CLIPS,
|
||||
);
|
||||
for (let i = 0; i < accepted.length; i += 1) {
|
||||
const file = accepted[i];
|
||||
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
|
||||
throw new Error(`"${file.name}" is too large (max 1 MB).`);
|
||||
}
|
||||
@@ -215,6 +249,9 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (rejected.length > 0) {
|
||||
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Upload failed.');
|
||||
} finally {
|
||||
|
||||
@@ -103,7 +103,8 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: Bookm
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const room = mx.getRoom(bookmark.roomId) ?? undefined;
|
||||
const displayRoomName = room?.name ?? bookmark.roomName;
|
||||
// E2EE-room bookmarks store no roomName; fall back past the '' placeholder.
|
||||
const displayRoomName = room?.name || bookmark.roomName || 'Unknown room';
|
||||
const avatarUrl = room
|
||||
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
|
||||
: undefined;
|
||||
@@ -162,7 +163,7 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: Bookm
|
||||
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
|
||||
>
|
||||
<Text className={css.BookmarkPreview} size="T200" priority="400">
|
||||
{preview ?? (bookmark.previewText || '(no preview)')}
|
||||
{preview ?? (bookmark.previewText || 'Message unavailable')}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -173,13 +174,16 @@ type LiveBookmarkItemProps = BookmarkItemProps & { room: Room };
|
||||
|
||||
// Renders the same layout as BookmarkItem, but resolves the message body live so
|
||||
// edits (m.replace, applied by useRoomEvent) and redactions are reflected. The
|
||||
// stored snapshot (previewText) remains the fallback for loading/failed/empty states.
|
||||
// stored snapshot (previewText) remains the fallback for loading/failed/empty
|
||||
// states; bookmarks from E2EE rooms have no snapshot at all (account data is
|
||||
// server-readable), so the live event is their only source of text.
|
||||
function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItemProps) {
|
||||
const liveEvent = useRoomEvent(room, bookmark.eventId, () =>
|
||||
room.findEventById(bookmark.eventId),
|
||||
);
|
||||
|
||||
const snapshot = bookmark.previewText || '(no preview)';
|
||||
const snapshot =
|
||||
bookmark.previewText || (liveEvent === undefined ? 'Loading…' : 'Message unavailable');
|
||||
let preview: ReactNode = snapshot;
|
||||
|
||||
// undefined (loading) and null (fetch failed / not found) both keep the snapshot.
|
||||
@@ -234,7 +238,7 @@ function RoomGroupHeader({
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const room = mx.getRoom(roomId) ?? undefined;
|
||||
const displayRoomName = room?.name ?? roomName;
|
||||
const displayRoomName = room?.name || roomName || 'Unknown room';
|
||||
const avatarUrl = room
|
||||
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
|
||||
: undefined;
|
||||
@@ -327,13 +331,20 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
||||
() =>
|
||||
query.length === 0
|
||||
? bookmarks
|
||||
: bookmarks.filter(
|
||||
(bk) =>
|
||||
bk.previewText.toLowerCase().includes(query) ||
|
||||
: bookmarks.filter((bk) => {
|
||||
// E2EE-room bookmarks have no stored text: match against the locally
|
||||
// cached event body / live room name instead (nothing is fetched here).
|
||||
const room = mx.getRoom(bk.roomId);
|
||||
const localBody = room?.findEventById(bk.eventId)?.getContent()?.body;
|
||||
return (
|
||||
(bk.previewText?.toLowerCase().includes(query) ?? false) ||
|
||||
(typeof localBody === 'string' && localBody.toLowerCase().includes(query)) ||
|
||||
bk.roomName.toLowerCase().includes(query) ||
|
||||
(bk.senderName?.toLowerCase().includes(query) ?? false),
|
||||
),
|
||||
[bookmarks, query],
|
||||
(room?.name.toLowerCase().includes(query) ?? false) ||
|
||||
(bk.senderName?.toLowerCase().includes(query) ?? false)
|
||||
);
|
||||
}),
|
||||
[mx, bookmarks, query],
|
||||
);
|
||||
|
||||
// Prune collapsed roomIds that no longer have any bookmark, so a room re-saved
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
||||
import React, { useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
color,
|
||||
config,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
} from 'folds';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { StatusDivider } from './components';
|
||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
||||
|
||||
type MicrophoneButtonProps = {
|
||||
enabled: boolean;
|
||||
@@ -177,6 +191,23 @@ export function CallControl({
|
||||
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
|
||||
// [Gitea #26] Apply the same room-level camera/screenshare policy as the
|
||||
// in-room CallControls bar, so the status bar can't be used to bypass it.
|
||||
const { allowCamera, allowScreenshare } = useRoomCallPolicy(callEmbed.room);
|
||||
// Keep a forbidden control visible while its track is still live (so the user
|
||||
// can stop it); otherwise hide it entirely.
|
||||
const showCamera = allowCamera || video;
|
||||
const showScreenshare = allowScreenshare || screenshare;
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!shareConfirm) return undefined;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setShareConfirm(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [shareConfirm]);
|
||||
|
||||
const handleMicrophoneToggle = useCallback(
|
||||
() => callEmbed.control.toggleMicrophone(),
|
||||
[callEmbed],
|
||||
@@ -198,7 +229,65 @@ export function CallControl({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box shrink="No" alignItems="Center" gap="300">
|
||||
<Box shrink="No" alignItems="Center" gap="300" style={{ position: 'relative' }}>
|
||||
{shareConfirm && (
|
||||
<>
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
|
||||
onClick={() => setShareConfirm(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '110%',
|
||||
left: 0,
|
||||
background: color.Surface.Container,
|
||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1rem 1.25rem',
|
||||
zIndex: 100,
|
||||
minWidth: '260px',
|
||||
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<Text size="T300" style={{ fontWeight: 600 }}>
|
||||
Share your screen?
|
||||
</Text>
|
||||
<Text size="T200" style={{ opacity: 0.75 }}>
|
||||
Your screen will be visible to all participants in this call.
|
||||
</Text>
|
||||
<Box gap="200">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
callEmbed.control.toggleScreenshare();
|
||||
setShareConfirm(false);
|
||||
}}
|
||||
>
|
||||
<Text size="B300">Share</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={() => setShareConfirm(false)}
|
||||
>
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Box alignItems="Inherit" gap="200">
|
||||
<MicrophoneButton
|
||||
enabled={microphone}
|
||||
@@ -210,12 +299,16 @@ export function CallControl({
|
||||
onToggle={() => callEmbed.control.toggleSound()}
|
||||
disabled={!callJoined}
|
||||
/>
|
||||
{!compact && <StatusDivider />}
|
||||
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
|
||||
{!compact && (
|
||||
{!compact && (showCamera || showScreenshare) && <StatusDivider />}
|
||||
{showCamera && (
|
||||
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
|
||||
)}
|
||||
{!compact && showScreenshare && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() => callEmbed.control.toggleScreenshare()}
|
||||
onToggle={() =>
|
||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||
}
|
||||
disabled={!callJoined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -39,7 +39,7 @@ import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
|
||||
import { pttActiveAtom } from '../../hooks/useCallHotkeys';
|
||||
import { CallSoundboard } from './CallSoundboard';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
@@ -88,8 +88,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
|
||||
useCallControlState(callEmbed.control);
|
||||
|
||||
useAfkAutoMute(callEmbed);
|
||||
|
||||
const [cords, setCords] = useState<RectCords>();
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -102,8 +100,12 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
}, [shareConfirm]);
|
||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
|
||||
// [Gitea #9] PTT/deafen key handling and AFK auto-mute live in useCallHotkeys
|
||||
// / useAfkAutoMute, mounted from CallEmbedProvider for the embed's lifetime
|
||||
// (this component only renders while the call room is selected). Only the
|
||||
// visual PTT chip remains here.
|
||||
const pttActive = useAtomValue(pttActiveAtom);
|
||||
|
||||
// [P5-31] Hard room publish policy — hide controls the server will refuse so
|
||||
// users don't click dead buttons. Absent/true = allowed.
|
||||
@@ -116,28 +118,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
const showCamera = cameraAllowed || video;
|
||||
const showScreenshare = screenshareAllowed || screenshare;
|
||||
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||
const [pttActive, setPttActive] = useState(false);
|
||||
|
||||
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||||
const microphoneRef = useRef(microphone);
|
||||
useEffect(() => {
|
||||
microphoneRef.current = microphone;
|
||||
}, [microphone]);
|
||||
|
||||
// Handle PTT mode toggle mid-call — save/restore mic state (I-4)
|
||||
const pttModeRef = useRef(pttMode);
|
||||
const micBeforePTTRef = useRef<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
if (pttMode && !pttModeRef.current) {
|
||||
micBeforePTTRef.current = microphoneRef.current;
|
||||
callEmbed.control.setMicrophone(false);
|
||||
} else if (!pttMode && pttModeRef.current) {
|
||||
callEmbed.control.setMicrophone(micBeforePTTRef.current ?? true);
|
||||
micBeforePTTRef.current = null;
|
||||
}
|
||||
pttModeRef.current = pttMode;
|
||||
}, [pttMode, callEmbed]);
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
@@ -163,138 +143,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
);
|
||||
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
|
||||
|
||||
const pttActiveRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pttMode) return;
|
||||
const iframeWindow = callEmbed.iframe.contentWindow;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code !== pttKey || e.repeat) return;
|
||||
// [Gitea #23] Ignore the PTT key with Ctrl/Alt/Meta held so it doesn't
|
||||
// hijack OS/app chords (e.g. Cmd+Space) that happen to share the code.
|
||||
// Shift is allowed through — Shift+Space is a harmless combo for PTT.
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
const target = e.target as HTMLElement;
|
||||
// BUG-7: use ownerDocument.body so isEditable works inside the EC iframe
|
||||
const isEditable = (el: HTMLElement): boolean => {
|
||||
const tag = el.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== el.ownerDocument.body) {
|
||||
if (node.contentEditable === 'true') return true;
|
||||
if (node.contentEditable === 'false') return false;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (isEditable(target)) return;
|
||||
// [Gitea #23] Don't swallow Space on a focused button/link/etc — PTT still
|
||||
// engages the mic, but the key's default action (activating the control)
|
||||
// is left alone so keyboard users can still Tab+Space the call buttons.
|
||||
const isInteractive = (el: HTMLElement): boolean => {
|
||||
const tag = el.tagName;
|
||||
if (tag === 'BUTTON' || tag === 'A' || tag === 'SELECT') return true;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== el.ownerDocument.body) {
|
||||
const role = node.getAttribute('role');
|
||||
if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') {
|
||||
return true;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!isInteractive(target)) 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);
|
||||
pttActiveRef.current = true;
|
||||
setPttActive(true);
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code !== pttKey) return;
|
||||
callEmbed.control.pttActive = false;
|
||||
callEmbed.control.setMicrophone(false);
|
||||
pttActiveRef.current = false;
|
||||
setPttActive(false);
|
||||
};
|
||||
const onBlur = () => {
|
||||
callEmbed.control.pttActive = false;
|
||||
callEmbed.control.setMicrophone(false);
|
||||
pttActiveRef.current = false;
|
||||
setPttActive(false);
|
||||
};
|
||||
const onFocus = () => {
|
||||
callEmbed.control.pttActive = false;
|
||||
callEmbed.control.setMicrophone(false);
|
||||
pttActiveRef.current = false;
|
||||
setPttActive(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', onBlur);
|
||||
window.addEventListener('focus', onFocus);
|
||||
// BUG-9: also wire iframe blur/focus so stuck-mic release works when focus moves to iframe
|
||||
iframeWindow?.addEventListener('keydown', onKeyDown);
|
||||
iframeWindow?.addEventListener('keyup', onKeyUp);
|
||||
iframeWindow?.addEventListener('blur', onBlur);
|
||||
iframeWindow?.addEventListener('focus', onFocus);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
iframeWindow?.removeEventListener('keydown', onKeyDown);
|
||||
iframeWindow?.removeEventListener('keyup', onKeyUp);
|
||||
iframeWindow?.removeEventListener('blur', onBlur);
|
||||
iframeWindow?.removeEventListener('focus', onFocus);
|
||||
// BUG-8: if callEmbed changes while PTT is active, release mic on cleanup
|
||||
if (pttActiveRef.current) {
|
||||
callEmbed.control.pttActive = false;
|
||||
callEmbed.control.setMicrophone(false);
|
||||
pttActiveRef.current = false;
|
||||
setPttActive(false);
|
||||
}
|
||||
};
|
||||
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
|
||||
}, [pttMode, pttKey, callEmbed]);
|
||||
|
||||
useEffect(() => {
|
||||
const isEditable = (el: HTMLElement): boolean => {
|
||||
const tag = el.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== el.ownerDocument.body) {
|
||||
if (node.contentEditable === 'true') return true;
|
||||
if (node.contentEditable === 'false') return false;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code !== deafenKey) return;
|
||||
if (e.repeat) return;
|
||||
// [Gitea #23] Ignore the deafen key with any modifier held — with the
|
||||
// default 'KeyM', Ctrl+M / Alt+M / Cmd+M are common OS/app chords that
|
||||
// shouldn't also toggle deafen (and previously got preventDefault()ed).
|
||||
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return;
|
||||
if (isEditable(e.target as HTMLElement)) return;
|
||||
e.preventDefault();
|
||||
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);
|
||||
iframeWindow?.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
iframeWindow?.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [callEmbed, deafenKey]);
|
||||
|
||||
const [hangupState, hangup] = useAsyncCallback(
|
||||
useCallback(() => callEmbed.hangup(), [callEmbed]),
|
||||
);
|
||||
|
||||
@@ -18,8 +18,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
import { LotusDecorationPusher } from '../lotus/LotusDecorationPusher';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
|
||||
import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
|
||||
import { CallMemberRenderer } from './CallMemberCard';
|
||||
import * as css from './styles.css';
|
||||
import { CallControls } from './CallControls';
|
||||
@@ -114,12 +113,9 @@ function CallPrescreen() {
|
||||
const callEmbed = useCallEmbed();
|
||||
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
|
||||
|
||||
// Voice channel user limit (io.lotus.voice_limit). 0 / absent means no limit.
|
||||
const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
|
||||
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
|
||||
// A user already counted in the session is rejoining and should not be blocked.
|
||||
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId());
|
||||
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers;
|
||||
// [Gitea #30] Voice channel user limit (io.lotus.voice_limit), shared with the
|
||||
// room-nav join path via useVoiceChannelFull so both agree on "full".
|
||||
const { channelFull, current: callMembersCount, max: maxUsers } = useVoiceChannelFull(room);
|
||||
|
||||
const canJoin = hasPermission && livekitSupported && rtcSupported && !channelFull;
|
||||
|
||||
@@ -144,7 +140,7 @@ function CallPrescreen() {
|
||||
<Box className={css.PrescreenMessage} alignItems="Center">
|
||||
{!inOtherCall && !hasPermission && <NoPermissionMessage />}
|
||||
{!inOtherCall && hasPermission && channelFull && (
|
||||
<ChannelFullMessage current={callMembers.length} max={maxUsers} />
|
||||
<ChannelFullMessage current={callMembersCount} max={maxUsers} />
|
||||
)}
|
||||
{!inOtherCall && hasPermission && !channelFull && (
|
||||
<JoinMessage
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { SearchCacheRow } from '../../utils/searchCache';
|
||||
|
||||
// useLocalMessageSearch.ts imports searchCacheEnabledAtom, which touches
|
||||
// localStorage at module-load time (atomWithLocalStorage reads the initial
|
||||
// value eagerly). Stub it before a dynamic import — a static import would
|
||||
// hoist above the stub. Same pattern as state/plaintextCaches.test.ts.
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
|
||||
const { rowToResultItem } = await import('./useLocalMessageSearch');
|
||||
|
||||
const row = (overrides: Partial<SearchCacheRow> = {}): SearchCacheRow => ({
|
||||
roomId: '!r1',
|
||||
eventId: '$1',
|
||||
ts: 100,
|
||||
sender: '@a',
|
||||
body: 'hello world',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Gitea #14 — cached rows for a locally-known-redacted event must carry a
|
||||
// `redacted_because` marker so SearchResultGroup's guard renders the
|
||||
// "message deleted" placeholder instead of the stale plaintext.
|
||||
|
||||
test('rowToResultItem: plain row has no redacted_because marker', () => {
|
||||
const item = rowToResultItem(row());
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
assert.equal((item.event as any).unsigned?.redacted_because, undefined);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
assert.equal((item.event as any).content.body, 'hello world');
|
||||
});
|
||||
|
||||
test('rowToResultItem: redacted=true sets the redacted_because marker', () => {
|
||||
const item = rowToResultItem(row(), true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
assert.ok((item.event as any).unsigned?.redacted_because);
|
||||
});
|
||||
|
||||
test('rowToResultItem: falls back to pollText when body is empty', () => {
|
||||
const item = rowToResultItem(row({ body: '', pollText: 'question answer' }));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
assert.equal((item.event as any).content.body, 'question answer');
|
||||
});
|
||||
|
||||
test('rowToResultItem: carries formattedBody as HTML when present', () => {
|
||||
const item = rowToResultItem(row({ formattedBody: '<b>hi</b>' }));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const content = (item.event as any).content;
|
||||
assert.equal(content.format, 'org.matrix.custom.html');
|
||||
assert.equal(content.formatted_body, '<b>hi</b>');
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EventType, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { EventType, MatrixEvent, RelationType } from 'matrix-js-sdk';
|
||||
import { useCallback } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
@@ -38,13 +38,15 @@ type ExtractedText = {
|
||||
const POLL_START_TYPES = ['m.poll.start', 'org.matrix.msc3381.poll.start'];
|
||||
|
||||
/**
|
||||
* Pull the text we index/search from a decrypted event's content. Returns
|
||||
* `null` for events that carry no searchable text (e.g. stickers).
|
||||
* Pull the text we index/search from an event type + content pair. Returns
|
||||
* `null` when there's no searchable text (e.g. stickers). Split out from
|
||||
* `extractText` so an edit's `m.new_content` can be run through the same
|
||||
* logic as a normal event's content.
|
||||
*/
|
||||
const extractText = (event: MatrixEvent): ExtractedText | null => {
|
||||
const evType = event.getType();
|
||||
const content = event.getContent();
|
||||
|
||||
const extractTextFromContent = (
|
||||
evType: string,
|
||||
content: Record<string, unknown>,
|
||||
): ExtractedText | null => {
|
||||
if (POLL_START_TYPES.includes(evType)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as any;
|
||||
@@ -74,6 +76,13 @@ const extractText = (event: MatrixEvent): ExtractedText | null => {
|
||||
return { body, formattedBody, pollText: '' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Pull the text we index/search from a decrypted event's content. Returns
|
||||
* `null` for events that carry no searchable text (e.g. stickers).
|
||||
*/
|
||||
const extractText = (event: MatrixEvent): ExtractedText | null =>
|
||||
extractTextFromContent(event.getType(), event.getContent());
|
||||
|
||||
/** Does the extracted text contain the (already-lowercased) term? */
|
||||
const matchesTerm = (text: ExtractedText, termLower: string): boolean =>
|
||||
text.body.toLowerCase().includes(termLower) ||
|
||||
@@ -85,8 +94,17 @@ const rowMatchesTerm = (row: SearchCacheRow, termLower: string): boolean =>
|
||||
(row.formattedBody ?? '').toLowerCase().includes(termLower) ||
|
||||
(row.pollText ?? '').toLowerCase().includes(termLower);
|
||||
|
||||
/** Build the synthetic result item a cached row renders as (text message). */
|
||||
const rowToResultItem = (row: SearchCacheRow): ResultItem => {
|
||||
/**
|
||||
* Build the synthetic result item a cached row renders as (text message).
|
||||
*
|
||||
* `redacted` marks a row whose event we can tell, from the local timeline,
|
||||
* has since been redacted (the async cache-delete listener in
|
||||
* `searchCacheInvalidation.ts` may not have caught up yet). It carries a
|
||||
* `redacted_because` marker on `unsigned` so `SearchResultGroup`'s existing
|
||||
* guard renders the "message deleted" placeholder instead of the stale
|
||||
* plaintext (Gitea #14).
|
||||
*/
|
||||
export const rowToResultItem = (row: SearchCacheRow, redacted = false): ResultItem => {
|
||||
const bodyText = row.body || row.pollText || '';
|
||||
const content: Record<string, unknown> = { msgtype: 'm.text', body: bodyText };
|
||||
if (row.formattedBody) {
|
||||
@@ -100,7 +118,7 @@ const rowToResultItem = (row: SearchCacheRow): ResultItem => {
|
||||
sender: row.sender,
|
||||
origin_server_ts: row.ts,
|
||||
content,
|
||||
unsigned: {},
|
||||
unsigned: redacted ? { redacted_because: { content: {} } } : {},
|
||||
};
|
||||
return {
|
||||
rank: 0,
|
||||
@@ -196,16 +214,43 @@ export const useLocalMessageSearch = () => {
|
||||
|
||||
// Persist every indexable (text-bearing) event we scanned, regardless
|
||||
// of whether it matches the current term — future searches benefit.
|
||||
if (cacheEnabled && text && event.getId()) {
|
||||
rowsToPersist.push({
|
||||
roomId,
|
||||
eventId: event.getId() as string,
|
||||
ts,
|
||||
sender,
|
||||
body: text.body,
|
||||
...(text.formattedBody ? { formattedBody: text.formattedBody } : {}),
|
||||
...(text.pollText ? { pollText: text.pollText } : {}),
|
||||
});
|
||||
if (cacheEnabled && event.getId()) {
|
||||
// An edit (`m.replace`) event's own body is just a "* new text"
|
||||
// fallback. Indexing it under its own event id would leave two
|
||||
// separate matching rows (the stale pre-edit text and the edit
|
||||
// fallback) searchable forever. Instead, upsert the *original*
|
||||
// event's row with the edit's `m.new_content` (Gitea #14).
|
||||
const editTargetId =
|
||||
event.getRelation()?.rel_type === RelationType.Replace
|
||||
? event.getRelation()?.event_id
|
||||
: undefined;
|
||||
if (editTargetId) {
|
||||
const newContent = (event.getContent()['m.new_content'] ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const editedText = extractTextFromContent(EventType.RoomMessage, newContent);
|
||||
if (editedText) {
|
||||
rowsToPersist.push({
|
||||
roomId,
|
||||
eventId: editTargetId,
|
||||
ts: room.findEventById(editTargetId)?.getTs() ?? ts,
|
||||
sender,
|
||||
body: editedText.body,
|
||||
...(editedText.formattedBody ? { formattedBody: editedText.formattedBody } : {}),
|
||||
});
|
||||
}
|
||||
} else if (text) {
|
||||
rowsToPersist.push({
|
||||
roomId,
|
||||
eventId: event.getId() as string,
|
||||
ts,
|
||||
sender,
|
||||
body: text.body,
|
||||
...(text.formattedBody ? { formattedBody: text.formattedBody } : {}),
|
||||
...(text.pollText ? { pollText: text.pollText } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (senderSet && !senderSet.has(sender)) continue;
|
||||
@@ -239,7 +284,12 @@ export const useLocalMessageSearch = () => {
|
||||
if (senderSet && !senderSet.has(row.sender)) return;
|
||||
if (!inRange(row.ts)) return;
|
||||
if (!senderOnlyMode && !rowMatchesTerm(row, termLower)) return;
|
||||
cachedItems.push(rowToResultItem(row));
|
||||
// The cache-delete listener (searchCacheInvalidation.ts) removes a
|
||||
// row on redaction asynchronously; if the event is still around
|
||||
// locally we can check for certain and must not surface stale
|
||||
// plaintext in the meantime (Gitea #14).
|
||||
const localEvent = room.findEventById(row.eventId);
|
||||
cachedItems.push(rowToResultItem(row, localEvent?.isRedacted()));
|
||||
});
|
||||
|
||||
const items = mergeSearchResults(memoryItems, cachedItems);
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
} from '../../hooks/useRoomMeta';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
||||
import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
||||
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
|
||||
@@ -691,8 +692,12 @@ function RoomNavItem_({
|
||||
const callMembers = useCallMembers(callSession);
|
||||
const startCall = useCallStart(direct);
|
||||
const callEmbed = useCallEmbed();
|
||||
// [Gitea #30] Same voice-limit check the call prescreen uses, so the sidebar
|
||||
// second-click join path can't bypass a full channel.
|
||||
const { channelFull, current: voiceCurrent, max: voiceMax } = useVoiceChannelFull(room);
|
||||
const callPref = useAtomValue(useCallPreferencesAtom());
|
||||
const autoDiscoveryInfo = useAutoDiscoveryInfo();
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
|
||||
const handleStartCall: MouseEventHandler<HTMLAnchorElement> = (evt) => {
|
||||
const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels);
|
||||
@@ -714,6 +719,22 @@ function RoomNavItem_({
|
||||
if (callEmbed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// [Gitea #30] Refuse to start a call into a full voice channel — the
|
||||
// prescreen already blocks this, but the sidebar second-click join path
|
||||
// skipped the check entirely.
|
||||
if (channelFull) {
|
||||
evt.preventDefault();
|
||||
setToast(
|
||||
createErrorToast(
|
||||
`Channel full (${voiceCurrent}/${voiceMax})`,
|
||||
Icons.Warning,
|
||||
'Cannot join',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start call in second click
|
||||
if (selected) {
|
||||
evt.preventDefault();
|
||||
|
||||
@@ -1273,6 +1273,9 @@ export const Message = React.memo(
|
||||
const content = mEvent.getContent();
|
||||
const body: string =
|
||||
(content?.body as string | undefined) ?? '';
|
||||
// For E2EE rooms useBookmarks strips the text
|
||||
// fields before persisting (account data is
|
||||
// server-readable); the panel resolves them live.
|
||||
addBookmark({
|
||||
roomId: room.roomId,
|
||||
eventId,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { MatrixEvent } from 'matrix-js-sdk';
|
||||
import { widgetsFromStateEvents } from './useRoomWidgets';
|
||||
|
||||
const APP = 'https://chat.lotusguild.org';
|
||||
|
||||
// Minimal fake MatrixEvent, just enough of the surface widgetsFromStateEvents reads.
|
||||
const fakeEvent = (stateKey: string, sender: string, content: Record<string, unknown>) =>
|
||||
({
|
||||
getStateKey: () => stateKey,
|
||||
getSender: () => sender,
|
||||
getContent: () => content,
|
||||
}) as unknown as MatrixEvent;
|
||||
|
||||
test('returns a Widget for a valid im.vector.modular.widgets state event', () => {
|
||||
// Regression test for matrix-widget-api 1.17.0's broken isValidUrl, which
|
||||
// compares URL.protocol ("https:") to "https" and rejects every URL,
|
||||
// making WidgetParser.parseWidgetsFromRoomState always return [].
|
||||
const events = new Map([
|
||||
[
|
||||
'w1',
|
||||
fakeEvent('w1', '@a:example.org', {
|
||||
id: 'w1',
|
||||
type: 'custom',
|
||||
url: 'https://example.com/widget',
|
||||
name: 'My Widget',
|
||||
creatorUserId: '@a:example.org',
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
const widgets = widgetsFromStateEvents(events, APP);
|
||||
assert.equal(widgets.length, 1);
|
||||
assert.equal(widgets[0].id, 'w1');
|
||||
assert.equal(widgets[0].templateUrl, 'https://example.com/widget');
|
||||
assert.equal(widgets[0].creatorUserId, '@a:example.org');
|
||||
assert.equal(widgets[0].name, 'My Widget');
|
||||
});
|
||||
|
||||
test('skips removed widgets (empty content)', () => {
|
||||
const events = new Map([['w1', fakeEvent('w1', '@a:example.org', {})]]);
|
||||
assert.deepEqual(widgetsFromStateEvents(events, APP), []);
|
||||
});
|
||||
|
||||
test('skips non-https and same-origin urls', () => {
|
||||
const events = new Map([
|
||||
[
|
||||
'w1',
|
||||
fakeEvent('w1', '@a:example.org', {
|
||||
id: 'w1',
|
||||
type: 'custom',
|
||||
url: 'http://example.com/widget',
|
||||
creatorUserId: '@a:example.org',
|
||||
}),
|
||||
],
|
||||
[
|
||||
'w2',
|
||||
fakeEvent('w2', '@a:example.org', {
|
||||
id: 'w2',
|
||||
type: 'custom',
|
||||
url: `${APP}/evil`,
|
||||
creatorUserId: '@a:example.org',
|
||||
}),
|
||||
],
|
||||
]);
|
||||
assert.deepEqual(widgetsFromStateEvents(events, APP), []);
|
||||
});
|
||||
|
||||
test('undefined state map yields no widgets', () => {
|
||||
assert.deepEqual(widgetsFromStateEvents(undefined, APP), []);
|
||||
});
|
||||
@@ -1,21 +1,67 @@
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Room, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { useMemo } from 'react';
|
||||
import { Widget, WidgetParser, IStateEvent } from 'matrix-widget-api';
|
||||
import { Widget } from 'matrix-widget-api';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { useRoomState } from '../../../hooks/useRoomState';
|
||||
import { StateKeyToEvents, useRoomState } from '../../../hooks/useRoomState';
|
||||
import { isWidgetUrlSafe } from './widgetUtils';
|
||||
|
||||
/**
|
||||
* Builds the `Widget` list from raw `im.vector.modular.widgets` state events.
|
||||
*
|
||||
* NOTE: we do NOT use `WidgetParser.parseWidgetsFromRoomState` here. In
|
||||
* matrix-widget-api 1.17.0 its `isValidUrl` compares `URL.protocol` (which is
|
||||
* always colon-suffixed, e.g. "https:") against the bare strings "http"/
|
||||
* "https", so it rejects every URL and the parser always returns []. We build
|
||||
* the `Widget`s ourselves with a correct scheme check plus the existing
|
||||
* `isWidgetUrlSafe` origin check.
|
||||
*/
|
||||
export const widgetsFromStateEvents = (
|
||||
widgetEvents: StateKeyToEvents | undefined,
|
||||
appOrigin: string,
|
||||
): Widget[] => {
|
||||
if (!widgetEvents) return [];
|
||||
const widgets: Widget[] = [];
|
||||
Array.from(widgetEvents.values()).forEach((event: MatrixEvent) => {
|
||||
const content = event.getContent();
|
||||
// Removed widgets are represented as an empty content state event.
|
||||
if (!content || Object.keys(content).length === 0) return;
|
||||
|
||||
const id = event.getStateKey();
|
||||
const { type, url, name, data, waitForIframeLoad } = content;
|
||||
const creatorUserId = content.creatorUserId || event.getSender();
|
||||
if (!id || !type || !url || !creatorUserId) return;
|
||||
|
||||
let scheme: string;
|
||||
try {
|
||||
scheme = new URL(url).protocol;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (scheme !== 'https:') return;
|
||||
if (!isWidgetUrlSafe(url, appOrigin)) return;
|
||||
|
||||
widgets.push(
|
||||
new Widget({
|
||||
id,
|
||||
creatorUserId,
|
||||
type,
|
||||
url,
|
||||
name,
|
||||
data,
|
||||
waitForIframeLoad,
|
||||
}),
|
||||
);
|
||||
});
|
||||
return widgets;
|
||||
};
|
||||
|
||||
/**
|
||||
* All valid `im.vector.modular.widgets` room widgets, reactive on room state.
|
||||
* `WidgetParser` drops empty/removed (`{}`) and malformed entries.
|
||||
*/
|
||||
export const useRoomWidgets = (room: Room): Widget[] => {
|
||||
const state = useRoomState(room);
|
||||
return useMemo(() => {
|
||||
const widgetEvents = state.get(StateEvent.Widget);
|
||||
if (!widgetEvents) return [];
|
||||
const stateEvents = Array.from(widgetEvents.values()).map(
|
||||
(event) => event.getEffectiveEvent() as unknown as IStateEvent,
|
||||
);
|
||||
return WidgetParser.parseWidgetsFromRoomState(stateEvents);
|
||||
}, [state]);
|
||||
return useMemo(
|
||||
() => widgetsFromStateEvents(state.get(StateEvent.Widget), window.location.origin),
|
||||
[state],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -751,22 +751,32 @@ function ProfilePronouns() {
|
||||
|
||||
const [pronouns, setPronouns] = useState<string>('');
|
||||
const [savedPronouns, setSavedPronouns] = useState<string>('');
|
||||
// True once the user has edited the field — guards against the mount-time
|
||||
// fetch below clobbering a fresh edit if it resolves late (mirrors
|
||||
// ProfileStatus's statusDirtyRef in this file).
|
||||
const pronounsDirtyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
mx.http
|
||||
.authedRequest<{ 'm.pronouns': string }>(
|
||||
Method.Get,
|
||||
`/profile/${encodeURIComponent(userId)}/m.pronouns`,
|
||||
)
|
||||
.then((res) => {
|
||||
if (cancelled || pronounsDirtyRef.current) return;
|
||||
const val = res['m.pronouns'] ?? '';
|
||||
setPronouns(val);
|
||||
setSavedPronouns(val);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled || pronounsDirtyRef.current) return;
|
||||
setPronouns('');
|
||||
setSavedPronouns('');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mx, userId]);
|
||||
|
||||
const [saveState, savePronouns] = useAsyncCallback(
|
||||
@@ -788,10 +798,12 @@ function ProfilePronouns() {
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
pronounsDirtyRef.current = true;
|
||||
setPronouns(evt.currentTarget.value);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
pronounsDirtyRef.current = true;
|
||||
setPronouns(savedPronouns);
|
||||
};
|
||||
|
||||
@@ -875,10 +887,15 @@ function ProfileTimezone() {
|
||||
|
||||
const [timezone, setTimezone] = useState<string>('');
|
||||
const [savedTimezone, setSavedTimezone] = useState<string>('');
|
||||
// True once the user has edited the field — guards against the mount-time
|
||||
// fetch below clobbering a fresh edit if it resolves late (mirrors
|
||||
// ProfileStatus's statusDirtyRef in this file).
|
||||
const timezoneDirtyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const cached = getAccountData<{ timezone: string }>(mx, 'im.lotus.timezone');
|
||||
if (cached?.timezone) {
|
||||
if (cached?.timezone && !timezoneDirtyRef.current) {
|
||||
setTimezone(cached.timezone);
|
||||
setSavedTimezone(cached.timezone);
|
||||
}
|
||||
@@ -889,6 +906,7 @@ function ProfileTimezone() {
|
||||
`/user/${encodeURIComponent(userId)}/account_data/im.lotus.timezone`,
|
||||
)
|
||||
.then((res) => {
|
||||
if (cancelled || timezoneDirtyRef.current) return;
|
||||
const val = res.timezone ?? '';
|
||||
setTimezone(val);
|
||||
setSavedTimezone(val);
|
||||
@@ -896,6 +914,9 @@ function ProfileTimezone() {
|
||||
.catch(() => {
|
||||
/* no stored timezone yet */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mx, userId]);
|
||||
|
||||
const [saveState, saveTimezone] = useAsyncCallback(
|
||||
@@ -921,7 +942,13 @@ function ProfileTimezone() {
|
||||
);
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
timezoneDirtyRef.current = true;
|
||||
setTimezone(value);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
timezoneDirtyRef.current = true;
|
||||
setTimezone(savedTimezone);
|
||||
};
|
||||
|
||||
@@ -955,7 +982,7 @@ function ProfileTimezone() {
|
||||
{ value: '', label: '— select timezone —' },
|
||||
...COMMON_TIMEZONES.map((tz) => ({ value: tz, label: tz })),
|
||||
]}
|
||||
onChange={setTimezone}
|
||||
onChange={handleChange}
|
||||
disabled={saving}
|
||||
aria-label="Timezone"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Button, Text, Spinner, color } from 'folds';
|
||||
import { Method } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
@@ -67,24 +67,47 @@ export function ProfileDecoration() {
|
||||
|
||||
const [current, setCurrent] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
// Distinguish "confirmed no decoration" from "failed to load": a fetch
|
||||
// failure must not be shown as (and saved over) "None".
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// True once the user has picked/cleared a decoration — guards against the
|
||||
// mount-time fetch below clobbering a fresh selection if it resolves late
|
||||
// (mirrors ProfileStatus's statusDirtyRef in Profile.tsx).
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDecoration = useCallback(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
// Fetch the whole profile, not the `/{field}` sub-resource: an unset field
|
||||
// 404s (a console error for anyone without a decoration). The full profile
|
||||
// returns 200 with all fields incl. custom MSC4133 ones — read it out.
|
||||
mx.http
|
||||
.authedRequest<Record<string, string>>(Method.Get, `/profile/${encodeURIComponent(userId)}`)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setLoadError(false);
|
||||
setLoading(false);
|
||||
if (dirtyRef.current) return;
|
||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||
setCurrent(val);
|
||||
setSelected(val);
|
||||
})
|
||||
.catch(() => {
|
||||
setCurrent(null);
|
||||
setSelected(null);
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
// Do NOT touch current/selected here — a network failure is not proof
|
||||
// there's no decoration, and defaulting to null risks the user saving
|
||||
// "None" over a real, still-set decoration (see #46).
|
||||
setLoadError(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mx, userId]);
|
||||
|
||||
useEffect(() => fetchDecoration(), [fetchDecoration]);
|
||||
|
||||
const [saveState, save] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (slug: string | null) => {
|
||||
@@ -105,16 +128,27 @@ export function ProfileDecoration() {
|
||||
const hasChanges = selected !== current;
|
||||
|
||||
const handleSelect = (slug: string) => {
|
||||
dirtyRef.current = true;
|
||||
setSelected((prev) => (prev === slug ? null : slug));
|
||||
};
|
||||
|
||||
const handleClear = () => setSelected(null);
|
||||
const handleClear = () => {
|
||||
dirtyRef.current = true;
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!hasChanges || saving) return;
|
||||
// Refuse to save while the initial load failed: `current`/`selected` are
|
||||
// not known-good, so saving could silently overwrite a real decoration.
|
||||
if (!hasChanges || saving || loadError) return;
|
||||
save(selected);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
dirtyRef.current = false;
|
||||
fetchDecoration();
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title={
|
||||
@@ -159,13 +193,15 @@ export function ProfileDecoration() {
|
||||
</div>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="T300">
|
||||
{selected
|
||||
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
|
||||
(d) => d.slug === selected,
|
||||
)?.name ?? selected)
|
||||
: 'None'}
|
||||
{loadError
|
||||
? 'Failed to load'
|
||||
: selected
|
||||
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
|
||||
(d) => d.slug === selected,
|
||||
)?.name ?? selected)
|
||||
: 'None'}
|
||||
</Text>
|
||||
{selected && (
|
||||
{selected && !loadError && (
|
||||
<Button
|
||||
type="button"
|
||||
size="300"
|
||||
@@ -178,7 +214,7 @@ export function ProfileDecoration() {
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{hasChanges && (
|
||||
{hasChanges && !loadError && (
|
||||
<Button
|
||||
type="button"
|
||||
size="400"
|
||||
@@ -194,6 +230,26 @@ export function ProfileDecoration() {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{loadError && (
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
Could not load your current decoration. Saving is disabled until this succeeds, so you
|
||||
don’t overwrite it based on a wrong display.
|
||||
</Text>
|
||||
<Button
|
||||
type="button"
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
onClick={handleRetry}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text size="B300">{loading ? 'Retrying…' : 'Retry'}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{saveState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
Failed to save. Try again.
|
||||
|
||||
@@ -11,12 +11,19 @@ import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { settingsAtom, Settings } from '../../../state/settings';
|
||||
import { notificationSnoozeUntilAtom } from '../../../state/notificationSnooze';
|
||||
|
||||
const PRESETS: Array<{
|
||||
label: string;
|
||||
icon: IconSrc;
|
||||
description: string;
|
||||
patch: Partial<Settings>;
|
||||
// Whether applying this preset should also clear an active "Pause
|
||||
// Notifications" snooze. Work/Gaming both claim notifications end up on,
|
||||
// so a leftover snooze would silently contradict them (#48). Sleep's
|
||||
// description ("All notifications off") is still true with a snooze left
|
||||
// active, so it does not need to touch it.
|
||||
clearSnooze: boolean;
|
||||
}> = [
|
||||
{
|
||||
label: 'Gaming',
|
||||
@@ -29,6 +36,7 @@ const PRESETS: Array<{
|
||||
inviteSoundId: 'none',
|
||||
quietHoursEnabled: false,
|
||||
},
|
||||
clearSnooze: true,
|
||||
},
|
||||
{
|
||||
label: 'Work',
|
||||
@@ -41,6 +49,7 @@ const PRESETS: Array<{
|
||||
inviteSoundId: 'invite',
|
||||
quietHoursEnabled: false,
|
||||
},
|
||||
clearSnooze: true,
|
||||
},
|
||||
{
|
||||
label: 'Sleep',
|
||||
@@ -51,15 +60,23 @@ const PRESETS: Array<{
|
||||
isNotificationSounds: false,
|
||||
quietHoursEnabled: false,
|
||||
},
|
||||
clearSnooze: false,
|
||||
},
|
||||
];
|
||||
|
||||
function NotificationPresets() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const setSettings = useSetAtom(settingsAtom);
|
||||
const setSnoozeUntil = useSetAtom(notificationSnoozeUntilAtom);
|
||||
|
||||
const applyPreset = (patch: Partial<Settings>) => {
|
||||
const applyPreset = (patch: Partial<Settings>, clearSnooze: boolean) => {
|
||||
setSettings({ ...settings, ...patch });
|
||||
// Work/Gaming promise notifications are on; an active snooze from an
|
||||
// earlier "Pause Notifications" would otherwise keep them silently
|
||||
// suppressed despite the preset applying successfully (#48).
|
||||
if (clearSnooze) {
|
||||
setSnoozeUntil(0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -71,7 +88,7 @@ function NotificationPresets() {
|
||||
<Button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => applyPreset(preset.patch)}
|
||||
onClick={() => applyPreset(preset.patch, preset.clearSnooze)}
|
||||
title={preset.description}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
Bookmark,
|
||||
cleanupEncryptedBookmarks,
|
||||
hasBookmarkText,
|
||||
stripBookmarkText,
|
||||
toStorableBookmark,
|
||||
} from './useBookmarks';
|
||||
import { cleanupEncryptedReminders, Reminder, toStorableReminder } from './useReminders';
|
||||
|
||||
// E2EE policy for bookmarks/reminders (Gitea #10): account data is stored
|
||||
// unencrypted on the homeserver, so entries for encrypted rooms must carry no
|
||||
// message text or names. `isEncryptedRoom` is injected to keep these pure.
|
||||
const isEncrypted = (roomId: string) => roomId === '!enc';
|
||||
|
||||
const full = (roomId: string): Bookmark => ({
|
||||
roomId,
|
||||
eventId: `$ev-${roomId}`,
|
||||
savedAt: 100,
|
||||
previewText: 'secret text',
|
||||
roomName: 'Room',
|
||||
senderName: 'Alice',
|
||||
});
|
||||
|
||||
test('stripBookmarkText keeps only roomId/eventId/savedAt', () => {
|
||||
const stripped = stripBookmarkText(full('!enc'));
|
||||
assert.deepEqual(stripped, { roomId: '!enc', eventId: '$ev-!enc', savedAt: 100, roomName: '' });
|
||||
assert.equal(hasBookmarkText(stripped), false);
|
||||
assert.equal(hasBookmarkText(full('!enc')), true);
|
||||
});
|
||||
|
||||
test('toStorableBookmark strips text for encrypted rooms only', () => {
|
||||
const plain = full('!plain');
|
||||
assert.equal(toStorableBookmark(plain, isEncrypted), plain);
|
||||
const enc = toStorableBookmark(full('!enc'), isEncrypted);
|
||||
assert.equal(enc.previewText, undefined);
|
||||
assert.equal(enc.senderName, undefined);
|
||||
assert.equal(enc.roomName, '');
|
||||
});
|
||||
|
||||
test('cleanupEncryptedBookmarks returns undefined when nothing to strip', () => {
|
||||
const already = stripBookmarkText(full('!enc'));
|
||||
assert.equal(cleanupEncryptedBookmarks([full('!plain'), already], isEncrypted), undefined);
|
||||
assert.equal(cleanupEncryptedBookmarks([], isEncrypted), undefined);
|
||||
});
|
||||
|
||||
test('cleanupEncryptedBookmarks strips legacy encrypted entries and keeps the rest', () => {
|
||||
const plain = full('!plain');
|
||||
const out = cleanupEncryptedBookmarks([plain, full('!enc')], isEncrypted);
|
||||
assert.ok(out);
|
||||
assert.equal(out[0], plain);
|
||||
assert.deepEqual(out[1], stripBookmarkText(full('!enc')));
|
||||
// Running it again on the result is a no-op (no write loop).
|
||||
assert.equal(cleanupEncryptedBookmarks(out, isEncrypted), undefined);
|
||||
});
|
||||
|
||||
const reminder = (roomId: string): Reminder => ({
|
||||
roomId,
|
||||
eventId: `$ev-${roomId}`,
|
||||
timestamp: 200,
|
||||
message: 'secret text',
|
||||
});
|
||||
|
||||
test('toStorableReminder drops message for encrypted rooms only', () => {
|
||||
const plain = reminder('!plain');
|
||||
assert.equal(toStorableReminder(plain, isEncrypted), plain);
|
||||
assert.deepEqual(toStorableReminder(reminder('!enc'), isEncrypted), {
|
||||
roomId: '!enc',
|
||||
eventId: '$ev-!enc',
|
||||
timestamp: 200,
|
||||
});
|
||||
});
|
||||
|
||||
test('cleanupEncryptedReminders strips legacy entries once', () => {
|
||||
const out = cleanupEncryptedReminders([reminder('!plain'), reminder('!enc')], isEncrypted);
|
||||
assert.ok(out);
|
||||
assert.equal(out[0].message, 'secret text');
|
||||
assert.equal(out[1].message, undefined);
|
||||
assert.equal(cleanupEncryptedReminders(out, isEncrypted), undefined);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { createAccountDataListStore } from './createAccountDataListStore';
|
||||
|
||||
@@ -6,7 +7,14 @@ export type Bookmark = {
|
||||
roomId: string;
|
||||
eventId: string;
|
||||
savedAt: number;
|
||||
previewText: string;
|
||||
// Snapshot fields. Account data is stored UNENCRYPTED on the homeserver, so
|
||||
// for E2EE rooms none of these are persisted (see stripBookmarkText) — the
|
||||
// panel resolves the preview / room / author live from the local timeline
|
||||
// instead. They remain for unencrypted rooms (already server-visible) and for
|
||||
// entries saved before this policy, so older stored bookmarks still render.
|
||||
previewText?: string;
|
||||
// Kept as a (possibly empty) string in memory because the grouping helpers in
|
||||
// utils/bookmarks read it directly; the store omits it from the wire when empty.
|
||||
roomName: string;
|
||||
// Author display name, snapshotted at save time. Optional for backward
|
||||
// compatibility with bookmarks stored before attribution was added; the panel
|
||||
@@ -16,22 +24,83 @@ export type Bookmark = {
|
||||
senderName?: string;
|
||||
};
|
||||
|
||||
// Wire shape: identical to Bookmark except that `roomName` may be absent.
|
||||
type StoredBookmark = Omit<Bookmark, 'roomName'> & { roomName?: string };
|
||||
|
||||
const BOOKMARKS_KEY = 'io.lotus.bookmarks';
|
||||
const MAX_BOOKMARKS = 500;
|
||||
|
||||
type BookmarksContent = {
|
||||
bookmarks: Bookmark[];
|
||||
bookmarks: StoredBookmark[];
|
||||
};
|
||||
|
||||
/** True when the entry carries any of the text fields we refuse to upload for E2EE rooms. */
|
||||
export const hasBookmarkText = (b: Bookmark): boolean =>
|
||||
b.previewText !== undefined || b.roomName !== '' || b.senderName !== undefined;
|
||||
|
||||
/**
|
||||
* Reduce a bookmark to its non-text fields ({roomId, eventId, savedAt}). Used for
|
||||
* messages in encrypted rooms so no decrypted plaintext (or who said it) ever
|
||||
* reaches the server's account-data store.
|
||||
*/
|
||||
export const stripBookmarkText = (b: Bookmark): Bookmark => ({
|
||||
roomId: b.roomId,
|
||||
eventId: b.eventId,
|
||||
savedAt: b.savedAt,
|
||||
roomName: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply the E2EE storage policy to a bookmark about to be persisted.
|
||||
* `isEncryptedRoom` is injected so the policy is testable without a client.
|
||||
*/
|
||||
export const toStorableBookmark = (
|
||||
b: Bookmark,
|
||||
isEncryptedRoom: (roomId: string) => boolean,
|
||||
): Bookmark => (isEncryptedRoom(b.roomId) ? stripBookmarkText(b) : b);
|
||||
|
||||
/**
|
||||
* One-time cleanup for entries persisted before the E2EE policy: returns the list
|
||||
* with text fields stripped for every bookmark whose room is currently encrypted,
|
||||
* or `undefined` when nothing needs to change (so callers can skip the write).
|
||||
*/
|
||||
export const cleanupEncryptedBookmarks = (
|
||||
bookmarks: Bookmark[],
|
||||
isEncryptedRoom: (roomId: string) => boolean,
|
||||
): Bookmark[] | undefined => {
|
||||
let changed = false;
|
||||
const next = bookmarks.map((b) => {
|
||||
if (hasBookmarkText(b) && isEncryptedRoom(b.roomId)) {
|
||||
changed = true;
|
||||
return stripBookmarkText(b);
|
||||
}
|
||||
return b;
|
||||
});
|
||||
return changed ? next : undefined;
|
||||
};
|
||||
|
||||
const isEncryptedRoomFor =
|
||||
(mx: MatrixClient) =>
|
||||
(roomId: string): boolean =>
|
||||
mx.getRoom(roomId)?.hasEncryptionStateEvent() ?? false;
|
||||
|
||||
// Shared, concurrency-safe store. See createAccountDataListStore for why the
|
||||
// snapshot + write queue must be module-scoped (writes are serialized to avoid
|
||||
// lost updates, since setAccountData replaces the whole content with no merge).
|
||||
const bookmarksStore = createAccountDataListStore<Bookmark[], BookmarksContent>({
|
||||
eventType: BOOKMARKS_KEY,
|
||||
read: (content) => content?.bookmarks ?? [],
|
||||
write: (bookmarks) => ({ bookmarks }),
|
||||
read: (content) => (content?.bookmarks ?? []).map((b) => ({ ...b, roomName: b.roomName ?? '' })),
|
||||
write: (bookmarks) => ({
|
||||
// Drop the in-memory '' placeholder so stripped entries stay {roomId, eventId, savedAt}.
|
||||
bookmarks: bookmarks.map(({ roomName, ...rest }) => (roomName ? { ...rest, roomName } : rest)),
|
||||
}),
|
||||
});
|
||||
|
||||
// Guard so the load-time cleanup below runs once per client, not on every mount
|
||||
// (useBookmarks is mounted by every message row) and not again after its own
|
||||
// write echoes back.
|
||||
let cleanedUpFor: MatrixClient | null = null;
|
||||
|
||||
export function useBookmarks(): {
|
||||
bookmarks: Bookmark[];
|
||||
addBookmark: (b: Bookmark) => Promise<void>;
|
||||
@@ -41,12 +110,33 @@ export function useBookmarks(): {
|
||||
const mx = useMatrixClient();
|
||||
const bookmarks = bookmarksStore.useValue(mx);
|
||||
|
||||
// Strip text from entries saved (by older builds) for rooms that are encrypted,
|
||||
// writing the store back at most once per client.
|
||||
useEffect(() => {
|
||||
if (cleanedUpFor === mx) return;
|
||||
cleanedUpFor = mx;
|
||||
const cleaned = cleanupEncryptedBookmarks(bookmarksStore.getLatest(mx), isEncryptedRoomFor(mx));
|
||||
if (cleaned) {
|
||||
bookmarksStore
|
||||
.enqueueWrite(
|
||||
mx,
|
||||
(current) => cleanupEncryptedBookmarks(current, isEncryptedRoomFor(mx)) ?? current,
|
||||
)
|
||||
.catch(() => {
|
||||
// Retry on the next client (re)mount.
|
||||
if (cleanedUpFor === mx) cleanedUpFor = null;
|
||||
});
|
||||
}
|
||||
}, [mx]);
|
||||
|
||||
const addBookmark = useCallback(
|
||||
(b: Bookmark) =>
|
||||
bookmarksStore.enqueueWrite(mx, (current) => {
|
||||
// Never upload plaintext for E2EE rooms — account data is server-readable.
|
||||
const stored = toStorableBookmark(b, isEncryptedRoomFor(mx));
|
||||
// Avoid duplicates
|
||||
const filtered = current.filter((bk) => bk.eventId !== b.eventId);
|
||||
let next = [b, ...filtered];
|
||||
const filtered = current.filter((bk) => bk.eventId !== stored.eventId);
|
||||
let next = [stored, ...filtered];
|
||||
if (next.length > MAX_BOOKMARKS) {
|
||||
next = next.slice(0, MAX_BOOKMARKS);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isDeafenKeyDown, isPttKeyDown } from './useCallHotkeys';
|
||||
|
||||
const key = (
|
||||
code: string,
|
||||
mods: Partial<Record<'repeat' | 'ctrlKey' | 'altKey' | 'metaKey' | 'shiftKey', boolean>> = {},
|
||||
) => ({
|
||||
code,
|
||||
repeat: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...mods,
|
||||
});
|
||||
|
||||
test('isPttKeyDown: matches the bare key and Shift, ignores repeats and Ctrl/Alt/Meta chords (#23)', () => {
|
||||
assert.equal(isPttKeyDown(key('Space'), 'Space'), true);
|
||||
assert.equal(isPttKeyDown(key('Space', { shiftKey: true }), 'Space'), true);
|
||||
assert.equal(isPttKeyDown(key('KeyV'), 'Space'), false);
|
||||
assert.equal(isPttKeyDown(key('Space', { repeat: true }), 'Space'), false);
|
||||
assert.equal(isPttKeyDown(key('Space', { ctrlKey: true }), 'Space'), false);
|
||||
assert.equal(isPttKeyDown(key('Space', { altKey: true }), 'Space'), false);
|
||||
assert.equal(isPttKeyDown(key('Space', { metaKey: true }), 'Space'), false);
|
||||
});
|
||||
|
||||
test('isDeafenKeyDown: matches only the bare key — any modifier (incl. Shift) is ignored (#23)', () => {
|
||||
assert.equal(isDeafenKeyDown(key('KeyM'), 'KeyM'), true);
|
||||
assert.equal(isDeafenKeyDown(key('KeyN'), 'KeyM'), false);
|
||||
assert.equal(isDeafenKeyDown(key('KeyM', { repeat: true }), 'KeyM'), false);
|
||||
assert.equal(isDeafenKeyDown(key('KeyM', { shiftKey: true }), 'KeyM'), false);
|
||||
assert.equal(isDeafenKeyDown(key('KeyM', { ctrlKey: true }), 'KeyM'), false);
|
||||
assert.equal(isDeafenKeyDown(key('KeyM', { metaKey: true }), 'KeyM'), false);
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { atom, useSetAtom } from 'jotai';
|
||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
|
||||
/**
|
||||
* True while a push-to-talk key is held. Written by useCallHotkeys (mounted for
|
||||
* the lifetime of the embed) and read by the PTT chip in CallControls, which
|
||||
* only renders while the call room is selected.
|
||||
*/
|
||||
export const pttActiveAtom = atom(false);
|
||||
|
||||
type KeyLike = {
|
||||
code: string;
|
||||
repeat: boolean;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a keydown should engage push-to-talk for `pttKey`.
|
||||
* [Gitea #23] Ignore the PTT key with Ctrl/Alt/Meta held so it doesn't hijack
|
||||
* OS/app chords (e.g. Cmd+Space) that happen to share the code. Shift is
|
||||
* allowed through — Shift+Space is a harmless combo for PTT.
|
||||
*/
|
||||
export const isPttKeyDown = (e: KeyLike, pttKey: string): boolean =>
|
||||
e.code === pttKey && !e.repeat && !e.ctrlKey && !e.altKey && !e.metaKey;
|
||||
|
||||
/**
|
||||
* Whether a keydown should toggle deafen for `deafenKey`.
|
||||
* [Gitea #23] Ignore the deafen key with any modifier held — with the default
|
||||
* 'KeyM', Ctrl+M / Alt+M / Cmd+M are common OS/app chords that shouldn't also
|
||||
* toggle deafen (and previously got preventDefault()ed).
|
||||
*/
|
||||
export const isDeafenKeyDown = (e: KeyLike, deafenKey: string): boolean =>
|
||||
e.code === deafenKey && !e.repeat && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey;
|
||||
|
||||
// BUG-7: use ownerDocument.body so isEditable works inside the EC iframe
|
||||
const isEditable = (el: HTMLElement): boolean => {
|
||||
const tag = el.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== el.ownerDocument.body) {
|
||||
if (node.contentEditable === 'true') return true;
|
||||
if (node.contentEditable === 'false') return false;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// [Gitea #23] Don't swallow Space on a focused button/link/etc — PTT still
|
||||
// engages the mic, but the key's default action (activating the control)
|
||||
// is left alone so keyboard users can still Tab+Space the call buttons.
|
||||
const isInteractive = (el: HTMLElement): boolean => {
|
||||
const tag = el.tagName;
|
||||
if (tag === 'BUTTON' || tag === 'A' || tag === 'SELECT') return true;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== el.ownerDocument.body) {
|
||||
const role = node.getAttribute('role');
|
||||
if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') {
|
||||
return true;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bind listeners to the EC iframe's window and keep them bound across document
|
||||
* reloads. [Gitea #60] Listeners registered on a contentWindow are discarded
|
||||
* when that window navigates to a new document (crash recovery, in-widget
|
||||
* navigation), so re-run `bind` from the iframe's `load` event and detach the
|
||||
* previous set. Returns a cleanup that detaches everything.
|
||||
*/
|
||||
const bindIframeWindow = (
|
||||
iframe: HTMLIFrameElement,
|
||||
bind: (win: Window) => () => void,
|
||||
): (() => void) => {
|
||||
let unbind: (() => void) | undefined;
|
||||
const attach = () => {
|
||||
unbind?.();
|
||||
const win = iframe.contentWindow;
|
||||
unbind = win ? bind(win) : undefined;
|
||||
};
|
||||
attach();
|
||||
iframe.addEventListener('load', attach);
|
||||
return () => {
|
||||
iframe.removeEventListener('load', attach);
|
||||
unbind?.();
|
||||
unbind = undefined;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Push-to-talk and deafen hotkeys for the active call.
|
||||
*
|
||||
* [Gitea #9] Mounted from CallUtils (CallEmbedProvider) so the bindings live as
|
||||
* long as the embed — not only while the call room is the selected room. Before
|
||||
* this lived in CallControls, so navigating away (PiP) or opening the in-call
|
||||
* chat on mobile silently dropped PTT and the deafen key. Gated on `joined` so
|
||||
* nothing is sent over the widget transport before EC's handler mounts.
|
||||
*/
|
||||
export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean): void {
|
||||
const embed = joined ? callEmbed : undefined;
|
||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||
const { microphone } = useCallControlState(embed?.control);
|
||||
const setPttActive = useSetAtom(pttActiveAtom);
|
||||
|
||||
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||||
const microphoneRef = useRef(microphone);
|
||||
useEffect(() => {
|
||||
microphoneRef.current = microphone;
|
||||
}, [microphone]);
|
||||
|
||||
// Handle PTT mode toggle mid-call — save/restore mic state (I-4)
|
||||
const pttModeRef = useRef(pttMode);
|
||||
const micBeforePTTRef = useRef<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
if (embed) {
|
||||
if (pttMode && !pttModeRef.current) {
|
||||
micBeforePTTRef.current = microphoneRef.current;
|
||||
embed.control.setMicrophone(false);
|
||||
} else if (!pttMode && pttModeRef.current) {
|
||||
embed.control.setMicrophone(micBeforePTTRef.current ?? true);
|
||||
micBeforePTTRef.current = null;
|
||||
}
|
||||
}
|
||||
pttModeRef.current = pttMode;
|
||||
}, [pttMode, embed]);
|
||||
|
||||
const pttActiveRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!embed || !pttMode) return undefined;
|
||||
|
||||
const release = () => {
|
||||
embed.control.pttActive = false;
|
||||
embed.control.setMicrophone(false);
|
||||
pttActiveRef.current = false;
|
||||
setPttActive(false);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isPttKeyDown(e, pttKey)) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (isEditable(target)) return;
|
||||
if (!isInteractive(target)) 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.
|
||||
embed.control.pttActive = true;
|
||||
if (!microphoneRef.current) embed.control.setMicrophone(true);
|
||||
pttActiveRef.current = true;
|
||||
setPttActive(true);
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code !== pttKey) return;
|
||||
release();
|
||||
};
|
||||
// BUG-9: release a held key when focus leaves/enters so the mic can't stick
|
||||
// open after the keyup lands elsewhere. [Gitea #27] Only when a PTT hold is
|
||||
// actually in progress — the mic button is still usable in PTT mode, and a
|
||||
// deliberate hands-free unmute must survive a click into the iframe/alt-tab.
|
||||
const onFocusChange = () => {
|
||||
if (pttActiveRef.current) release();
|
||||
};
|
||||
const bind = (win: Window) => {
|
||||
win.addEventListener('keydown', onKeyDown);
|
||||
win.addEventListener('keyup', onKeyUp);
|
||||
win.addEventListener('blur', onFocusChange);
|
||||
win.addEventListener('focus', onFocusChange);
|
||||
return () => {
|
||||
win.removeEventListener('keydown', onKeyDown);
|
||||
win.removeEventListener('keyup', onKeyUp);
|
||||
win.removeEventListener('blur', onFocusChange);
|
||||
win.removeEventListener('focus', onFocusChange);
|
||||
};
|
||||
};
|
||||
const unbindHost = bind(window);
|
||||
// BUG-9: also wire iframe key/blur/focus so PTT works with focus in the iframe
|
||||
const unbindIframe = bindIframeWindow(embed.iframe, bind);
|
||||
return () => {
|
||||
unbindHost();
|
||||
unbindIframe();
|
||||
// BUG-8: if the embed changes while PTT is active, release mic on cleanup
|
||||
if (pttActiveRef.current) release();
|
||||
};
|
||||
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
|
||||
}, [pttMode, pttKey, embed, setPttActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!embed) return undefined;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isDeafenKeyDown(e, deafenKey)) return;
|
||||
if (isEditable(e.target as HTMLElement)) return;
|
||||
e.preventDefault();
|
||||
embed.control.toggleSound();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
// 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 unbindIframe = bindIframeWindow(embed.iframe, (win) => {
|
||||
win.addEventListener('keydown', onKeyDown);
|
||||
return () => win.removeEventListener('keydown', onKeyDown);
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
unbindIframe();
|
||||
};
|
||||
}, [embed, deafenKey]);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { createAccountDataListStore } from './createAccountDataListStore';
|
||||
|
||||
@@ -6,7 +7,10 @@ export type Reminder = {
|
||||
roomId: string;
|
||||
eventId: string;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
// Message preview. Account data is stored UNENCRYPTED on the homeserver, so
|
||||
// this is omitted for E2EE rooms (see stripReminderText) and the toast resolves
|
||||
// the text locally at fire time. Optional for entries stored before that policy.
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const REMINDERS_KEY = 'io.lotus.reminders';
|
||||
@@ -15,6 +19,43 @@ type RemindersContent = {
|
||||
reminders: Reminder[];
|
||||
};
|
||||
|
||||
/** Reduce a reminder to its non-text fields ({roomId, eventId, timestamp}). */
|
||||
export const stripReminderText = (r: Reminder): Reminder => ({
|
||||
roomId: r.roomId,
|
||||
eventId: r.eventId,
|
||||
timestamp: r.timestamp,
|
||||
});
|
||||
|
||||
/** Apply the E2EE storage policy to a reminder about to be persisted. */
|
||||
export const toStorableReminder = (
|
||||
r: Reminder,
|
||||
isEncryptedRoom: (roomId: string) => boolean,
|
||||
): Reminder => (isEncryptedRoom(r.roomId) ? stripReminderText(r) : r);
|
||||
|
||||
/**
|
||||
* One-time cleanup for entries persisted before the E2EE policy; `undefined`
|
||||
* when nothing needs to change (so callers can skip the write).
|
||||
*/
|
||||
export const cleanupEncryptedReminders = (
|
||||
reminders: Reminder[],
|
||||
isEncryptedRoom: (roomId: string) => boolean,
|
||||
): Reminder[] | undefined => {
|
||||
let changed = false;
|
||||
const next = reminders.map((r) => {
|
||||
if (r.message !== undefined && isEncryptedRoom(r.roomId)) {
|
||||
changed = true;
|
||||
return stripReminderText(r);
|
||||
}
|
||||
return r;
|
||||
});
|
||||
return changed ? next : undefined;
|
||||
};
|
||||
|
||||
const isEncryptedRoomFor =
|
||||
(mx: MatrixClient) =>
|
||||
(roomId: string): boolean =>
|
||||
mx.getRoom(roomId)?.hasEncryptionStateEvent() ?? false;
|
||||
|
||||
// Shared, concurrency-safe store. See createAccountDataListStore for why the
|
||||
// snapshot + write queue must be module-scoped (writes are serialized to avoid
|
||||
// lost updates, since setAccountData replaces the whole content with no merge).
|
||||
@@ -24,6 +65,9 @@ const remindersStore = createAccountDataListStore<Reminder[], RemindersContent>(
|
||||
write: (reminders) => ({ reminders }),
|
||||
});
|
||||
|
||||
// Once-per-client guard for the load-time cleanup (mirrors useBookmarks).
|
||||
let cleanedUpFor: MatrixClient | null = null;
|
||||
|
||||
export function useReminders(): {
|
||||
reminders: Reminder[];
|
||||
addReminder: (r: Reminder) => Promise<void>;
|
||||
@@ -33,8 +77,30 @@ export function useReminders(): {
|
||||
const mx = useMatrixClient();
|
||||
const reminders = remindersStore.useValue(mx);
|
||||
|
||||
// Strip text from entries saved (by older builds) for rooms that are encrypted,
|
||||
// writing the store back at most once per client.
|
||||
useEffect(() => {
|
||||
if (cleanedUpFor === mx) return;
|
||||
cleanedUpFor = mx;
|
||||
if (cleanupEncryptedReminders(remindersStore.getLatest(mx), isEncryptedRoomFor(mx))) {
|
||||
remindersStore
|
||||
.enqueueWrite(
|
||||
mx,
|
||||
(current) => cleanupEncryptedReminders(current, isEncryptedRoomFor(mx)) ?? current,
|
||||
)
|
||||
.catch(() => {
|
||||
if (cleanedUpFor === mx) cleanedUpFor = null;
|
||||
});
|
||||
}
|
||||
}, [mx]);
|
||||
|
||||
const addReminder = useCallback(
|
||||
(r: Reminder) => remindersStore.enqueueWrite(mx, (current) => [...current, r]),
|
||||
(r: Reminder) =>
|
||||
remindersStore.enqueueWrite(mx, (current) => [
|
||||
...current,
|
||||
// Never upload plaintext for E2EE rooms — account data is server-readable.
|
||||
toStorableReminder(r, isEncryptedRoomFor(mx)),
|
||||
]),
|
||||
[mx],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useMemo } from 'react';
|
||||
import { useStateEvent } from './useStateEvent';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { RoomQualityContent } from '../utils/callQuality';
|
||||
|
||||
export type RoomCallPolicy = {
|
||||
allowCamera: boolean;
|
||||
allowScreenshare: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #26] Shared room-level camera/screenshare policy, read from the
|
||||
* `io.lotus.room_quality` state event. Absent/true = allowed; only an explicit
|
||||
* `false` forbids. Hoisted out of `CallControls` so other call surfaces (e.g.
|
||||
* the app-wide `CallStatus` bar) can apply the same gating.
|
||||
*/
|
||||
export const useRoomCallPolicy = (room: Room): RoomCallPolicy => {
|
||||
const roomQualityEvent = useStateEvent(room, StateEvent.LotusRoomQuality);
|
||||
|
||||
return useMemo(() => {
|
||||
const roomQuality = roomQualityEvent?.getContent<RoomQualityContent>();
|
||||
return {
|
||||
allowCamera: roomQuality?.allow_camera !== false,
|
||||
allowScreenshare: roomQuality?.allow_screenshare !== false,
|
||||
};
|
||||
}, [roomQualityEvent]);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { classifySessionChange, SessionIdentity } from './useSessionSync';
|
||||
|
||||
const alice: SessionIdentity = { userId: '@alice:hs', deviceId: 'DEV1', accessToken: 'tok-1' };
|
||||
|
||||
test('classifySessionChange: session removed elsewhere → removed', () => {
|
||||
assert.equal(classifySessionChange(alice, null), 'removed');
|
||||
});
|
||||
|
||||
test('classifySessionChange: nothing before or after → none', () => {
|
||||
assert.equal(classifySessionChange(null, null), 'none');
|
||||
});
|
||||
|
||||
test('classifySessionChange: session appeared → relogin', () => {
|
||||
assert.equal(classifySessionChange(null, alice), 'relogin');
|
||||
});
|
||||
|
||||
test('classifySessionChange: different user or device → relogin', () => {
|
||||
assert.equal(classifySessionChange(alice, { ...alice, userId: '@bob:hs' }), 'relogin');
|
||||
assert.equal(classifySessionChange(alice, { ...alice, deviceId: 'DEV2' }), 'relogin');
|
||||
// Even when the access token also changed, the identity change wins.
|
||||
assert.equal(
|
||||
classifySessionChange(alice, { ...alice, deviceId: 'DEV2', accessToken: 'tok-2' }),
|
||||
'relogin',
|
||||
);
|
||||
});
|
||||
|
||||
test('classifySessionChange: same user+device, new access token → rotated (no reload)', () => {
|
||||
assert.equal(classifySessionChange(alice, { ...alice, accessToken: 'tok-2' }), 'rotated');
|
||||
});
|
||||
|
||||
test('classifySessionChange: identical credentials (metadata-only rewrite) → none', () => {
|
||||
assert.equal(classifySessionChange(alice, { ...alice }), 'none');
|
||||
});
|
||||
@@ -1,5 +1,32 @@
|
||||
import { useEffect } from 'react';
|
||||
import { getFallbackSession, subscribeSessionChanges } from '../state/sessions';
|
||||
import type { MatrixClient } from 'matrix-js-sdk';
|
||||
import { getFallbackSession, Session, subscribeSessionChanges } from '../state/sessions';
|
||||
|
||||
/** The credential identity this tab is currently running on. */
|
||||
export type SessionIdentity = Pick<Session, 'userId' | 'deviceId' | 'accessToken'>;
|
||||
|
||||
/**
|
||||
* What an out-of-tab session change means for this tab:
|
||||
* - `none` — nothing credential-relevant changed (metadata-only rewrite,
|
||||
* or a duplicate storage event from the dual-write).
|
||||
* - `removed` — the session disappeared (logout / localStorage.clear()).
|
||||
* - `relogin` — a different user or device signed in.
|
||||
* - `rotated` — same user + device, only the access token changed (an OIDC
|
||||
* refresh performed by another tab).
|
||||
*/
|
||||
export type SessionChange = 'none' | 'removed' | 'relogin' | 'rotated';
|
||||
|
||||
/** Pure: classify a freshly-read session against the one this tab runs on. */
|
||||
export const classifySessionChange = (
|
||||
current: SessionIdentity | null,
|
||||
next: SessionIdentity | null,
|
||||
): SessionChange => {
|
||||
if (!next) return current ? 'removed' : 'none';
|
||||
if (!current) return 'relogin';
|
||||
if (next.userId !== current.userId || next.deviceId !== current.deviceId) return 'relogin';
|
||||
if (next.accessToken !== current.accessToken) return 'rotated';
|
||||
return 'none';
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep this tab in sync with session changes performed in other tabs/windows.
|
||||
@@ -11,26 +38,39 @@ import { getFallbackSession, subscribeSessionChanges } from '../state/sessions';
|
||||
* Default action is the safest one for auth-critical state — a full reload:
|
||||
* - session REMOVED elsewhere (logout / localStorage.clear()) → the access
|
||||
* token disappears, so we reload; the router bounces to auth on next boot.
|
||||
* - session APPEARED or its access token CHANGED elsewhere (a fresh login or
|
||||
* a token rotation) → we reload so the client re-initialises with the new
|
||||
* credentials rather than running on a stale/revoked token.
|
||||
* - session APPEARED or its user/device CHANGED elsewhere (a fresh login) →
|
||||
* we reload so the client re-initialises with the new credentials rather
|
||||
* than running on a stale/revoked token.
|
||||
*
|
||||
* A change that does not alter the access token (e.g. an OIDC metadata-only
|
||||
* #16 — a same-user, same-device access-token ROTATION (a routine OIDC refresh
|
||||
* in another tab) must NOT reload: that would drop in-progress calls/uploads
|
||||
* every few minutes. Instead the new tokens are swapped into the running
|
||||
* client in place. When no client is available yet we fall back to the reload.
|
||||
*
|
||||
* A change that does not alter the credentials (e.g. an OIDC metadata-only
|
||||
* rewrite) is ignored, which also collapses the several storage events emitted
|
||||
* by a single dual-write into at most one reload.
|
||||
* by a single dual-write into at most one reaction.
|
||||
*/
|
||||
export const useSessionSync = (): void => {
|
||||
export const useSessionSync = (mx?: MatrixClient): void => {
|
||||
useEffect(() => {
|
||||
// Snapshot the credential this tab booted with; compare against it so we
|
||||
// only reload on a genuine credential change.
|
||||
const initialAccessToken = getFallbackSession()?.accessToken ?? null;
|
||||
// Snapshot the credential this tab runs on; compare against it so we only
|
||||
// react to a genuine credential change. Updated on an in-place rotation.
|
||||
let current: SessionIdentity | null = getFallbackSession() ?? null;
|
||||
|
||||
const unsubscribe = subscribeSessionChanges((session) => {
|
||||
const nextAccessToken = session?.accessToken ?? null;
|
||||
if (nextAccessToken === initialAccessToken) return;
|
||||
const change = classifySessionChange(current, session);
|
||||
if (change === 'none') return;
|
||||
if (change === 'rotated' && mx && session) {
|
||||
// Same opts object backs the SDK's TokenRefresher, so updating the
|
||||
// refresh token here keeps its next refresh on the rotated token.
|
||||
mx.setAccessToken(session.accessToken);
|
||||
if (session.refreshToken) mx.http.opts.refreshToken = session.refreshToken;
|
||||
current = session;
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
}, [mx]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useMemo } from 'react';
|
||||
import { useStateEvent } from './useStateEvent';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { VoiceLimitContent } from '../features/common-settings/general/RoomVoiceLimit';
|
||||
import { useCallMembers, useCallSession } from './useCall';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
|
||||
export type VoiceChannelFull = {
|
||||
channelFull: boolean;
|
||||
current: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #30] Voice channel user limit (`io.lotus.voice_limit`), shared between
|
||||
* `CallPrescreen` and any other join path (e.g. the room-nav second-click join)
|
||||
* so they agree on when a channel is full. 0/absent `max_users` means no limit.
|
||||
*/
|
||||
export const useVoiceChannelFull = (room: Room): VoiceChannelFull => {
|
||||
const mx = useMatrixClient();
|
||||
const callSession = useCallSession(room);
|
||||
const callMembers = useCallMembers(callSession);
|
||||
|
||||
const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
|
||||
|
||||
return useMemo(() => {
|
||||
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
|
||||
// A user already counted in the session is rejoining and should not be blocked.
|
||||
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId());
|
||||
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers;
|
||||
return { channelFull, current: callMembers.length, max: maxUsers };
|
||||
}, [limitEvent, callMembers, mx]);
|
||||
};
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
registerOidcClient,
|
||||
} from 'matrix-js-sdk';
|
||||
import { getOidcCallbackUrl, getOidcClientMetadata } from './oidcConfig';
|
||||
import { cacheClientId, getCachedClientId, invalidateCachedClient } from './oidcState';
|
||||
import {
|
||||
cacheClientId,
|
||||
getCachedClientId,
|
||||
invalidateCachedClient,
|
||||
isStaleClientError,
|
||||
} from './oidcState';
|
||||
|
||||
export { completeAuthorizationCodeGrant };
|
||||
|
||||
@@ -42,8 +47,11 @@ export const startOidcLogin = async (issuer: string, homeserverBaseUrl: string):
|
||||
});
|
||||
window.location.assign(url);
|
||||
} catch (e) {
|
||||
// Drop a possibly-stale cached client so the next attempt re-registers.
|
||||
invalidateCachedClient(issuer);
|
||||
// #67 — drop the cached client so the next attempt re-registers, but ONLY
|
||||
// when the failure says the client id itself was rejected. Network and
|
||||
// discovery failures keep the cache, otherwise each retry would register
|
||||
// yet another throwaway dynamic client at the provider.
|
||||
if (isStaleClientError(e)) invalidateCachedClient(issuer);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
cacheClientId,
|
||||
invalidateCachedClient,
|
||||
parseOidcCallbackParams,
|
||||
isStaleClientError,
|
||||
} from './oidcState';
|
||||
|
||||
const installStorage = (): Map<string, string> => {
|
||||
@@ -60,3 +61,24 @@ test('parseOidcCallbackParams classifies success / error / invalid', () => {
|
||||
assert.deepEqual(parseOidcCallbackParams('?code=only'), { kind: 'invalid' });
|
||||
assert.deepEqual(parseOidcCallbackParams(''), { kind: 'invalid' });
|
||||
});
|
||||
|
||||
test('isStaleClientError: only client-rejection shapes invalidate the cache', () => {
|
||||
// OAuth error responses naming the client (oidc-client-ts ErrorResponse shape).
|
||||
assert.equal(isStaleClientError({ error: 'invalid_client' }), true);
|
||||
assert.equal(isStaleClientError({ error: 'unauthorized_client' }), true);
|
||||
// HTTP 400/401 from the registration/authorize step (MatrixError-style).
|
||||
assert.equal(isStaleClientError({ httpStatus: 400 }), true);
|
||||
assert.equal(isStaleClientError({ httpStatus: 401 }), true);
|
||||
assert.equal(isStaleClientError({ status: 401 }), true);
|
||||
assert.equal(isStaleClientError({ httpStatus: 500 }), false);
|
||||
assert.equal(isStaleClientError({ httpStatus: 400, status: 200 }), true); // httpStatus wins
|
||||
});
|
||||
|
||||
test('isStaleClientError: transient / discovery / local failures keep the cache', () => {
|
||||
assert.equal(isStaleClientError(new TypeError('Failed to fetch')), false);
|
||||
assert.equal(isStaleClientError(new Error('Something went wrong with OIDC discovery')), false);
|
||||
assert.equal(isStaleClientError({ error: 'server_error' }), false);
|
||||
assert.equal(isStaleClientError(new Error('crypto.randomUUID is not a function')), false);
|
||||
assert.equal(isStaleClientError(undefined), false);
|
||||
assert.equal(isStaleClientError('invalid_client'), false); // bare strings are not error objects
|
||||
});
|
||||
|
||||
@@ -40,6 +40,32 @@ export const invalidateCachedClient = (issuer: string): void => {
|
||||
}
|
||||
};
|
||||
|
||||
// OAuth error codes that mean the provider no longer recognises our client id
|
||||
// (deleted/expired dynamic registration, or one issued by a different deployment).
|
||||
const STALE_CLIENT_ERROR_CODES = new Set(['invalid_client', 'unauthorized_client']);
|
||||
|
||||
/**
|
||||
* #67 — pure: does a `startOidcLogin` failure indicate the CACHED client id is
|
||||
* bad? Only then is dropping the registration cache justified; a transient
|
||||
* network error, an offline discovery fetch, or a local (`crypto.randomUUID`)
|
||||
* failure must leave it alone, otherwise every retry performs a fresh dynamic
|
||||
* registration and piles throwaway clients onto the provider. Recognised
|
||||
* shapes: an OAuth error response (`{ error: 'invalid_client' }`, as thrown by
|
||||
* oidc-client-ts `ErrorResponse`), or an HTTP 400/401 carried as
|
||||
* `httpStatus`/`status` (MatrixError-style) from the registration/authorize step.
|
||||
*/
|
||||
export const isStaleClientError = (e: unknown): boolean => {
|
||||
if (!e || typeof e !== 'object') return false;
|
||||
const { error, httpStatus, status } = e as {
|
||||
error?: unknown;
|
||||
httpStatus?: unknown;
|
||||
status?: unknown;
|
||||
};
|
||||
if (typeof error === 'string' && STALE_CLIENT_ERROR_CODES.has(error)) return true;
|
||||
const code = typeof httpStatus === 'number' ? httpStatus : status;
|
||||
return code === 400 || code === 401;
|
||||
};
|
||||
|
||||
/** Parsed shape of the provider's redirect back to our callback URL. */
|
||||
export type OidcCallbackParams =
|
||||
| { kind: 'success'; code: string; state: string }
|
||||
|
||||
@@ -26,6 +26,7 @@ import { settingsAtom } from '../../state/settings';
|
||||
import { allInvitesAtom } from '../../state/room-list/inviteList';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
|
||||
import { useSearchCacheInvalidation } from '../../utils/searchCacheInvalidation';
|
||||
import {
|
||||
getDirectRoomPath,
|
||||
getHomeRoomPath,
|
||||
@@ -722,14 +723,23 @@ function ReminderMonitor() {
|
||||
if (!firedRef.current.has(key)) {
|
||||
firedRef.current.add(key);
|
||||
const room = mx.getRoom(r.roomId);
|
||||
const roomName = room?.name ?? 'Unknown Room';
|
||||
// Reminders for E2EE rooms carry no text (account data is server-
|
||||
// readable), so resolve the body from the local timeline at fire time.
|
||||
const localBody = room?.findEventById(r.eventId)?.getContent()?.body;
|
||||
const body =
|
||||
r.message ??
|
||||
(typeof localBody === 'string' && localBody
|
||||
? localBody.slice(0, 120)
|
||||
: `Reminder for a message in ${roomName}`);
|
||||
const hashPath = mDirectsRef.current.has(r.roomId)
|
||||
? getDirectRoomPath(r.roomId, r.eventId)
|
||||
: getHomeRoomPath(r.roomId, r.eventId);
|
||||
setToast({
|
||||
id: `reminder-${key}`,
|
||||
displayName: 'Reminder',
|
||||
body: r.message,
|
||||
roomName: room?.name ?? 'Unknown Room',
|
||||
body,
|
||||
roomName,
|
||||
roomId: r.roomId,
|
||||
hashPath,
|
||||
});
|
||||
@@ -896,10 +906,18 @@ function MsgDraftHydrator(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Gitea #14 — keeps the opt-in persistent search cache from outliving the
|
||||
// plaintext it indexed (redactions + leaving/being banned from a room).
|
||||
function SearchCacheInvalidationFeature(): null {
|
||||
useSearchCacheInvalidation();
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
return (
|
||||
<>
|
||||
<MsgDraftHydrator />
|
||||
<SearchCacheInvalidationFeature />
|
||||
<SystemEmojiFeature />
|
||||
<PageZoomFeature />
|
||||
<FaviconUpdater />
|
||||
|
||||
@@ -208,9 +208,11 @@ export function ClientRoot({ children }: ClientRootProps) {
|
||||
);
|
||||
|
||||
useLogoutListener(mx);
|
||||
// Cross-tab session sync: another tab logging out / in (access token changed
|
||||
// in localStorage) reloads this tab so it never runs with stale credentials.
|
||||
useSessionSync();
|
||||
// Cross-tab session sync: another tab logging out / in reloads this tab so it
|
||||
// never runs with stale credentials. A same-device token *rotation* (OIDC
|
||||
// refresh in another tab) is swapped into the running client instead of
|
||||
// reloading, which would drop an in-progress call/upload (#16).
|
||||
useSessionSync(mx);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadState.status === AsyncStatus.Idle) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
saveRoomIndex,
|
||||
clearRoom,
|
||||
clearAll,
|
||||
deleteRow,
|
||||
deleteSearchCacheDatabase,
|
||||
SearchCacheRow,
|
||||
} from './searchCache';
|
||||
@@ -128,6 +129,31 @@ test('searchCache IDB round-trip', { skip: !hasIdb }, async () => {
|
||||
await deleteSearchCacheDatabase();
|
||||
});
|
||||
|
||||
test('deleteRow: removes only the targeted [roomId, eventId] row', { skip: !hasIdb }, async () => {
|
||||
await clearAll();
|
||||
const rows: SearchCacheRow[] = [
|
||||
{ roomId: '!r1', eventId: '$1', ts: 100, sender: '@a', body: 'hello' },
|
||||
{ roomId: '!r1', eventId: '$2', ts: 200, sender: '@b', body: 'world' },
|
||||
{ roomId: '!r2', eventId: '$1', ts: 300, sender: '@a', body: 'other room, same id' },
|
||||
];
|
||||
await putRows(rows);
|
||||
|
||||
await deleteRow('!r1', '$1');
|
||||
|
||||
const r1 = await queryRoom('!r1');
|
||||
assert.deepEqual(
|
||||
r1.map((x) => x.eventId),
|
||||
['$2'],
|
||||
);
|
||||
// A same-eventId row in a different room is untouched (composite key).
|
||||
assert.equal((await queryRoom('!r2')).length, 1);
|
||||
|
||||
// Deleting a row that doesn't exist is a silent no-op.
|
||||
await assert.doesNotReject(deleteRow('!r1', '$does-not-exist'));
|
||||
|
||||
await deleteSearchCacheDatabase();
|
||||
});
|
||||
|
||||
test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb }, async () => {
|
||||
// In this environment IndexedDB is absent; every call must degrade to a
|
||||
// cache-miss rather than throwing.
|
||||
@@ -138,6 +164,7 @@ test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb },
|
||||
assert.equal(await getCoverage('!r'), null);
|
||||
await assert.doesNotReject(saveRoomIndex('!r', []));
|
||||
await assert.doesNotReject(clearRoom('!r'));
|
||||
await assert.doesNotReject(deleteRow('!r', '$1'));
|
||||
await assert.doesNotReject(clearAll());
|
||||
await assert.doesNotReject(deleteSearchCacheDatabase());
|
||||
});
|
||||
|
||||
@@ -288,6 +288,23 @@ export const mergeSearchResults = <
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a single cached row, e.g. because its event was redacted. Gitea #14:
|
||||
* without this, a redaction only ever removed the in-memory hit — the
|
||||
* decrypted plaintext stayed in IndexedDB forever.
|
||||
*/
|
||||
export const deleteRow = async (roomId: string, eventId: string): Promise<void> => {
|
||||
const db = await openDb();
|
||||
if (!db) return;
|
||||
try {
|
||||
const tx = db.transaction(MESSAGES_STORE, 'readwrite');
|
||||
tx.objectStore(MESSAGES_STORE).delete([roomId, eventId]);
|
||||
await awaitTx(tx);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
export const clearRoom = async (roomId: string): Promise<void> => {
|
||||
const db = await openDb();
|
||||
if (!db) return;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { searchCacheEnabledAtom } from '../state/searchCacheEnabled';
|
||||
import { clearRoom, deleteRow } from './searchCache';
|
||||
|
||||
/**
|
||||
* Gitea #14 — the persistent search cache (`searchCache.ts`) had no
|
||||
* invalidation path other than logout or the manual "Clear cached index"
|
||||
* button, so redacted messages and rooms the user left kept their decrypted
|
||||
* plaintext searchable on disk indefinitely.
|
||||
*
|
||||
* While the cache is enabled, listen client-wide for redactions (delete the
|
||||
* redacted row) and for leaving/being banned from a room (wipe the room's
|
||||
* cached rows via the existing `clearRoom`).
|
||||
*/
|
||||
export const useSearchCacheInvalidation = (): void => {
|
||||
const mx = useMatrixClient();
|
||||
const cacheEnabled = useAtomValue(searchCacheEnabledAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cacheEnabled) return undefined;
|
||||
|
||||
const onRedaction = (event: MatrixEvent, room: Room) => {
|
||||
const redactedEventId = event.getAssociatedId();
|
||||
if (redactedEventId) deleteRow(room.roomId, redactedEventId);
|
||||
};
|
||||
|
||||
const onMyMembership = (room: Room, membership: string) => {
|
||||
if (membership === 'leave' || membership === 'ban') {
|
||||
clearRoom(room.roomId);
|
||||
}
|
||||
};
|
||||
|
||||
mx.on(RoomEvent.Redaction, onRedaction);
|
||||
mx.on(RoomEvent.MyMembership, onMyMembership);
|
||||
return () => {
|
||||
mx.off(RoomEvent.Redaction, onRedaction);
|
||||
mx.off(RoomEvent.MyMembership, onMyMembership);
|
||||
};
|
||||
}, [mx, cacheEnabled]);
|
||||
};
|
||||
@@ -1,6 +1,23 @@
|
||||
import { OidcTokenRefresher } from 'matrix-js-sdk';
|
||||
import { AccessTokens, OidcTokenRefresher } from 'matrix-js-sdk';
|
||||
import type { IdTokenClaims } from 'oidc-client-ts';
|
||||
import { OidcSessionMeta, setFallbackSession } from '../app/state/sessions';
|
||||
import { getFallbackSession, OidcSessionMeta, setFallbackSession } from '../app/state/sessions';
|
||||
|
||||
// Web Lock name serialising OIDC refreshes across tabs (Gitea #16). Every tab
|
||||
// runs its own refresher against the SAME stored refresh token; with rotating
|
||||
// refresh tokens the second tab to hit the issuer gets `invalid_grant` and is
|
||||
// signed out. Holding the lock while refreshing (and re-reading storage once
|
||||
// inside it) makes the loser adopt the winner's tokens instead.
|
||||
const REFRESH_LOCK_NAME = 'lotus-oidc-refresh';
|
||||
|
||||
/**
|
||||
* Run `fn` under the cross-tab refresh lock. Falls back to running it directly
|
||||
* when the Web Locks API is unavailable (older browsers, non-secure contexts).
|
||||
*/
|
||||
export const withRefreshLock = <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
|
||||
if (!locks) return fn();
|
||||
return locks.request(REFRESH_LOCK_NAME, fn);
|
||||
};
|
||||
|
||||
/**
|
||||
* OidcTokenRefresher that persists rotated tokens back to the fallback session,
|
||||
@@ -30,6 +47,37 @@ export class LotusOidcTokenRefresher extends OidcTokenRefresher {
|
||||
this.oidcRef = oidc;
|
||||
}
|
||||
|
||||
// #16 — before touching the issuer, check whether another tab already rotated
|
||||
// the tokens. `refreshToken` is exactly what the SDK currently holds, so a
|
||||
// DIFFERENT stored refresh token (same user + device) means a sibling tab won
|
||||
// the race: adopt its tokens instead of burning a possibly-consumed refresh
|
||||
// token. Comparing refresh tokens (not access tokens) can never adopt the very
|
||||
// token that just 401'd, so this cannot loop. The whole thing runs under a
|
||||
// cross-tab Web Lock so concurrent refreshes serialise and the waiter sees
|
||||
// the winner's write.
|
||||
public doRefreshAccessToken(refreshToken: string): Promise<AccessTokens> {
|
||||
return withRefreshLock(async () => {
|
||||
const stored = getFallbackSession();
|
||||
if (
|
||||
stored &&
|
||||
stored.userId === this.userIdRef &&
|
||||
stored.deviceId === this.deviceIdRef &&
|
||||
stored.refreshToken &&
|
||||
stored.refreshToken !== refreshToken
|
||||
) {
|
||||
return {
|
||||
accessToken: stored.accessToken,
|
||||
refreshToken: stored.refreshToken,
|
||||
expiry:
|
||||
typeof stored.expiresInMs === 'number'
|
||||
? new Date(Date.now() + stored.expiresInMs)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return super.doRefreshAccessToken(refreshToken);
|
||||
});
|
||||
}
|
||||
|
||||
// F5 — persist the new expiry so the stored `expiresAt` stays fresh across
|
||||
// reloads instead of going stale. The SDK invokes persistTokens synchronously
|
||||
// inside the refresh and passes the freshly-refreshed `expiry` (a Date) on the
|
||||
|
||||
Reference in New Issue
Block a user