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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user