feat(sidebar): custom room sections via u.* tags (#108)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s

Room context menu → "Add to Section" submenu: every u.<name> tag in use
across your rooms as a checkable item, plus a "New section…" field
(validated: non-empty, ≤ 40 chars, no dots, unique). Toggling writes or
deletes the standard u.<name> room tag (order 0.5), so sections sync
across devices and other clients see the same tags. The menu row reads
"Sections: Raids, Off-topic" once a room is in any.

Home renders each section as a collapsible category between Favorites
and Rooms (alphabetical; members by tag order then name; the same
closed-state store and unread-only-when-collapsed behaviour as the built-in
categories). A sectioned room leaves the plain Rooms list but keeps a
Favorite / Low Priority placement. Empty sections don't exist by
construction; rename is retag (v2). Derivation in utils/roomSections.ts
with unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-20 15:05:30 -04:00
co-authored by Claude Opus 5
parent c6c2e88df5
commit 082b8fc879
4 changed files with 354 additions and 3 deletions
+97 -3
View File
@@ -73,6 +73,7 @@ import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import {
getRoomNotificationMode,
RoomsNotificationPreferences,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { UseStateProvider } from '../../../components/UseStateProvider';
@@ -80,6 +81,7 @@ import { JoinAddressPrompt } from '../../../components/join-address-prompt';
import { _RoomSearchParams } from '../../paths';
import { getLocalRoomNamesContent } from '../../../hooks/useRoomMeta';
import { useRoomTagsVersion } from '../../../hooks/useRoomTagsVersion';
import { RoomSection, deriveRoomSections } from '../../../utils/roomSections';
type HomeMenuProps = {
requestClose: () => void;
@@ -217,6 +219,73 @@ function HomeEmpty() {
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority');
type HomeCustomSectionProps = {
section: RoomSection;
closed: boolean;
onCategoryClick: MouseEventHandler<HTMLButtonElement>;
filterQuery: string;
selectedRoomId?: string;
roomsWithUnreadSet: Set<string>;
notificationPreferences: RoomsNotificationPreferences;
};
/** [Gitea #108] One collapsible u.* section; mirrors the Favorites block. */
function HomeCustomSection({
section,
closed,
onCategoryClick,
filterQuery,
selectedRoomId,
roomsWithUnreadSet,
notificationPreferences,
}: HomeCustomSectionProps) {
const mx = useMatrixClient();
const categoryId = makeNavCategoryId('home', section.tag);
const items = useMemo(() => {
// Open: the tag's own order. Closed: only unread/selected, by activity.
const base = closed
? [...section.rooms]
.sort(factoryRoomIdByActivity(mx))
.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId)
: section.rooms;
if (!filterQuery.trim()) return base;
const query = filterQuery.toLowerCase();
const localNames = getLocalRoomNamesContent(mx);
return base.filter((rId) => {
const localName = localNames.rooms[rId];
const matrixName = mx.getRoom(rId)?.name ?? '';
return (localName ?? matrixName).toLowerCase().includes(query);
});
}, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]);
// Sections are user-curated and small, so they render plainly — no
// virtualizer per section (a late-mounted one never measured its scroller).
return (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closed}
data-category-id={categoryId}
onClick={onCategoryClick}
>
{section.name}
</RoomNavCategoryButton>
</NavCategoryHeader>
{items.map((roomId) => {
const room = mx.getRoom(roomId);
if (!room) return null;
return (
<RoomNavItem
key={roomId}
room={room}
selected={selectedRoomId === roomId}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
/>
);
})}
</NavCategory>
);
}
export function Home() {
const mx = useMatrixClient();
useNavToActivePathMapper('home');
@@ -260,21 +329,34 @@ export function Home() {
// once some unrelated event (e.g. an unread-count change) forces a re-render.
const roomTagsVersion = useRoomTagsVersion(mx);
const { favoriteRooms, lowPriorityRooms, otherRooms } = useMemo(() => {
const { favoriteRooms, lowPriorityRooms, otherRooms, customSections } = useMemo(() => {
const favs: string[] = [];
const low: string[] = [];
const others: string[] = [];
// [Gitea #108] u.* tags → custom sections. A room in a section leaves the
// plain "Rooms" list but keeps its Favorite / Low Priority placement.
const { sections, sectioned } = deriveRoomSections(
rooms.map((rId) => {
const room = mx.getRoom(rId);
return { roomId: rId, name: room?.name ?? rId, tags: room?.tags };
}),
);
rooms.forEach((rId) => {
const room = mx.getRoom(rId);
if (room?.tags?.['m.favourite']) {
favs.push(rId);
} else if (room?.tags?.['m.lowpriority']) {
low.push(rId);
} else {
} else if (!sectioned.has(rId)) {
others.push(rId);
}
});
return { favoriteRooms: favs, lowPriorityRooms: low, otherRooms: others };
return {
favoriteRooms: favs,
lowPriorityRooms: low,
otherRooms: others,
customSections: sections,
};
// roomTagsVersion is a trigger-only counter, not read in the body; it forces
// this memo to re-run whenever any room's tags change.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -536,6 +618,18 @@ export function Home() {
</div>
</NavCategory>
)}
{customSections.map((section) => (
<HomeCustomSection
key={section.tag}
section={section}
closed={closedCategories.has(makeNavCategoryId('home', section.tag))}
onCategoryClick={handleCategoryClick}
filterQuery={filterQuery}
selectedRoomId={selectedRoomId}
roomsWithUnreadSet={roomsWithUnreadSet}
notificationPreferences={notificationPreferences}
/>
))}
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton