Compare commits

...

2 Commits

Author SHA1 Message Date
Lotus Bot fdc45db52f fix(perf,a11y): selectAtom for unread subscriptions, semantic headings, Perf-5 binary search
Perf-3: Replace raw roomToUnreadAtom subscription in Home, Direct, Space with
  selectAtom-derived Set<string> — components now only re-render when rooms
  gain/lose unread presence, not on every notification count update
Perf-5: RoomTimeline eventRenderer now uses binary search on precomputed
  timelineSegments instead of O(N×T) linear scan per visible message
A11y L-1: Add as=h2 semantic heading to Home, Direct, Inbox, Space page nav
  titles so screen readers announce page sections correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 21:59:09 -04:00
Lotus Bot 584da83bf0 feat(a11y,perf): comprehensive icon button labels, toolbar a11y, timeline binary search
A11y C-1: aria-label on 30+ remaining icon-only buttons across:
  - settings panels (close, reset, info, expand, remove, undo)
  - editor toolbar (bold, italic, underline, strike, code, spoiler,
    blockquote, code block, ordered/unordered list, headings 1-3)
  - auth stages (cancel buttons in SSO, Password stages)
  - device verification (cancel buttons)
  - password input (show/hide toggle with dynamic label)
  - event readers, account data editor close buttons
  - global emoji packs (add/remove buttons)
