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:
@@ -23,6 +23,7 @@ import {
|
|||||||
Overlay,
|
Overlay,
|
||||||
OverlayBackdrop,
|
OverlayBackdrop,
|
||||||
OverlayCenter,
|
OverlayCenter,
|
||||||
|
color,
|
||||||
config,
|
config,
|
||||||
PopOut,
|
PopOut,
|
||||||
toRem,
|
toRem,
|
||||||
@@ -37,6 +38,13 @@ import { useFocusWithin, useHover } from 'react-aria';
|
|||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { selectAtom } from 'jotai/utils';
|
import { selectAtom } from 'jotai/utils';
|
||||||
|
import {
|
||||||
|
isSectionTag,
|
||||||
|
listSectionNames,
|
||||||
|
sectionName,
|
||||||
|
sectionTag,
|
||||||
|
validateSectionName,
|
||||||
|
} from '../../utils/roomSections';
|
||||||
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
||||||
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
||||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
@@ -277,6 +285,14 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
|
|
||||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||||
const [muteMenuAnchor, setMuteMenuAnchor] = useState<RectCords>();
|
const [muteMenuAnchor, setMuteMenuAnchor] = useState<RectCords>();
|
||||||
|
// [Gitea #108] "Add to section" submenu: existing u.* sections + new one.
|
||||||
|
const [sectionMenuAnchor, setSectionMenuAnchor] = useState<RectCords>();
|
||||||
|
const [newSectionName, setNewSectionName] = useState('');
|
||||||
|
const [newSectionError, setNewSectionError] = useState<string>();
|
||||||
|
const sectionNames = useMemo(() => listSectionNames(mx), [mx]);
|
||||||
|
const roomSections = Object.keys(room.tags ?? {})
|
||||||
|
.filter(isSectionTag)
|
||||||
|
.map(sectionName);
|
||||||
const isServerNotice = room.getType() === 'm.server_notice';
|
const isServerNotice = room.getType() === 'm.server_notice';
|
||||||
|
|
||||||
const isFavorite = !!room.tags?.['m.favourite'];
|
const isFavorite = !!room.tags?.['m.favourite'];
|
||||||
@@ -316,6 +332,25 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
requestClose();
|
requestClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleSection = (name: string) => {
|
||||||
|
const tag = sectionTag(name);
|
||||||
|
const op = room.tags?.[tag]
|
||||||
|
? mx.deleteRoomTag(room.roomId, tag)
|
||||||
|
: mx.setRoomTag(room.roomId, tag, { order: 0.5 });
|
||||||
|
op.catch(notifyTagFailure);
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewSection = (evt: React.FormEvent) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const error = validateSectionName(newSectionName, sectionNames);
|
||||||
|
if (error) {
|
||||||
|
setNewSectionError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleToggleSection(newSectionName.trim());
|
||||||
|
};
|
||||||
|
|
||||||
const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId);
|
const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId);
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
@@ -498,6 +533,97 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
{isLowPriority ? 'Remove from Low Priority' : 'Add to Low Priority'}
|
{isLowPriority ? 'Remove from Low Priority' : 'Add to Low Priority'}
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<PopOut
|
||||||
|
anchor={sectionMenuAnchor}
|
||||||
|
position="Right"
|
||||||
|
align="Start"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setSectionMenuAnchor(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ maxWidth: toRem(220), width: '100vw' }}>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{sectionNames.map((name) => {
|
||||||
|
const inSection = roomSections.includes(name);
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={name}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
role="menuitemcheckbox"
|
||||||
|
aria-checked={inSection}
|
||||||
|
after={inSection ? <Icon size="100" src={Icons.Check} /> : undefined}
|
||||||
|
onClick={() => handleToggleSection(name)}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{sectionNames.length > 0 && <Line variant="Surface" size="300" />}
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
onSubmit={handleNewSection}
|
||||||
|
style={{ padding: config.space.S100 }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
size="300"
|
||||||
|
variant="Background"
|
||||||
|
radii="300"
|
||||||
|
placeholder="New section…"
|
||||||
|
aria-label="New section name"
|
||||||
|
value={newSectionName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewSectionName(e.currentTarget.value);
|
||||||
|
setNewSectionError(undefined);
|
||||||
|
}}
|
||||||
|
after={
|
||||||
|
<IconButton
|
||||||
|
type="submit"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Background"
|
||||||
|
aria-label="Create section"
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Plus} />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{newSectionError && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
{newSectionError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.ChevronRight} />}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={!!sectionMenuAnchor}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
onClick={(e) => setSectionMenuAnchor(e.currentTarget.getBoundingClientRect())}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
{roomSections.length > 0
|
||||||
|
? `Sections: ${roomSections.join(', ')}`
|
||||||
|
: 'Add to Section'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</PopOut>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleInvite}
|
onClick={handleInvite}
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import { useSetting } from '../../../state/hooks/settings';
|
|||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import {
|
import {
|
||||||
getRoomNotificationMode,
|
getRoomNotificationMode,
|
||||||
|
RoomsNotificationPreferences,
|
||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||||
import { UseStateProvider } from '../../../components/UseStateProvider';
|
import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||||
@@ -80,6 +81,7 @@ import { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
|||||||
import { _RoomSearchParams } from '../../paths';
|
import { _RoomSearchParams } from '../../paths';
|
||||||
import { getLocalRoomNamesContent } from '../../../hooks/useRoomMeta';
|
import { getLocalRoomNamesContent } from '../../../hooks/useRoomMeta';
|
||||||
import { useRoomTagsVersion } from '../../../hooks/useRoomTagsVersion';
|
import { useRoomTagsVersion } from '../../../hooks/useRoomTagsVersion';
|
||||||
|
import { RoomSection, deriveRoomSections } from '../../../utils/roomSections';
|
||||||
|
|
||||||
type HomeMenuProps = {
|
type HomeMenuProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -217,6 +219,73 @@ function HomeEmpty() {
|
|||||||
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
||||||
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
||||||
const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority');
|
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() {
|
export function Home() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
useNavToActivePathMapper('home');
|
useNavToActivePathMapper('home');
|
||||||
@@ -260,21 +329,34 @@ export function Home() {
|
|||||||
// once some unrelated event (e.g. an unread-count change) forces a re-render.
|
// once some unrelated event (e.g. an unread-count change) forces a re-render.
|
||||||
const roomTagsVersion = useRoomTagsVersion(mx);
|
const roomTagsVersion = useRoomTagsVersion(mx);
|
||||||
|
|
||||||
const { favoriteRooms, lowPriorityRooms, otherRooms } = useMemo(() => {
|
const { favoriteRooms, lowPriorityRooms, otherRooms, customSections } = useMemo(() => {
|
||||||
const favs: string[] = [];
|
const favs: string[] = [];
|
||||||
const low: string[] = [];
|
const low: string[] = [];
|
||||||
const others: 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) => {
|
rooms.forEach((rId) => {
|
||||||
const room = mx.getRoom(rId);
|
const room = mx.getRoom(rId);
|
||||||
if (room?.tags?.['m.favourite']) {
|
if (room?.tags?.['m.favourite']) {
|
||||||
favs.push(rId);
|
favs.push(rId);
|
||||||
} else if (room?.tags?.['m.lowpriority']) {
|
} else if (room?.tags?.['m.lowpriority']) {
|
||||||
low.push(rId);
|
low.push(rId);
|
||||||
} else {
|
} else if (!sectioned.has(rId)) {
|
||||||
others.push(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
|
// roomTagsVersion is a trigger-only counter, not read in the body; it forces
|
||||||
// this memo to re-run whenever any room's tags change.
|
// this memo to re-run whenever any room's tags change.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -536,6 +618,18 @@ export function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</NavCategory>
|
</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>
|
<NavCategory>
|
||||||
<NavCategoryHeader>
|
<NavCategoryHeader>
|
||||||
<RoomNavCategoryButton
|
<RoomNavCategoryButton
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { deriveRoomSections, sectionTag, validateSectionName } from './roomSections';
|
||||||
|
|
||||||
|
describe('deriveRoomSections', () => {
|
||||||
|
it('groups by u.* tags, orders members by order then name, sections by name', () => {
|
||||||
|
const { sections, sectioned } = deriveRoomSections([
|
||||||
|
{ roomId: '!a', name: 'Alpha', tags: { 'u.Raids': { order: 0.7 } } },
|
||||||
|
{ roomId: '!b', name: 'Beta', tags: { 'u.Raids': { order: 0.2 }, 'm.favourite': {} } },
|
||||||
|
{ roomId: '!c', name: 'Gamma', tags: { 'u.Raids': {}, 'u.off-topic': { order: '0.1' } } },
|
||||||
|
{ roomId: '!d', name: 'Delta', tags: { 'm.lowpriority': {} } },
|
||||||
|
{ roomId: '!e', name: 'Eps', tags: undefined },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
sections.map((s) => [s.name, s.tag, s.rooms]),
|
||||||
|
[
|
||||||
|
['off-topic', 'u.off-topic', ['!c']],
|
||||||
|
['Raids', 'u.Raids', ['!b', '!a', '!c']],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual([...sectioned].sort(), ['!a', '!b', '!c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no empty sections', () => {
|
||||||
|
assert.deepEqual(deriveRoomSections([{ roomId: '!a', name: 'A', tags: {} }]).sections, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores m.* and malformed u. tags', () => {
|
||||||
|
const { sections } = deriveRoomSections([
|
||||||
|
{ roomId: '!a', name: 'A', tags: { 'm.favourite': {}, 'u.': {}, 'x.y': {} } },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(sections, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('section names', () => {
|
||||||
|
it('builds the tag and validates input', () => {
|
||||||
|
assert.equal(sectionTag(' Raids '), 'u.Raids');
|
||||||
|
assert.equal(validateSectionName('Raids', []), undefined);
|
||||||
|
assert.ok(validateSectionName('', []));
|
||||||
|
assert.ok(validateSectionName('raids', ['Raids']));
|
||||||
|
assert.ok(validateSectionName('a.b', []));
|
||||||
|
assert.ok(validateSectionName('x'.repeat(41), []));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #108] User-defined sidebar sections from standard `u.<name>` room
|
||||||
|
* tags, so they sync across devices and other clients see the same tags.
|
||||||
|
*/
|
||||||
|
export const SECTION_TAG_PREFIX = 'u.';
|
||||||
|
|
||||||
|
export type RoomSection = {
|
||||||
|
/** Display name ("Raids"). */
|
||||||
|
name: string;
|
||||||
|
/** Full tag name ("u.Raids"). */
|
||||||
|
tag: string;
|
||||||
|
/** Member room ids, by the tag's `order` (ascending), then by name. */
|
||||||
|
rooms: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaggedRoom = {
|
||||||
|
roomId: string;
|
||||||
|
name: string;
|
||||||
|
tags: Record<string, { order?: number | string } | undefined> | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sectionTag = (name: string): string => `${SECTION_TAG_PREFIX}${name.trim()}`;
|
||||||
|
export const isSectionTag = (tag: string): boolean =>
|
||||||
|
tag.startsWith(SECTION_TAG_PREFIX) && tag.length > SECTION_TAG_PREFIX.length;
|
||||||
|
export const sectionName = (tag: string): string => tag.slice(SECTION_TAG_PREFIX.length);
|
||||||
|
|
||||||
|
const orderOf = (v: { order?: number | string } | undefined): number => {
|
||||||
|
const n = typeof v?.order === 'string' ? parseFloat(v.order) : v?.order;
|
||||||
|
return typeof n === 'number' && Number.isFinite(n) ? n : 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sections (alphabetical by name) with their members ordered by tag `order`
|
||||||
|
* then name; `sectioned` is the set of rooms that appear in any section.
|
||||||
|
* Empty sections simply don't exist — a section is its members' tags.
|
||||||
|
*/
|
||||||
|
export function deriveRoomSections(rooms: TaggedRoom[]): {
|
||||||
|
sections: RoomSection[];
|
||||||
|
sectioned: Set<string>;
|
||||||
|
} {
|
||||||
|
const byTag = new Map<string, { roomId: string; order: number; name: string }[]>();
|
||||||
|
const sectioned = new Set<string>();
|
||||||
|
rooms.forEach((room) => {
|
||||||
|
Object.entries(room.tags ?? {}).forEach(([tag, value]) => {
|
||||||
|
if (!isSectionTag(tag)) return;
|
||||||
|
const list = byTag.get(tag) ?? [];
|
||||||
|
list.push({ roomId: room.roomId, order: orderOf(value), name: room.name });
|
||||||
|
byTag.set(tag, list);
|
||||||
|
sectioned.add(room.roomId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const sections = [...byTag.entries()]
|
||||||
|
.map(([tag, members]) => ({
|
||||||
|
tag,
|
||||||
|
name: sectionName(tag),
|
||||||
|
rooms: members
|
||||||
|
.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
|
||||||
|
.map((m) => m.roomId),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||||
|
return { sections, sectioned };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every section name in use across the user's rooms (for the "Add to section" menu). */
|
||||||
|
export function listSectionNames(mx: MatrixClient): string[] {
|
||||||
|
const names = new Set<string>();
|
||||||
|
mx.getRooms().forEach((room) => {
|
||||||
|
Object.keys(room.tags ?? {}).forEach((tag) => {
|
||||||
|
if (isSectionTag(tag)) names.add(sectionName(tag));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return [...names].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate a new section name; returns the error to show, or undefined. */
|
||||||
|
export const validateSectionName = (raw: string, existing: string[]): string | undefined => {
|
||||||
|
const name = raw.trim();
|
||||||
|
if (!name) return 'Give the section a name.';
|
||||||
|
if (name.length > 40) return 'Keep it under 40 characters.';
|
||||||
|
if (name.includes('.')) return 'Dots are not allowed in section names.';
|
||||||
|
if (existing.some((e) => e.toLowerCase() === name.toLowerCase()))
|
||||||
|
return 'A section with that name already exists.';
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user