Security, performance, bug fixes, and TDS improvements
Security: - HIGH-1: Validate hex color format before CSS interpolation in sanitize.ts - HIGH-5: Add sandbox attribute to OpenStreetMap iframe - MED-1: Fix permissive URL scheme regex in LINKIFY_OPTS - MED-3/HIGH-4: Add .js.map blocking + CSP header to nginx config - LOW-2: Validate OIDC authUrl scheme before window.open - Accessibility: Remove maximum-scale=1.0 from viewport meta (WCAG 1.4.4) Performance: - O(1) Map index in computePositions (was O(M×T) findIndex per member) - Add RoomMemberEvent.Membership subscription so positions update on join/leave - Fix uncleaned 2000ms setTimeout in RoomTimeline useLayoutEffect Bug fixes: - BUG-5: Add QUEUED/CANCELLED cases to DeliveryStatus component - BUG-6: Guard DeliveryStatus against state events via isState() check - BUG-10: Clamp PiP position on window resize - BUG-14: Separate runLotusBootSequence into dedicated useEffect([lotusTerminal]) - Fix aria-live on typing indicator (WCAG 4.1.3) - Add aria-label + aria-multiline to message editor TDS (Lotus Terminal Design System): - Add reaction chip styles (dark + light mode) - Add GIF picker CSS via globalStyle instead of runtime injection - Add URL preview styles (dark + light mode) - Add complete GIF picker light-mode TDS block (was missing)
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lotus Chat</title>
|
||||
<meta name="name" content="Lotus Chat" />
|
||||
<meta name="author" content="Lotus Guild" />
|
||||
|
||||
@@ -431,6 +431,20 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
}
|
||||
}, [pipMode, callVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pipMode) return;
|
||||
const onPipWindowResize = (): void => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el) return;
|
||||
const l = parseFloat(el.style.left);
|
||||
const t = parseFloat(el.style.top);
|
||||
if (!isNaN(l)) el.style.left = `${Math.max(0, Math.min(l, window.innerWidth - el.offsetWidth))}px`;
|
||||
if (!isNaN(t)) el.style.top = `${Math.max(0, Math.min(t, window.innerHeight - el.offsetHeight))}px`;
|
||||
};
|
||||
window.addEventListener('resize', onPipWindowResize);
|
||||
return () => window.removeEventListener('resize', onPipWindowResize);
|
||||
}, [pipMode, callEmbedRef]);
|
||||
|
||||
const handlePipMouseDown = (e: React.MouseEvent) => {
|
||||
const el = callEmbedRef.current; if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
@@ -140,6 +140,8 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
data-editable-name={editableName}
|
||||
className={css.EditorTextarea}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder ?? 'Message input'}
|
||||
aria-multiline="true"
|
||||
renderPlaceholder={renderPlaceholder}
|
||||
renderElement={renderElement}
|
||||
renderLeaf={renderLeaf}
|
||||
|
||||
@@ -410,6 +410,7 @@ export function MLocation({ content }: MLocationProps) {
|
||||
}}
|
||||
scrolling="no"
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
/>
|
||||
<Text size="T300" style={{ opacity: 0.65 }}>
|
||||
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ position: 'relative' }} aria-live="polite" aria-atomic="false">
|
||||
<Box
|
||||
className={classNames(css.RoomViewTyping, className)}
|
||||
alignItems="Center"
|
||||
|
||||
@@ -91,8 +91,10 @@ function DeliveryStatus({ status, lotusTerminal }: { status: string | null; lotu
|
||||
let icon: string;
|
||||
let label: string;
|
||||
let colorStyle: string;
|
||||
if (status === EventStatus.NOT_SENT) {
|
||||
if (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED) {
|
||||
icon = '✕'; label = 'Failed to send'; colorStyle = lotusTerminal ? '#FF3B3B' : color.Critical.Main;
|
||||
} else if (status === EventStatus.QUEUED) {
|
||||
icon = '⏳'; label = 'Queued'; colorStyle = lotusTerminal ? 'rgba(0,212,255,0.45)' : color.Secondary.Main;
|
||||
} else if (status === EventStatus.SENDING || status === EventStatus.ENCRYPTING) {
|
||||
icon = '⟳'; label = 'Sending...'; colorStyle = lotusTerminal ? 'rgba(0,212,255,0.60)' : color.Secondary.Main;
|
||||
} else {
|
||||
@@ -888,7 +890,7 @@ export const Message = as<'div', MessageProps>(
|
||||
userIds={readReceiptUsers}
|
||||
/>
|
||||
)}
|
||||
{isMine && readReceiptUsers.length === 0 && (
|
||||
{isMine && !mEvent.isState() && readReceiptUsers.length === 0 && (
|
||||
<DeliveryStatus status={mEvent.status} lotusTerminal={!!lotusTerminal} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Room, RoomEvent, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { Room, RoomEvent, RoomMemberEvent, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { reactionOrEditEvent } from '../utils/room';
|
||||
|
||||
// Receipts can land on reaction/edit events which RoomTimeline skips (renders null).
|
||||
// Walk backwards from the receipt event to find the nearest event that IS rendered.
|
||||
function nearestRenderableId(liveEvents: MatrixEvent[], evtId: string): string | null {
|
||||
const idx = liveEvents.findIndex(e => e.getId() === evtId);
|
||||
function nearestRenderableId(
|
||||
liveEvents: MatrixEvent[],
|
||||
eventIndex: Map<string, number>,
|
||||
evtId: string
|
||||
): string | null {
|
||||
const idx = eventIndex.get(evtId) ?? -1;
|
||||
if (idx === -1) return null;
|
||||
for (let i = idx; i >= 0; i--) {
|
||||
const e = liveEvents[i];
|
||||
@@ -18,11 +22,15 @@ function nearestRenderableId(liveEvents: MatrixEvent[], evtId: string): string |
|
||||
function computePositions(room: Room, myUserId: string): Map<string, string[]> {
|
||||
const map = new Map<string, string[]>();
|
||||
const liveEvents = room.getLiveTimeline().getEvents();
|
||||
// Build O(1) index once instead of O(T) findIndex per member
|
||||
const eventIndex = new Map<string, number>(
|
||||
liveEvents.map((e, i) => [e.getId() ?? '', i])
|
||||
);
|
||||
for (const member of room.getJoinedMembers()) {
|
||||
if (member.userId === myUserId) continue;
|
||||
const evtId = room.getEventReadUpTo(member.userId);
|
||||
if (!evtId) continue;
|
||||
const targetId = nearestRenderableId(liveEvents, evtId);
|
||||
const targetId = nearestRenderableId(liveEvents, eventIndex, evtId);
|
||||
if (!targetId) continue;
|
||||
const arr = map.get(targetId);
|
||||
if (arr) arr.push(member.userId);
|
||||
@@ -46,10 +54,13 @@ export function useRoomReadPositions(room: Room): Map<string, string[]> {
|
||||
debounceTimer = null;
|
||||
}, 150);
|
||||
};
|
||||
const onMembership = (): void => setPositions(computePositions(room, myUserId));
|
||||
room.on(RoomEvent.Receipt, onReceipt);
|
||||
room.on(RoomMemberEvent.Membership, onMembership);
|
||||
return () => {
|
||||
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
||||
room.removeListener(RoomEvent.Receipt, onReceipt);
|
||||
room.removeListener(RoomMemberEvent.Membership, onMembership);
|
||||
};
|
||||
}, [room, myUserId]);
|
||||
|
||||
|
||||
@@ -50,6 +50,11 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
|
||||
? (terminalIsLight ? LotusTerminalLightTheme : LotusTerminalTheme)
|
||||
: activeTheme;
|
||||
|
||||
// Boot animation only fires when lotusTerminal is toggled on, not on every theme change
|
||||
useEffect(() => {
|
||||
if (lotusTerminal) runLotusBootSequence();
|
||||
}, [lotusTerminal]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.className = '';
|
||||
document.body.classList.add(configClass, varsClass);
|
||||
@@ -57,7 +62,6 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
|
||||
if (lotusTerminal) {
|
||||
document.documentElement.setAttribute('data-theme', terminalIsLight ? 'light' : 'dark');
|
||||
document.body.classList.add(lotusTerminalBodyClass);
|
||||
runLotusBootSequence();
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const LINKIFY_OPTS: LinkifyOpts = {
|
||||
rel: 'noreferrer noopener',
|
||||
},
|
||||
validate: {
|
||||
url: (value) => /^(https|http|ftp|mailto|magnet)?:/.test(value),
|
||||
url: (value) => /^(https?|ftp|mailto|magnet):/.test(value),
|
||||
},
|
||||
ignoreTags: ['span'],
|
||||
};
|
||||
|
||||
@@ -84,7 +84,7 @@ const transformFontTag: Transformer = (tagName, attribs) => ({
|
||||
tagName,
|
||||
attribs: {
|
||||
...attribs,
|
||||
style: `background-color: ${attribs['data-mx-bg-color']}; color: ${attribs['data-mx-color']}`,
|
||||
style: `${attribs['data-mx-bg-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-bg-color']) ? `background-color: ${attribs['data-mx-bg-color']};` : ''} ${attribs['data-mx-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-color']) ? `color: ${attribs['data-mx-color']}` : ''}`.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ const transformSpanTag: Transformer = (tagName, attribs) => ({
|
||||
tagName,
|
||||
attribs: {
|
||||
...attribs,
|
||||
style: `background-color: ${attribs['data-mx-bg-color']}; color: ${attribs['data-mx-color']}`,
|
||||
style: `${attribs['data-mx-bg-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-bg-color']) ? `background-color: ${attribs['data-mx-bg-color']};` : ''} ${attribs['data-mx-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-color']) ? `color: ${attribs['data-mx-color']}` : ''}`.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -686,3 +686,34 @@ globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-url-p
|
||||
color: '#0062b8 !important' as any,
|
||||
});
|
||||
|
||||
// ── GIF picker light TDS (dark-mode rules already exist via [data-gif-terminal]) ──
|
||||
globalStyle(
|
||||
`html[data-theme="light"] body.\${lotusTerminalBodyClass} [data-gif-terminal] input,` +
|
||||
`html[data-theme="light"] body.\${lotusTerminalBodyClass} [data-gif-terminal] form`, {
|
||||
background: '#f4f6fa !important' as any,
|
||||
color: '#111827 !important' as any,
|
||||
border: '1px solid rgba(196,78,0,0.28) !important' as any,
|
||||
fontFamily: "'JetBrains Mono','Cascadia Code','Fira Code',monospace !important" as any,
|
||||
fontSize: '12px !important' as any,
|
||||
boxShadow: 'none !important' as any,
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] body.\${lotusTerminalBodyClass} [data-gif-terminal] input:focus`, {
|
||||
borderColor: 'rgba(196,78,0,0.60) !important' as any,
|
||||
boxShadow: '0 0 0 2px rgba(196,78,0,0.12) !important' as any,
|
||||
outline: 'none !important' as any,
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] body.\${lotusTerminalBodyClass} [data-gif-terminal] input::placeholder`, {
|
||||
color: 'rgba(196,78,0,0.45) !important' as any,
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] body.\${lotusTerminalBodyClass} [data-gif-terminal] svg,` +
|
||||
`html[data-theme="light"] body.\${lotusTerminalBodyClass} [data-gif-terminal] button[type="reset"]`, {
|
||||
display: 'none !important' as any,
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] body.\${lotusTerminalBodyClass} [data-gif-terminal] ::-webkit-scrollbar-track`, {
|
||||
background: '#e2e7ef',
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] body.\${lotusTerminalBodyClass} [data-gif-terminal] ::-webkit-scrollbar-thumb`, {
|
||||
background: 'rgba(196,78,0,0.35)',
|
||||
borderRadius: '2px',
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user