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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]: {
|
||||
|
||||
@@ -21,7 +21,7 @@ export function useCompositionEndTracking(): void {
|
||||
return () => {
|
||||
window.removeEventListener('compositionend', recordCompositionEnd, { capture: true });
|
||||
};
|
||||
});
|
||||
}, [recordCompositionEnd]);
|
||||
}
|
||||
|
||||
interface IsComposingLike {
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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'] })],
|
||||
|
||||
Reference in New Issue
Block a user