diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index f7bacd69c..8b56abae3 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -23,6 +23,7 @@ import { Overlay, OverlayBackdrop, OverlayCenter, + color, config, PopOut, toRem, @@ -37,6 +38,13 @@ import { useFocusWithin, useHover } from 'react-aria'; import FocusTrap from 'focus-trap-react'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { selectAtom } from 'jotai/utils'; +import { + isSectionTag, + listSectionNames, + sectionName, + sectionTag, + validateSectionName, +} from '../../utils/roomSections'; import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; @@ -277,6 +285,14 @@ const RoomNavItemMenu = forwardRef( const [invitePrompt, setInvitePrompt] = useState(false); const [muteMenuAnchor, setMuteMenuAnchor] = useState(); + // [Gitea #108] "Add to section" submenu: existing u.* sections + new one. + const [sectionMenuAnchor, setSectionMenuAnchor] = useState(); + const [newSectionName, setNewSectionName] = useState(''); + const [newSectionError, setNewSectionError] = useState(); + const sectionNames = useMemo(() => listSectionNames(mx), [mx]); + const roomSections = Object.keys(room.tags ?? {}) + .filter(isSectionTag) + .map(sectionName); const isServerNotice = room.getType() === 'm.server_notice'; const isFavorite = !!room.tags?.['m.favourite']; @@ -316,6 +332,25 @@ const RoomNavItemMenu = forwardRef( 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 handleMarkAsRead = () => { @@ -498,6 +533,97 @@ const RoomNavItemMenu = forwardRef( {isLowPriority ? 'Remove from Low Priority' : 'Add to Low Priority'} + setSectionMenuAnchor(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + {sectionNames.map((name) => { + const inSection = roomSections.includes(name); + return ( + : undefined} + onClick={() => handleToggleSection(name)} + > + + {name} + + + ); + })} + {sectionNames.length > 0 && } + + { + setNewSectionName(e.currentTarget.value); + setNewSectionError(undefined); + }} + after={ + + + + } + /> + {newSectionError && ( + + {newSectionError} + + )} + + + + + } + > + } + radii="300" + aria-pressed={!!sectionMenuAnchor} + aria-haspopup="menu" + onClick={(e) => setSectionMenuAnchor(e.currentTarget.getBoundingClientRect())} + > + + {roomSections.length > 0 + ? `Sections: ${roomSections.join(', ')}` + : 'Add to Section'} + + + 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; + filterQuery: string; + selectedRoomId?: string; + roomsWithUnreadSet: Set; + 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 ( + + + + {section.name} + + + {items.map((roomId) => { + const room = mx.getRoom(roomId); + if (!room) return null; + return ( + + ); + })} + + ); +} + 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() { )} + {customSections.map((section) => ( + + ))} { + 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), [])); + }); +}); diff --git a/src/app/utils/roomSections.ts b/src/app/utils/roomSections.ts new file mode 100644 index 000000000..49356cdf6 --- /dev/null +++ b/src/app/utils/roomSections.ts @@ -0,0 +1,86 @@ +import { MatrixClient } from 'matrix-js-sdk'; + +/** + * [Gitea #108] User-defined sidebar sections from standard `u.` 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 | 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; +} { + const byTag = new Map(); + const sectioned = new Set(); + 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(); + 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; +};