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 443d8407fc
commit ff7c2ed941
16 changed files with 94 additions and 67 deletions
@@ -410,7 +410,7 @@ export function MLocation({ content }: MLocationProps) {
}}
scrolling="no"
loading="lazy"
sandbox="allow-scripts allow-same-origin"
sandbox="allow-scripts"
/>
<Text size="T300" style={{ opacity: 0.65 }}>
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
@@ -1,5 +1,5 @@
/* 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 { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { Range } from 'react-range';
@@ -65,6 +65,19 @@ export function AudioContent({
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);
// duration in seconds. (NOTE: info.duration is in milliseconds)
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" />
)}
</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} />
</IconButton>
</>
+4 -3
View File
@@ -341,7 +341,7 @@ const useTimelinePagination = (
})
);
if (err) {
// TODO: handle pagination error.
fetching = false;
return;
}
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),
// 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.
requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideActivity));
const _roomId = mEvt.getRoomId();
if (_roomId) requestAnimationFrame(() => markAsRead(mx, _roomId, hideActivity));
}
if (!document.hasFocus() && !unreadInfo) {
@@ -826,7 +827,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const evtTimeline = getEventTimeline(room, readUptoEventId);
const absoluteIndex =
evtTimeline && getEventIdAbsoluteIndex(linkedTimelines, evtTimeline, readUptoEventId);
if (absoluteIndex) {
if (typeof absoluteIndex === 'number') {
scrollToItem(absoluteIndex, {
behavior: 'instant',
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 { EventType } from 'matrix-js-sdk';
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 (
<Page ref={roomViewRef} style={getChatBg(lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground, isDark)}>
<Page ref={roomViewRef} style={chatBgStyle}>
<Box grow="Yes" direction="Column">
<RoomTimeline
key={roomId}
+13 -7
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 { Room } from 'matrix-js-sdk';
import classNames from 'classnames';
@@ -20,12 +20,18 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
const mx = useMatrixClient();
const typingMembers = useRoomTypingMember(room.roomId);
const typingNames = typingMembers
.filter((receipt) => receipt.userId !== mx.getUserId())
.map(
(receipt) => getMemberDisplayName(room, receipt.userId) ?? getMxIdLocalPart(receipt.userId)
)
.reverse();
const myUserId = mx.getUserId();
const typingNames = useMemo(
() =>
typingMembers
.filter((receipt) => receipt.userId !== myUserId)
.map(
(receipt) =>
getMemberDisplayName(room, receipt.userId) ?? getMxIdLocalPart(receipt.userId)
)
.reverse(),
[typingMembers, myUserId, room]
);
if (typingNames.length === 0) {
return null;
+1 -3
View File
@@ -232,9 +232,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
const roomIdOrAliases = rawIds.filter(
(idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias)
);
roomIdOrAliases.forEach(async (idOrAlias) => {
await mx.joinRoom(idOrAlias);
});
await Promise.all(roomIdOrAliases.map((idOrAlias) => mx.joinRoom(idOrAlias)));
},
},
[Command.Leave]: {
+1 -1
View File
@@ -21,7 +21,7 @@ export function useCompositionEndTracking(): void {
return () => {
window.removeEventListener('compositionend', recordCompositionEnd, { capture: true });
};
});
}, [recordCompositionEnd]);
}
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);
const afterLoginRedirectUrl = getAfterLoginRedirectPath();
deleteAfterLoginRedirectPath();
navigate(afterLoginRedirectUrl ?? getHomePath(), { replace: true });
const _redir = afterLoginRedirectUrl;
const _safePath = (_redir && /^\/(?!\/)/.test(_redir)) ? _redir : getHomePath();
navigate(_safePath, { replace: true });
}
}, [data, navigate]);
};
+4 -3
View File
@@ -461,11 +461,12 @@ export const getReactCustomHtmlParser = (
<span
{...props}
role="button"
tabIndex={params.handleSpoilerClick ? 0 : -1}
onKeyDown={params.handleSpoilerClick}
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') params.handleSpoilerClick?.(e as any); }}
onClick={params.handleSpoilerClick}
className={css.Spoiler()}
aria-pressed
aria-label="Spoiler — click to reveal"
aria-pressed={false}
style={{ cursor: 'pointer' }}
>
{domToReact(children, opts)}
+2 -1
View File
@@ -26,7 +26,8 @@ export const useSetting = <K extends keyof Settings>(
key: K
): [Settings[K], ReturnType<typeof useSetSetting<K>>] => {
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);
return [setting, setter];
+10 -8
View File
@@ -102,17 +102,19 @@ const defaultSettings: Settings = {
pttKey: 'Space',
};
export const getSettings = () => {
const settings = localStorage.getItem(STORAGE_KEY);
if (settings === null) return defaultSettings;
return {
...defaultSettings,
...(JSON.parse(settings) as Settings),
};
export const getSettings = (): Settings => {
try {
const settings = localStorage.getItem(STORAGE_KEY);
if (settings === null) return defaultSettings;
return { ...defaultSettings, ...(JSON.parse(settings) as Settings) };
} catch {
localStorage.removeItem(STORAGE_KEY);
return defaultSettings;
}
};
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());
+27 -20
View File
@@ -89,27 +89,34 @@ export const roomIdToTypingMembersAtom = atom<
// remove typing receipt after some timeout
// to prevent stuck typing members
setTimeout(() => {
const { roomId, userId } = action;
const timeout = timeoutReceipt(
get(baseRoomIdToTypingMembersAtom),
roomId,
userId,
TYPING_TIMEOUT_MS
);
if (timeout) {
set(
baseRoomIdToTypingMembersAtom,
produce(get(baseRoomIdToTypingMembersAtom), (draft) =>
deleteTypingMember(draft, {
type: 'DELETE',
roomId,
userId,
})
)
const timerKey = `${action.roomId}:${action.userId}`;
const existingTimer = typingTimers.get(timerKey);
if (existingTimer !== undefined) clearTimeout(existingTimer);
typingTimers.set(
timerKey,
setTimeout(() => {
typingTimers.delete(timerKey);
const { roomId, userId } = action;
const timeout = timeoutReceipt(
get(baseRoomIdToTypingMembersAtom),
roomId,
userId,
TYPING_TIMEOUT_MS
);
}
}, TYPING_TIMEOUT_MS);
if (timeout) {
set(
baseRoomIdToTypingMembersAtom,
produce(get(baseRoomIdToTypingMembersAtom), (draft) =>
deleteTypingMember(draft, {
type: 'DELETE',
roomId,
userId,
})
)
);
}
}, TYPING_TIMEOUT_MS)
);
}
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 => {
let str = '';
const minCode = 'A'.charCodeAt(0);
const maxCode = 'Z'.charCodeAt(0);
for (let i = 0; i < len; i += 1) {
const code = Math.floor(Math.random() * (maxCode - minCode + 1) + minCode);
str += String.fromCharCode(code);
}
return str;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const buf = new Uint8Array(len);
crypto.getRandomValues(buf);
return Array.from(buf, (b) => chars[b % chars.length]).join('');
};
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
globalStyle(`body.${lotusTerminalBodyClass} *`, {
globalStyle(`body.${lotusTerminalBodyClass}`, {
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace",
});
+1 -5
View File
@@ -22,10 +22,6 @@ const copyFiles = {
dest: '',
rename: 'pdf.worker.min.js',
},
{
src: 'netlify.toml',
dest: '',
},
{
src: 'config.json',
dest: '',
@@ -129,7 +125,7 @@ export default defineConfig({
},
build: {
outDir: 'dist',
sourcemap: true,
sourcemap: false,
copyPublicDir: false,
rollupOptions: {
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],