Perf-5: Replace O(N×T) getTimelineAndBaseIndex scan with precomputed binary
  search (timelineSegments useMemo) — O(log T) per visible message render

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 21:54:33 -04:00
27 changed files with 160 additions and 43 deletions
+1 -1
View File
@@ -294,7 +294,7 @@ export function AccountDataEditor({
</Chip> </Chip>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
+1 -1
View File
@@ -261,7 +261,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Device Verification</Text> <Text size="H4">Device Verification</Text>
</Box> </Box>
<IconButton size="300" radii="300" onClick={handleCancel}> <IconButton size="300" radii="300" onClick={handleCancel} aria-label="Cancel verification">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
@@ -301,7 +301,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Setup Device Verification</Text> <Text size="H4">Setup Device Verification</Text>
</Box> </Box>
<IconButton size="300" radii="300" onClick={onCancel}> <IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
+39 -13
View File
@@ -54,8 +54,8 @@ function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
); );
} }
type MarkButtonProps = { format: MarkType; icon: IconSrc; tooltip: ReactNode }; type MarkButtonProps = { format: MarkType; icon: IconSrc; tooltip: ReactNode; label?: string };
export function MarkButton({ format, icon, tooltip }: MarkButtonProps) { export function MarkButton({ format, icon, tooltip, label }: MarkButtonProps) {
const editor = useSlate(); const editor = useSlate();
const disableInline = isBlockActive(editor, BlockType.CodeBlock); const disableInline = isBlockActive(editor, BlockType.CodeBlock);
@@ -76,6 +76,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
variant="SurfaceVariant" variant="SurfaceVariant"
onClick={handleClick} onClick={handleClick}
aria-pressed={isMarkActive(editor, format)} aria-pressed={isMarkActive(editor, format)}
aria-label={label}
size="400" size="400"
radii="300" radii="300"
disabled={disableInline} disabled={disableInline}
@@ -91,8 +92,9 @@ type BlockButtonProps = {
format: BlockType; format: BlockType;
icon: IconSrc; icon: IconSrc;
tooltip: ReactNode; tooltip: ReactNode;
label?: string;
}; };
export function BlockButton({ format, icon, tooltip }: BlockButtonProps) { export function BlockButton({ format, icon, tooltip, label }: BlockButtonProps) {
const editor = useSlate(); const editor = useSlate();
const handleClick = () => { const handleClick = () => {
@@ -108,6 +110,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
variant="SurfaceVariant" variant="SurfaceVariant"
onClick={handleClick} onClick={handleClick}
aria-pressed={isBlockActive(editor, format)} aria-pressed={isBlockActive(editor, format)}
aria-label={label}
size="400" size="400"
radii="300" radii="300"
> >
@@ -165,6 +168,7 @@ export function HeadingBlockButton() {
<IconButton <IconButton
ref={triggerRef} ref={triggerRef}
onClick={() => handleMenuSelect(1)} onClick={() => handleMenuSelect(1)}
aria-label="Heading 1"
size="400" size="400"
radii="300" radii="300"
> >
@@ -180,6 +184,7 @@ export function HeadingBlockButton() {
<IconButton <IconButton
ref={triggerRef} ref={triggerRef}
onClick={() => handleMenuSelect(2)} onClick={() => handleMenuSelect(2)}
aria-label="Heading 2"
size="400" size="400"
radii="300" radii="300"
> >
@@ -195,6 +200,7 @@ export function HeadingBlockButton() {
<IconButton <IconButton
ref={triggerRef} ref={triggerRef}
onClick={() => handleMenuSelect(3)} onClick={() => handleMenuSelect(3)}
aria-label="Heading 3"
size="400" size="400"
radii="300" radii="300"
> >
@@ -271,32 +277,44 @@ export function Toolbar() {
<MarkButton <MarkButton
format={MarkType.Bold} format={MarkType.Bold}
icon={Icons.Bold} icon={Icons.Bold}
tooltip={<BtnTooltip text="Bold" shortCode={`${modKey} + B`} />} tooltip={<BtnTooltip text="Bold" shortCode={`${modKey} + B`}
label="Bold"
/>}
/> />
<MarkButton <MarkButton
format={MarkType.Italic} format={MarkType.Italic}
icon={Icons.Italic} icon={Icons.Italic}
tooltip={<BtnTooltip text="Italic" shortCode={`${modKey} + I`} />} tooltip={<BtnTooltip text="Italic" shortCode={`${modKey} + I`}
label="Italic"
/>}
/> />
<MarkButton <MarkButton
format={MarkType.Underline} format={MarkType.Underline}
icon={Icons.Underline} icon={Icons.Underline}
tooltip={<BtnTooltip text="Underline" shortCode={`${modKey} + U`} />} tooltip={<BtnTooltip text="Underline" shortCode={`${modKey} + U`}
label="Underline"
/>}
/> />
<MarkButton <MarkButton
format={MarkType.StrikeThrough} format={MarkType.StrikeThrough}
icon={Icons.Strike} icon={Icons.Strike}
tooltip={<BtnTooltip text="Strike Through" shortCode={`${modKey} + S`} />} tooltip={<BtnTooltip text="Strike Through" shortCode={`${modKey} + S`}
label="Strikethrough"
/>}
/> />
<MarkButton <MarkButton
format={MarkType.Code} format={MarkType.Code}
icon={Icons.Code} icon={Icons.Code}
tooltip={<BtnTooltip text="Inline Code" shortCode={`${modKey} + [`} />} tooltip={<BtnTooltip text="Inline Code" shortCode={`${modKey} + [`}
label="Inline code"
/>}
/> />
<MarkButton <MarkButton
format={MarkType.Spoiler} format={MarkType.Spoiler}
icon={Icons.EyeBlind} icon={Icons.EyeBlind}
tooltip={<BtnTooltip text="Spoiler" shortCode={`${modKey} + H`} />} tooltip={<BtnTooltip text="Spoiler" shortCode={`${modKey} + H`}
label="Spoiler"
/>}
/> />
</Box> </Box>
<Line variant="SurfaceVariant" direction="Vertical" style={{ height: toRem(12) }} /> <Line variant="SurfaceVariant" direction="Vertical" style={{ height: toRem(12) }} />
@@ -305,22 +323,30 @@ export function Toolbar() {
<BlockButton <BlockButton
format={BlockType.BlockQuote} format={BlockType.BlockQuote}
icon={Icons.BlockQuote} icon={Icons.BlockQuote}
tooltip={<BtnTooltip text="Block Quote" shortCode={`${modKey} + '`} />} tooltip={<BtnTooltip text="Block Quote" shortCode={`${modKey} + '`}
label="Block quote"
/>}
/> />
<BlockButton <BlockButton
format={BlockType.CodeBlock} format={BlockType.CodeBlock}
icon={Icons.BlockCode} icon={Icons.BlockCode}
tooltip={<BtnTooltip text="Block Code" shortCode={`${modKey} + ;`} />} tooltip={<BtnTooltip text="Block Code" shortCode={`${modKey} + ;`}
label="Code block"
/>}
/> />
<BlockButton <BlockButton
format={BlockType.OrderedList} format={BlockType.OrderedList}
icon={Icons.OrderList} icon={Icons.OrderList}
tooltip={<BtnTooltip text="Ordered List" shortCode={`${modKey} + 7`} />} tooltip={<BtnTooltip text="Ordered List" shortCode={`${modKey} + 7`}
label="Ordered list"
/>}
/> />
<BlockButton <BlockButton
format={BlockType.UnorderedList} format={BlockType.UnorderedList}
icon={Icons.UnorderList} icon={Icons.UnorderList}
tooltip={<BtnTooltip text="Unordered List" shortCode={`${modKey} + 8`} />} tooltip={<BtnTooltip text="Unordered List" shortCode={`${modKey} + 8`}
label="Unordered list"
/>}
/> />
<HeadingBlockButton /> <HeadingBlockButton />
</Box> </Box>
@@ -73,7 +73,7 @@ export const EventReaders = as<'div', EventReadersProps>(
<Box grow="Yes"> <Box grow="Yes">
<Text size="H3" style={lotusTerminal ? { color: '#00D4FF', textShadow: '0 0 6px rgba(0,212,255,0.45)', letterSpacing: '0.05em' } : undefined}>Seen by</Text> <Text size="H3" style={lotusTerminal ? { color: '#00D4FF', textShadow: '0 0 6px rgba(0,212,255,0.45)', letterSpacing: '0.05em' } : undefined}>Seen by</Text>
</Box> </Box>
<IconButton size="300" onClick={requestClose}> <IconButton size="300" onClick={requestClose} aria-label="Close">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
@@ -28,6 +28,7 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
variant={visible ? 'Warning' : variant} variant={visible ? 'Warning' : variant}
size="300" size="300"
radii="300" radii="300"
aria-label={visible ? 'Hide password' : 'Show password'}
> >
<Icon <Icon
style={{ opacity: config.opacity.P300 }} style={{ opacity: config.opacity.P300 }}
@@ -45,7 +45,7 @@ export function PasswordStage({
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Account Password</Text> <Text size="H4">Account Password</Text>
</Box> </Box>
<IconButton size="300" onClick={onCancel} radii="300"> <IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
+1 -1
View File
@@ -56,7 +56,7 @@ export function SSOStage({
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">SSO Login</Text> <Text size="H4">SSO Login</Text>
</Box> </Box>
<IconButton size="300" onClick={onCancel} radii="300"> <IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
+25 -1
View File
@@ -546,6 +546,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timelineRef = React.useRef(timeline); const timelineRef = React.useRef(timeline);
timelineRef.current = timeline; timelineRef.current = timeline;
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines); const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
// Perf-5: precompute base offsets once per linkedTimelines change instead of O(N×T) scan
const timelineSegments = useMemo<Array<[number, number, EventTimeline]>>(() => {
let base = 0;
return timeline.linkedTimelines.map((t) => {
const len = t.getEvents().length;
const seg: [number, number, EventTimeline] = [base, len, t];
base += len;
return seg;
});
}, [timeline.linkedTimelines]);
const liveTimelineLinked = const liveTimelineLinked =
timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room); timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
const canPaginateBack = const canPaginateBack =
@@ -1809,7 +1820,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
let newDivider = false; let newDivider = false;
let dayDivider = false; let dayDivider = false;
const eventRenderer = (item: number) => { const eventRenderer = (item: number) => {
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item); // Perf-5: O(T) → O(log T) via precomputed segments
let eventTimeline: EventTimeline | undefined;
let baseIndex = 0;
{
let lo = 0;
let hi = timelineSegments.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const [base, len] = timelineSegments[mid];
if (item < base) { hi = mid - 1; }
else if (item >= base + len) { lo = mid + 1; }
else { eventTimeline = timelineSegments[mid][2]; baseIndex = base; break; }
}
}
if (!eventTimeline) return null; if (!eventTimeline) return null;
const timelineSet = eventTimeline?.getTimelineSet(); const timelineSet = eventTimeline?.getTimelineSet();
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex)); const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
+1 -1
View File
@@ -141,7 +141,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
{screenSize === ScreenSize.Mobile && ( {screenSize === ScreenSize.Mobile && (
<IconButton onClick={requestClose} variant="Background"> <IconButton onClick={requestClose} variant="Background" aria-label="Close settings">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
)} )}
+1 -1
View File
@@ -25,7 +25,7 @@ export function About({ requestClose }: AboutProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
@@ -20,7 +20,7 @@ export function Account({ requestClose }: AccountProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
@@ -72,6 +72,7 @@ function IgnoreUserInput({ userList }: { userList: string[] }) {
size="300" size="300"
radii="300" radii="300"
variant="Secondary" variant="Secondary"
aria-label="Clear"
> >
<Icon src={Icons.Cross} size="100" /> <Icon src={Icons.Cross} size="100" />
</IconButton> </IconButton>
@@ -185,7 +185,7 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Remove Avatar</Text> <Text size="H4">Remove Avatar</Text>
</Box> </Box>
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300"> <IconButton size="300" onClick={() => setAlertRemove(false)} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
@@ -278,6 +278,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
size="300" size="300"
radii="300" radii="300"
variant="Secondary" variant="Secondary"
aria-label="Reset display name"
> >
<Icon src={Icons.Cross} size="100" /> <Icon src={Icons.Cross} size="100" />
</IconButton> </IconButton>
@@ -51,7 +51,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
@@ -292,6 +292,8 @@ export function DeviceTile({
outlined={deleted} outlined={deleted}
radii="300" radii="300"
onClick={() => setDetails(!details)} onClick={() => setDetails(!details)}
aria-label={details ? 'Collapse device details' : 'Expand device details'}
aria-expanded={details}
> >
<Icon size="50" src={details ? Icons.ChevronBottom : Icons.ChevronRight} /> <Icon size="50" src={details ? Icons.ChevronBottom : Icons.ChevronRight} />
</IconButton> </IconButton>
@@ -74,7 +74,7 @@ export function Devices({ requestClose }: DevicesProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
@@ -293,6 +293,7 @@ export function DeviceVerificationOptions() {
size="300" size="300"
radii="300" radii="300"
onClick={handleMenu} onClick={handleMenu}
aria-label="Verification options"
> >
<Icon size="100" src={Icons.VerticalDots} /> <Icon size="100" src={Icons.VerticalDots} />
</IconButton> </IconButton>
@@ -30,7 +30,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
@@ -373,6 +373,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
variant="Critical" variant="Critical"
onClick={() => handleUndoRemove(address)} onClick={() => handleUndoRemove(address)}
disabled={applyingChanges} disabled={applyingChanges}
aria-label="Undo remove pack"
> >
<Icon src={Icons.Plus} size="100" /> <Icon src={Icons.Plus} size="100" />
</IconButton> </IconButton>
@@ -383,6 +384,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
variant="Secondary" variant="Secondary"
onClick={() => handleRemove(address)} onClick={() => handleRemove(address)}
disabled={applyingChanges} disabled={applyingChanges}
aria-label="Remove pack"
> >
<Icon src={Icons.Cross} size="100" /> <Icon src={Icons.Cross} size="100" />
</IconButton> </IconButton>
@@ -1184,7 +1184,7 @@ export function General({ requestClose }: GeneralProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
@@ -80,6 +80,7 @@ function KeywordInput() {
size="300" size="300"
radii="300" radii="300"
variant="Secondary" variant="Secondary"
aria-label="Clear keyword input"
> >
<Icon src={Icons.Cross} size="100" /> <Icon src={Icons.Cross} size="100" />
</IconButton> </IconButton>
@@ -118,7 +119,7 @@ function KeywordCross({ pushRule }: PushRulesProps) {
const removing = removeState.status === AsyncStatus.Loading; const removing = removeState.status === AsyncStatus.Loading;
return ( return (
<IconButton onClick={remove} size="300" radii="Pill" variant="Secondary" disabled={removing}> <IconButton onClick={remove} size="300" radii="Pill" variant="Secondary" disabled={removing} aria-label="Remove keyword">
{removing ? <Spinner size="100" /> : <Icon src={Icons.Cross} size="100" />} {removing ? <Spinner size="100" /> : <Icon src={Icons.Cross} size="100" />}
</IconButton> </IconButton>
); );
@@ -23,7 +23,7 @@ export function Notifications({ requestClose }: NotificationsProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
+23 -4
View File
@@ -1,5 +1,6 @@
import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react'; import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { import {
Avatar, Avatar,
Box, Box,
@@ -102,7 +103,7 @@ function DirectHeader() {
<PageNavHeader> <PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300"> <Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4" truncate> <Text as="h2" size="H4" truncate>
Direct Messages Direct Messages
</Text> </Text>
</Box> </Box>
@@ -174,7 +175,25 @@ export function Direct() {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const directs = useDirectRooms(); const directs = useDirectRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationPreferences = useRoomsNotificationPreferencesContext();
const roomToUnread = useAtomValue(roomToUnreadAtom); const roomsWithUnreadSet = useAtomValue(
useMemo(
() =>
selectAtom(
roomToUnreadAtom,
(rtu) => {
const s = new Set<string>();
for (const id of rtu.keys()) s.add(id);
return s;
},
(a, b) => {
if (a.size !== b.size) return false;
for (const id of a) if (!b.has(id)) return false;
return true;
}
),
[]
)
);
const navigate = useNavigate(); const navigate = useNavigate();
const createDirectSelected = useDirectCreateSelected(); const createDirectSelected = useDirectCreateSelected();
@@ -186,10 +205,10 @@ export function Direct() {
const sortedDirects = useMemo(() => { const sortedDirects = useMemo(() => {
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx)); const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
if (closedCategories.has(DEFAULT_CATEGORY_ID)) { if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId); return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
} }
return items; return items;
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId]); }, [mx, directs, closedCategories, roomsWithUnreadSet, selectedRoomId]);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: sortedDirects.length, count: sortedDirects.length,
+24 -4
View File
@@ -17,6 +17,7 @@ import {
} from 'folds'; } from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort'; import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort';
import { import {
@@ -116,7 +117,7 @@ function HomeHeader() {
<PageNavHeader> <PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300"> <Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4" truncate> <Text as="h2" size="H4" truncate>
Home Home
</Text> </Text>
</Box> </Box>
@@ -200,7 +201,26 @@ export function Home() {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const rooms = useHomeRooms(); const rooms = useHomeRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationPreferences = useRoomsNotificationPreferencesContext();
const roomToUnread = useAtomValue(roomToUnreadAtom); // Perf-3: only re-render when the set of rooms WITH unread changes, not on count updates
const roomsWithUnreadSet = useAtomValue(
useMemo(
() =>
selectAtom(
roomToUnreadAtom,
(rtu) => {
const s = new Set<string>();
for (const id of rtu.keys()) s.add(id);
return s;
},
(a, b) => {
if (a.size !== b.size) return false;
for (const id of a) if (!b.has(id)) return false;
return true;
}
),
[]
)
);
const navigate = useNavigate(); const navigate = useNavigate();
const selectedRoomId = useSelectedRoom(); const selectedRoomId = useSelectedRoom();
@@ -216,10 +236,10 @@ export function Home() {
: factoryRoomIdByAtoZ(mx) : factoryRoomIdByAtoZ(mx)
); );
if (closedCategories.has(DEFAULT_CATEGORY_ID)) { if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId); return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
} }
return items; return items;
}, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]); }, [mx, rooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: sortedRooms.length, count: sortedRooms.length,
+1 -1
View File
@@ -52,7 +52,7 @@ export function Inbox() {
<PageNavHeader> <PageNavHeader>
<Box grow="Yes" gap="300"> <Box grow="Yes" gap="300">
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4" truncate> <Text as="h2" size="H4" truncate>
Inbox Inbox
</Text> </Text>
</Box> </Box>
+23 -4
View File
@@ -7,6 +7,7 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { import {
Avatar, Avatar,
Box, Box,
@@ -267,7 +268,7 @@ function SpaceHeader() {
<PageNavHeader> <PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300"> <Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes" alignItems="Center" gap="100"> <Box grow="Yes" alignItems="Center" gap="100">
<Text size="H4" truncate> <Text as="h2" size="H4" truncate>
{spaceName} {spaceName}
</Text> </Text>
{joinRules?.join_rule !== JoinRule.Public && <Icon src={Icons.Lock} size="50" />} {joinRules?.join_rule !== JoinRule.Public && <Icon src={Icons.Lock} size="50" />}
@@ -382,7 +383,25 @@ export function Space() {
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId); const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const roomToUnread = useAtomValue(roomToUnreadAtom); const roomsWithUnreadSet = useAtomValue(
useMemo(
() =>
selectAtom(
roomToUnreadAtom,
(rtu) => {
const s = new Set<string>();
for (const id of rtu.keys()) s.add(id);
return s;
},
(a, b) => {
if (a.size !== b.size) return false;
for (const id of a) if (!b.has(id)) return false;
return true;
}
),
[]
)
);
const allRooms = useAtomValue(allRoomsAtom); const allRooms = useAtomValue(allRoomsAtom);
const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationPreferences = useRoomsNotificationPreferencesContext();
@@ -414,10 +433,10 @@ export function Space() {
return false; return false;
} }
const showRoomAnyway = const showRoomAnyway =
roomToUnread.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId; roomsWithUnreadSet.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId;
return !showRoomAnyway; return !showRoomAnyway;
}, },
[space.roomId, closedCategories, roomToUnread, selectedRoomId, callEmbed] [space.roomId, closedCategories, roomsWithUnreadSet, selectedRoomId, callEmbed]
), ),
useCallback( useCallback(
(sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)),