Bug fixes, security hardening, and performance improvements

- BUG-16: Fixed pagination deadlock (fetching flag stuck on error path)
- BUG-17: Fixed absoluteIndex===0 falsy check skipping unread jump
- BUG-19: Fixed mEvt.getRoomId()! non-null assertion crash
- BUG-20: Wrapped getSettings()/setSettings() in try/catch for corrupt localStorage
- SEC: Replaced randomStr() Math.random() with crypto.getRandomValues() CSPRNG
- SEC: Fixed afterLoginRedirectPath open redirect validation
- SEC: Narrowed OSM iframe sandbox to scripts-only (removed allow-same-origin)
- Perf-2: Memoized selectAtom in useSetting (prevented new atom ref per render)
- Perf-4: Fixed typingMembers setTimeout leak (tracked timers per user/room)
- Perf-8: Memoized getChatBg() result in RoomView (not inline in JSX)
- Perf-12: Replaced body.class * font-family with body.class (inherited)
- Perf-15: Memoized typingNames array chain in RoomViewTyping
- Perf-9: Added blob URL cleanup useEffect in AudioContent
- BUG: Fixed forEach(async) -> Promise.all in useCommands join handler
- BUG: Fixed useCompositionEndTracking missing dependency array
- A11y: Fixed spoiler button aria-pressed + keyboard handler
- A11y: Added aria-label to Send message button
- Build: Set sourcemap:false, removed netlify.toml from copyFiles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lotus Bot
2026-05-20 21:11:38 -04:00
parent 2b2619145c
commit a77929de8b
16 changed files with 94 additions and 67 deletions
@@ -410,7 +410,7 @@ export function MLocation({ content }: MLocationProps) {
}} }}
scrolling="no" scrolling="no"
loading="lazy" loading="lazy"
sandbox="allow-scripts allow-same-origin" sandbox="allow-scripts"
/> />
<Text size="T300" style={{ opacity: 0.65 }}> <Text size="T300" style={{ opacity: 0.65 }}>
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`} {`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
@@ -1,5 +1,5 @@
/* eslint-disable jsx-a11y/media-has-caption */ /* eslint-disable jsx-a11y/media-has-caption */
import React, { ReactNode, useCallback, useRef, useState } from 'react'; import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds'; import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { Range } from 'react-range'; import { Range } from 'react-range';
@@ -65,6 +65,19 @@ export function AudioContent({
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(
() => () => {
if (
srcState.status === AsyncStatus.Success &&
typeof srcState.data === 'string' &&
srcState.data.startsWith('blob:')
) {
URL.revokeObjectURL(srcState.data);
}
},
[srcState]
);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
// duration in seconds. (NOTE: info.duration is in milliseconds) // duration in seconds. (NOTE: info.duration is in milliseconds)
const infoDuration = info.duration ?? 0; const infoDuration = info.duration ?? 0;
+1 -1
View File
@@ -802,7 +802,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<Icon src={Icons.Pin} size="100" /> <Icon src={Icons.Pin} size="100" />
)} )}
</IconButton> </IconButton>
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300"> <IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300" aria-label="Send message">
<Icon src={Icons.Send} /> <Icon src={Icons.Send} />
</IconButton> </IconButton>
</> </>
+4 -3
View File
@@ -341,7 +341,7 @@ const useTimelinePagination = (
}) })
); );
if (err) { if (err) {
// TODO: handle pagination error. fetching = false;
return; return;
} }
const fetchedTimeline = const fetchedTimeline =
@@ -622,7 +622,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// Check if the document is in focus (user is actively viewing the app), // Check if the document is in focus (user is actively viewing the app),
// and either there are no unread messages or the latest message is from the current user. // and either there are no unread messages or the latest message is from the current user.
// If either condition is met, trigger the markAsRead function to send a read receipt. // If either condition is met, trigger the markAsRead function to send a read receipt.
requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideActivity)); const _roomId = mEvt.getRoomId();
if (_roomId) requestAnimationFrame(() => markAsRead(mx, _roomId, hideActivity));
} }
if (!document.hasFocus() && !unreadInfo) { if (!document.hasFocus() && !unreadInfo) {
@@ -826,7 +827,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const evtTimeline = getEventTimeline(room, readUptoEventId); const evtTimeline = getEventTimeline(room, readUptoEventId);
const absoluteIndex = const absoluteIndex =
evtTimeline && getEventIdAbsoluteIndex(linkedTimelines, evtTimeline, readUptoEventId); evtTimeline && getEventIdAbsoluteIndex(linkedTimelines, evtTimeline, readUptoEventId);
if (absoluteIndex) { if (typeof absoluteIndex === 'number') {
scrollToItem(absoluteIndex, { scrollToItem(absoluteIndex, {
behavior: 'instant', behavior: 'instant',
align: 'start', align: 'start',
+7 -2
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useRef } from 'react'; import React, { useCallback, useMemo, useRef } from 'react';
import { Box, Text, config } from 'folds'; import { Box, Text, config } from 'folds';
import { EventType } from 'matrix-js-sdk'; import { EventType } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
@@ -98,8 +98,13 @@ export function RoomView({ eventId }: { eventId?: string }) {
) )
); );
const chatBgStyle = useMemo(
() => getChatBg(lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground, isDark),
[chatBackground, lotusTerminal, isDark]
);
return ( return (
<Page ref={roomViewRef} style={getChatBg(lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground, isDark)}> <Page ref={roomViewRef} style={chatBgStyle}>
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<RoomTimeline <RoomTimeline
key={roomId} key={roomId}
+11 -5
View File
@@ -1,4 +1,4 @@
import React from 'react'; import React, { useMemo } from 'react';
import { Box, Icon, IconButton, Icons, Text, as } from 'folds'; import { Box, Icon, IconButton, Icons, Text, as } from 'folds';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import classNames from 'classnames'; import classNames from 'classnames';
@@ -20,12 +20,18 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
const mx = useMatrixClient(); const mx = useMatrixClient();
const typingMembers = useRoomTypingMember(room.roomId); const typingMembers = useRoomTypingMember(room.roomId);
const typingNames = typingMembers const myUserId = mx.getUserId();
.filter((receipt) => receipt.userId !== mx.getUserId()) const typingNames = useMemo(
() =>
typingMembers
.filter((receipt) => receipt.userId !== myUserId)
.map( .map(
(receipt) => getMemberDisplayName(room, receipt.userId) ?? getMxIdLocalPart(receipt.userId) (receipt) =>
getMemberDisplayName(room, receipt.userId) ?? getMxIdLocalPart(receipt.userId)
) )
.reverse(); .reverse(),
[typingMembers, myUserId, room]
);
if (typingNames.length === 0) { if (typingNames.length === 0) {
return null; return null;
+1 -3
View File
@@ -232,9 +232,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
const roomIdOrAliases = rawIds.filter( const roomIdOrAliases = rawIds.filter(
(idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias)
); );
roomIdOrAliases.forEach(async (idOrAlias) => { await Promise.all(roomIdOrAliases.map((idOrAlias) => mx.joinRoom(idOrAlias)));
await mx.joinRoom(idOrAlias);
});
}, },
}, },
[Command.Leave]: { [Command.Leave]: {
+1 -1
View File
@@ -21,7 +21,7 @@ export function useCompositionEndTracking(): void {
return () => { return () => {
window.removeEventListener('compositionend', recordCompositionEnd, { capture: true }); window.removeEventListener('compositionend', recordCompositionEnd, { capture: true });
}; };
}); }, [recordCompositionEnd]);
} }
interface IsComposingLike { interface IsComposingLike {
+3 -1
View File
@@ -117,7 +117,9 @@ export const useLoginComplete = (data?: CustomLoginResponse) => {
setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl); setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl);
const afterLoginRedirectUrl = getAfterLoginRedirectPath(); const afterLoginRedirectUrl = getAfterLoginRedirectPath();
deleteAfterLoginRedirectPath(); deleteAfterLoginRedirectPath();
navigate(afterLoginRedirectUrl ?? getHomePath(), { replace: true }); const _redir = afterLoginRedirectUrl;
const _safePath = (_redir && /^\/(?!\/)/.test(_redir)) ? _redir : getHomePath();
navigate(_safePath, { replace: true });
} }
}, [data, navigate]); }, [data, navigate]);
}; };
+4 -3
View File
@@ -461,11 +461,12 @@ export const getReactCustomHtmlParser = (
<span <span
{...props} {...props}
role="button" role="button"
tabIndex={params.handleSpoilerClick ? 0 : -1} tabIndex={0}
onKeyDown={params.handleSpoilerClick} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') params.handleSpoilerClick?.(e as any); }}
onClick={params.handleSpoilerClick} onClick={params.handleSpoilerClick}
className={css.Spoiler()} className={css.Spoiler()}
aria-pressed aria-label="Spoiler — click to reveal"
aria-pressed={false}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
{domToReact(children, opts)} {domToReact(children, opts)}
+2 -1
View File
@@ -26,7 +26,8 @@ export const useSetting = <K extends keyof Settings>(
key: K key: K
): [Settings[K], ReturnType<typeof useSetSetting<K>>] => { ): [Settings[K], ReturnType<typeof useSetSetting<K>>] => {
const selector = useMemo(() => (s: Settings) => s[key], [key]); const selector = useMemo(() => (s: Settings) => s[key], [key]);
const setting = useAtomValue(selectAtom(settingsAtom, selector)); const derivedAtom = useMemo(() => selectAtom(settingsAtom, selector), [settingsAtom, selector]);
const setting = useAtomValue(derivedAtom);
const setter = useSetSetting(settingsAtom, key); const setter = useSetSetting(settingsAtom, key);
return [setting, setter]; return [setting, setter];
+8 -6
View File
@@ -102,17 +102,19 @@ const defaultSettings: Settings = {
pttKey: 'Space', pttKey: 'Space',
}; };
export const getSettings = () => { export const getSettings = (): Settings => {
try {
const settings = localStorage.getItem(STORAGE_KEY); const settings = localStorage.getItem(STORAGE_KEY);
if (settings === null) return defaultSettings; if (settings === null) return defaultSettings;
return { return { ...defaultSettings, ...(JSON.parse(settings) as Settings) };
...defaultSettings, } catch {
...(JSON.parse(settings) as Settings), localStorage.removeItem(STORAGE_KEY);
}; return defaultSettings;
}
}; };
export const setSettings = (settings: Settings) => { export const setSettings = (settings: Settings) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); try { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } catch { /* quota */ }
}; };
const baseSettings = atom<Settings>(getSettings()); const baseSettings = atom<Settings>(getSettings());
+8 -1
View File
@@ -89,7 +89,13 @@ export const roomIdToTypingMembersAtom = atom<
// remove typing receipt after some timeout // remove typing receipt after some timeout
// to prevent stuck typing members // to prevent stuck typing members
const timerKey = `${action.roomId}:${action.userId}`;
const existingTimer = typingTimers.get(timerKey);
if (existingTimer !== undefined) clearTimeout(existingTimer);
typingTimers.set(
timerKey,
setTimeout(() => { setTimeout(() => {
typingTimers.delete(timerKey);
const { roomId, userId } = action; const { roomId, userId } = action;
const timeout = timeoutReceipt( const timeout = timeoutReceipt(
get(baseRoomIdToTypingMembersAtom), get(baseRoomIdToTypingMembersAtom),
@@ -109,7 +115,8 @@ export const roomIdToTypingMembersAtom = atom<
) )
); );
} }
}, TYPING_TIMEOUT_MS); }, TYPING_TIMEOUT_MS)
);
} }
if ( if (
+4 -9
View File
@@ -117,15 +117,10 @@ export const nameInitials = (str: string | undefined | null, len = 1): string =>
}; };
export const randomStr = (len = 12): string => { export const randomStr = (len = 12): string => {
let str = ''; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const minCode = 'A'.charCodeAt(0); const buf = new Uint8Array(len);
const maxCode = 'Z'.charCodeAt(0); crypto.getRandomValues(buf);
return Array.from(buf, (b) => chars[b % chars.length]).join('');
for (let i = 0; i < len; i += 1) {
const code = Math.floor(Math.random() * (maxCode - minCode + 1) + minCode);
str += String.fromCharCode(code);
}
return str;
}; };
export const suffixRename = (name: string, validator: (newName: string) => boolean): string => { export const suffixRename = (name: string, validator: (newName: string) => boolean): string => {
+1 -1
View File
@@ -88,7 +88,7 @@ export const lotusTerminalBodyClass = style({
}); });
// Font on all descendants // Font on all descendants
globalStyle(`body.${lotusTerminalBodyClass} *`, { globalStyle(`body.${lotusTerminalBodyClass}`, {
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace", fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace",
}); });
+1 -5
View File
@@ -22,10 +22,6 @@ const copyFiles = {
dest: '', dest: '',
rename: 'pdf.worker.min.js', rename: 'pdf.worker.min.js',
}, },
{
src: 'netlify.toml',
dest: '',
},
{ {
src: 'config.json', src: 'config.json',
dest: '', dest: '',
@@ -129,7 +125,7 @@ export default defineConfig({
}, },
build: { build: {
outDir: 'dist', outDir: 'dist',
sourcemap: true, sourcemap: false,
copyPublicDir: false, copyPublicDir: false,
rollupOptions: { rollupOptions: {
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })], plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],