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:
@@ -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