Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 6ae4d7adc0 fix(sidebar): the room filter hides empty sections and says when nothing matches (#289)
CI / Build & Quality Checks (pull_request) Successful in 3m7s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m7s
A filter matching nothing left Favorites / Projects / Rooms / Low
Priority headers with nothing under them. While filtering, sections
without a match are hidden, and with no match at all the list shows
"No rooms match “…”" with a Clear filter button.

Fixes #289

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:08:56 -04:00
5 changed files with 211 additions and 219 deletions
-42
View File
@@ -29,32 +29,6 @@ async function settled(page: Page) {
await page.waitForTimeout(2000);
}
/** [Gitea #307] Serve a Kuma status page for `localhost` with one posted announcement. */
async function mockAnnouncement(page: Page) {
await page.route(/\/config\.json(\?.*)?$/, async (route) => {
const res = await route.fetch();
const cfg = await res.json();
cfg.statusPages = { localhost: { url: 'https://isitup.lotusguild.org', slug: 'matrix' } };
await route.fulfill({ response: res, json: cfg });
});
await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => {
const cors = { 'access-control-allow-origin': '*' };
if (route.request().url().includes('/heartbeat/')) {
await route.fulfill({ headers: cors, json: { heartbeatList: {}, uptimeList: {} } });
return;
}
await route.fulfill({
headers: cors,
json: {
config: { slug: 'matrix' },
incidents: [{ id: 7, title: 'Community call on Friday', content: '', style: 'info' }],
maintenanceList: [],
publicGroupList: [],
},
});
});
}
test.describe('security nudge (#110, #123)', () => {
let user: TestUser;
@@ -86,22 +60,6 @@ test.describe('security nudge (#110, #123)', () => {
await expect(page.getByText('Device Verification').first()).toBeVisible();
});
test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => {
test.setTimeout(200_000);
await mockAnnouncement(page);
await loginUI(page, user);
await pastGrace(page);
await settled(page);
// The announcement wins the one strip slot until it's dismissed.
const announcement = page.getByRole('status').filter({ hasText: 'Community call on Friday' });
await expect(announcement).toBeVisible();
await expect(strip(page)).toHaveCount(0);
await announcement.getByRole('button', { name: 'Dismiss' }).click();
await page.reload();
await settled(page);
await expect(strip(page)).toContainText("Your encryption keys aren't backed up");
});
test('"Not now" snoozes it across a reload', async ({ page }) => {
test.setTimeout(200_000);
await loginUI(page, user);
@@ -14,7 +14,7 @@ import { useKeyBackupInfo, useKeyBackupStatusChange } from '../../hooks/useKeyBa
import { useAlive } from '../../hooks/useAlive';
import { useSyncState } from '../../hooks/useSyncState';
import { settingsRequestAtom } from '../../state/settingsRequest';
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
import { serverStatusAtom } from '../../state/serverStatus';
import { pickBanner } from '../../utils/kumaStatus';
import {
dismissNudge,
@@ -66,7 +66,6 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
const backupInfo = useKeyBackupInfo(crypto);
const backupActive = useBackupActive(crypto);
const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom);
const statusDismissed = useAtomValue(statusDismissedAtom);
const requestSettings = useSetAtom(settingsRequestAtom);
// Not urgent: wait until sync has settled (two SYNCING in a row), so it
// never stacks under the "Connecting…" strip at startup.
@@ -92,11 +91,7 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
});
// One strip at a time: outages, maintenance and a lost connection win.
// [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't
// on screen and must not keep the nudge hidden for as long as it's posted.
const otherStrip =
syncLost ||
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
const otherStrip = syncLost || !!pickBanner(serverStatus, { syncLost, where: 'client' });
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
return null;
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useAtomValue, useSetAtom } from 'jotai';
import { MatrixClient, SyncState } from 'matrix-js-sdk';
import { Box, Button, config, Icon, IconButton, Icons, IconSrc, Line, Text } from 'folds';
import { ContainerColor } from '../../styles/ContainerColor.css';
@@ -7,9 +7,21 @@ import { useClientConfig } from '../../hooks/useClientConfig';
import { useSyncState } from '../../hooks/useSyncState';
import { useServerStatus } from '../../hooks/useServerStatus';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
import { serverStatusAtom } from '../../state/serverStatus';
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
const DISMISS_KEY = 'lotus-status-banner-dismissed';
const DISMISS_MAX = 50;
const readDismissed = (): string[] => {
try {
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
} catch {
return [];
}
};
const bannerIcon = (b: StripContent): IconSrc => {
if (b.kind === 'maintenance') return Icons.Setting;
if (b.tone === 'Primary') return Icons.Info;
@@ -161,14 +173,22 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
/** The logged-in app's status strip. */
export function ServerStatusBanner() {
const { status, syncLost } = useAtomValue(serverStatusAtom);
const [dismissed, dismissKey] = useAtom(statusDismissedAtom);
const [dismissed, setDismissed] = useState(readDismissed);
const banner = pickBanner(status, {
syncLost,
where: 'client',
dismissed: new Set(dismissed),
});
if (!banner) return null;
const dismiss = () => dismissKey(banner.key);
const dismiss = () => {
const next = [...dismissed.filter((k) => k !== banner.key), banner.key].slice(-DISMISS_MAX);
try {
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
} catch {
// storage unavailable — dismissed for this session only
}
setDismissed(next);
};
return <StatusStrip banner={banner} onDismiss={dismiss} />;
}
+185 -134
View File
@@ -255,6 +255,8 @@ function HomeCustomSection({
return (localName ?? matrixName).toLowerCase().includes(query);
});
}, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]);
// [Gitea #289] While filtering, a section with no matches is hidden.
if (filterQuery.trim() && items.length === 0) return null;
// Sections are user-curated and small, so they render plainly — no
// virtualizer per section (a late-mounted one never measured its scroller).
return (
@@ -443,6 +445,26 @@ export function Home() {
});
}, [mx, sortedRooms, filterQuery]);
// [Gitea #289] While filtering, hide empty sections; with no match at all,
// say so instead of leaving bare headers.
const filtering = filterQuery.trim().length > 0;
const customSectionMatch = useMemo(() => {
if (!filtering) return true;
const query = filterQuery.toLowerCase();
const localNames = getLocalRoomNamesContent(mx);
return customSections.some((section) =>
section.rooms.some((rId) =>
(localNames.rooms[rId] ?? mx.getRoom(rId)?.name ?? '').toLowerCase().includes(query),
),
);
}, [mx, customSections, filtering, filterQuery]);
const noFilterMatch =
filtering &&
!customSectionMatch &&
filteredFavoriteRooms.length === 0 &&
filteredRooms.length === 0 &&
filteredLowPriorityRooms.length === 0;
const favVirtualizer = useVirtualizer({
count: filteredFavoriteRooms.length,
getScrollElement: () => scrollRef.current,
@@ -580,7 +602,7 @@ export function Home() {
/>
</Box>
</NavCategory>
{favoriteRooms.length > 0 && (
{favoriteRooms.length > 0 && (!filtering || filteredFavoriteRooms.length > 0) && (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
@@ -629,142 +651,171 @@ export function Home() {
notificationPreferences={notificationPreferences}
/>
))}
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
{noFilterMatch && (
<NavCategory>
<Box
direction="Column"
alignItems="Center"
gap="200"
style={{ padding: config.space.S400 }}
>
Rooms
</RoomNavCategoryButton>
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
<>
<IconButton
aria-expanded={!!sortMenuAnchor}
aria-haspopup="menu"
variant="Background"
fill="None"
size="300"
radii="300"
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
const cords = evt.currentTarget.getBoundingClientRect();
setSortMenuAnchor((current) => (current ? undefined : cords));
}}
aria-label="Sort rooms"
>
<Icon src={Icons.Sort} size="100" />
</IconButton>
<PopOut
anchor={sortMenuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setSortMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S100 }}
>
<MenuItem
onClick={() => {
setHomeRoomSort('recent');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'recent' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
<Text size="T300" priority="300" align="Center">
No rooms match &ldquo;{filterQuery.trim()}&rdquo;
</Text>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => setFilterQuery('')}
>
<Text size="B300">Clear filter</Text>
</Button>
</Box>
</NavCategory>
)}
{(!filtering || filteredRooms.length > 0) && (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
>
Rooms
</RoomNavCategoryButton>
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
<>
<IconButton
aria-expanded={!!sortMenuAnchor}
aria-haspopup="menu"
variant="Background"
fill="None"
size="300"
radii="300"
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
const cords = evt.currentTarget.getBoundingClientRect();
setSortMenuAnchor((current) => (current ? undefined : cords));
}}
aria-label="Sort rooms"
>
<Icon src={Icons.Sort} size="100" />
</IconButton>
<PopOut
anchor={sortMenuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setSortMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S100 }}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Recent Activity
</Text>
</MenuItem>
<MenuItem
onClick={() => {
setHomeRoomSort('alpha');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'alpha' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
A → Z
</Text>
</MenuItem>
<MenuItem
onClick={() => {
setHomeRoomSort('unread');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'unread' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Unread First
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
</>
)}
</NavCategoryHeader>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = filteredRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
<RoomNavItem
room={room}
selected={selected}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId,
)}
<MenuItem
onClick={() => {
setHomeRoomSort('recent');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'recent' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Recent Activity
</Text>
</MenuItem>
<MenuItem
onClick={() => {
setHomeRoomSort('alpha');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'alpha' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
A → Z
</Text>
</MenuItem>
<MenuItem
onClick={() => {
setHomeRoomSort('unread');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'unread' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Unread First
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
{lowPriorityRooms.length > 0 && (
</>
)}
</NavCategoryHeader>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = filteredRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={roomId}
ref={virtualizer.measureElement}
>
<RoomNavItem
room={room}
selected={selected}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId,
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
)}
{lowPriorityRooms.length > 0 && (!filtering || filteredLowPriorityRooms.length > 0) && (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
-32
View File
@@ -16,35 +16,3 @@ export const serverStatusAtom = atom<{
syncLost: false,
lostAt: null,
});
const DISMISS_KEY = 'lotus-status-banner-dismissed';
const DISMISS_MAX = 50;
const readDismissed = (): string[] => {
try {
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
} catch {
return [];
}
};
const baseDismissedAtom = atom<string[]>(readDismissed());
/**
* [Gitea #307] Status strips the user dismissed (by banner key), shared so
* the security nudge knows a dismissed announcement isn't on screen.
* Persisted to localStorage; session-only if storage is unavailable.
*/
export const statusDismissedAtom = atom(
(get) => get(baseDismissedAtom),
(get, set, key: string) => {
const next = [...get(baseDismissedAtom).filter((k) => k !== key), key].slice(-DISMISS_MAX);
try {
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
} catch {
// storage unavailable — dismissed for this session only
}
set(baseDismissedAtom, next);
},
);