feat: enhance room navigation with sub-room indicators and styling adjustments

This commit is contained in:
2026-03-15 19:23:22 +11:00
parent c980439bc9
commit 12286f57f7
5 changed files with 117 additions and 49 deletions

View File

@@ -126,3 +126,7 @@ export const NavItemContent = style({
export const NavItemOptions = style({ export const NavItemOptions = style({
paddingRight: config.space.S200, paddingRight: config.space.S200,
}); });
export const ThreadItem = style({
minHeight: toRem(24),
});

View File

@@ -23,6 +23,7 @@ import {
import { useFocusWithin, useHover } from 'react-aria'; import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import * as css from '../../components/nav/styles.css';
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';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, guessPerfectParent, getOrphanParents } from '../../utils/room'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl, guessPerfectParent, getOrphanParents } from '../../utils/room';
@@ -279,6 +280,7 @@ type RoomNavItemProps = {
notificationMode?: RoomNotificationMode; notificationMode?: RoomNotificationMode;
showAvatar?: boolean; showAvatar?: boolean;
direct?: boolean; direct?: boolean;
hideIcon?: boolean;
}; };
export function RoomNavItem({ export function RoomNavItem({
room, room,
@@ -287,6 +289,7 @@ export function RoomNavItem({
direct, direct,
notificationMode, notificationMode,
linkPath, linkPath,
hideIcon,
}: RoomNavItemProps) { }: RoomNavItemProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
@@ -360,6 +363,7 @@ export function RoomNavItem({
return ( return (
<Box direction="Column" style={{ width: '100%' }}> <Box direction="Column" style={{ width: '100%' }}>
<NavItem <NavItem
className={hideIcon ? css.ThreadItem : undefined}
variant="Background" variant="Background"
radii="400" radii="400"
highlight={unread !== undefined} highlight={unread !== undefined}
@@ -371,7 +375,8 @@ export function RoomNavItem({
> >
<NavLink to={linkPath}> <NavLink to={linkPath}>
<NavItemContent> <NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200"> <Box as="span" grow="Yes" alignItems="Center" gap={hideIcon ? "100" : "200"}>
{!hideIcon && (
<Avatar size="200" radii="400"> <Avatar size="200" radii="400">
{showAvatar ? ( {showAvatar ? (
<RoomAvatar <RoomAvatar
@@ -397,6 +402,7 @@ export function RoomNavItem({
/> />
)} )}
</Avatar> </Avatar>
)}
<Box as="span" grow="Yes" direction="Column" style={{ overflow: 'hidden', minWidth: 0 }}> <Box as="span" grow="Yes" direction="Column" style={{ overflow: 'hidden', minWidth: 0 }}>
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate> <Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{room.name} {room.name}

View File

@@ -1,6 +1,5 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { import {
Avatar,
Box, Box,
Chip, Chip,
Icon, Icon,
@@ -28,13 +27,14 @@ function getServerFromRoomId(roomId: string): string {
type UnjoinedSubRoomItemProps = { type UnjoinedSubRoomItemProps = {
roomId: string; roomId: string;
depth: number; depth: number;
isLast?: boolean;
}; };
/** /**
* Displays an unjoined sub-room with a Join button. * Displays an unjoined sub-room with a Join button.
* Used when a parent room has sub-rooms that the user hasn't joined yet. * Used when a parent room has sub-rooms that the user hasn't joined yet.
*/ */
export function UnjoinedSubRoomItem({ roomId, depth }: UnjoinedSubRoomItemProps) { export function UnjoinedSubRoomItem({ roomId, depth, isLast = false }: UnjoinedSubRoomItemProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const viaServers = [getServerFromRoomId(roomId)].filter(Boolean); const viaServers = [getServerFromRoomId(roomId)].filter(Boolean);
@@ -43,17 +43,32 @@ export function UnjoinedSubRoomItem({ roomId, depth }: UnjoinedSubRoomItemProps)
); );
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
const paddingLeft = depth > 0 ? `${depth * 24}px` : undefined; const paddingLeft = depth > 0 ? '30px' : undefined;
let treeIcon = '';
if (depth > 0) {
treeIcon = isLast ? '╰' : '├';
}
return ( return (
<Box <Box
style={{ paddingLeft, padding: `${config.space.S100} ${config.space.S200}` }} style={{ paddingLeft, padding: `${config.space.S100} ${config.space.S200}`, minHeight: '1.5rem' }}
alignItems="Center" alignItems="Center"
gap="200" gap="200"
> >
<Avatar size="200" radii="400"> {treeIcon && (
<Icon src={Icons.Hash} size="100" /> <span style={{
</Avatar> paddingRight: '2px',
opacity: 0.5,
fontFamily: 'monospace',
fontSize: '14px',
lineHeight: '1',
userSelect: 'none',
marginLeft: '-10px'
}}>
{treeIcon}
</span>
)}
<Box grow="Yes" alignItems="Center" gap="100"> <Box grow="Yes" alignItems="Center" gap="100">
<Text size="T300" truncate style={{ opacity: 0.7 }}> <Text size="T300" truncate style={{ opacity: 0.7 }}>
{roomId} {roomId}

View File

@@ -236,8 +236,8 @@ function HomeEmpty() {
} }
type RoomListItem = type RoomListItem =
| { type: 'room'; roomId: string; room: Room; depth: number; parentId?: string } | { type: 'room'; roomId: string; room: Room; depth: number; parentId?: string; isLast?: boolean }
| { type: 'unjoined-subroom'; roomId: string; depth: number; parentId: string }; | { type: 'unjoined-subroom'; roomId: string; depth: number; parentId: string; isLast?: boolean };
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room'); const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
export function Home() { export function Home() {
@@ -273,7 +273,7 @@ export function Home() {
const items: RoomListItem[] = []; const items: RoomListItem[] = [];
const processedRooms = new Set<string>(); const processedRooms = new Set<string>();
const addRoomAndSubRooms = (roomId: string, depth: number = 0, parentId?: string) => { const addRoomAndSubRooms = (roomId: string, depth: number = 0, parentId?: string, isLast: boolean = false) => {
if (processedRooms.has(roomId)) return; // Prevent infinite loops if (processedRooms.has(roomId)) return; // Prevent infinite loops
processedRooms.add(roomId); processedRooms.add(roomId);
@@ -281,19 +281,19 @@ export function Home() {
if (room) { if (room) {
// Joined room - show normally // Joined room - show normally
items.push({ type: 'room', roomId, room, depth, parentId }); items.push({ type: 'room', roomId, room, depth, parentId, isLast });
// Get sub-rooms from room state // Get sub-rooms from room state
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, ''); const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? []; const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
// Add sub-rooms recursively // Add sub-rooms recursively
subRooms.forEach(subRoomId => { subRooms.forEach((subRoomId, index) => {
addRoomAndSubRooms(subRoomId, depth + 1, roomId); addRoomAndSubRooms(subRoomId, depth + 1, roomId, index === subRooms.length - 1);
}); });
} else if (parentId) { } else if (parentId) {
// Unjoined sub-room - show with join button // Unjoined sub-room - show with join button
items.push({ type: 'unjoined-subroom', roomId, depth, parentId }); items.push({ type: 'unjoined-subroom', roomId, depth, parentId, isLast });
} }
}; };
@@ -321,7 +321,7 @@ export function Home() {
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: listItems.length, count: listItems.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
estimateSize: () => 38, estimateSize: () => 32,
overscan: 10, overscan: 10,
}); });
@@ -336,7 +336,7 @@ export function Home() {
<HomeEmpty /> <HomeEmpty />
) : ( ) : (
<PageNavContent scrollRef={scrollRef}> <PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300"> <Box direction="Column" gap="100">
<NavCategory> <NavCategory>
<NavItem variant="Background" radii="400" aria-selected={createRoomSelected}> <NavItem variant="Background" radii="400" aria-selected={createRoomSelected}>
<NavButton onClick={() => navigate(getHomeCreatePath())}> <NavButton onClick={() => navigate(getHomeCreatePath())}>
@@ -378,7 +378,9 @@ export function Home() {
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOpen={(roomIdOrAlias, viaServers, eventId) => { onOpen={(roomIdOrAlias, viaServers, eventId) => {
setOpen(false); setOpen(false);
const searchParams: _RoomSearchParams = { viaServers, eventId }; const searchParams: _RoomSearchParams = {
viaServers: viaServers?.join(',')
};
navigate( navigate(
withSearchParam( withSearchParam(
getHomeRoomPath(roomIdOrAlias), getHomeRoomPath(roomIdOrAlias),
@@ -435,13 +437,18 @@ export function Home() {
key={vItem.index} key={vItem.index}
ref={virtualizer.measureElement} ref={virtualizer.measureElement}
> >
<UnjoinedSubRoomItem roomId={item.roomId} depth={item.depth} /> <UnjoinedSubRoomItem roomId={item.roomId} depth={item.depth} isLast={item.isLast} />
</VirtualTile> </VirtualTile>
); );
} }
const selected = selectedRoomId === item.roomId; const selected = selectedRoomId === item.roomId;
const paddingLeft = item.depth > 0 ? `${item.depth * 24}px` : undefined; const paddingLeft = item.depth > 0 ? '30px' : undefined;
let treeIcon = '';
if (item.depth > 0) {
treeIcon = item.isLast ? '╰' : '├';
}
return ( return (
<VirtualTile <VirtualTile
@@ -449,10 +456,24 @@ export function Home() {
key={vItem.index} key={vItem.index}
ref={virtualizer.measureElement} ref={virtualizer.measureElement}
> >
<div style={{ paddingLeft }}> <div style={{ paddingLeft, display: 'flex', alignItems: 'center', minHeight: item.depth > 0 ? '1.5rem' : undefined }}>
{treeIcon && (
<span style={{
paddingRight: '2px',
opacity: 0.5,
fontFamily: 'monospace',
fontSize: '14px',
lineHeight: '1',
userSelect: 'none'
}}>
{treeIcon}
</span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<RoomNavItem <RoomNavItem
room={item.room} room={item.room}
selected={selected} selected={selected}
hideIcon={item.depth > 0}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, item.roomId))} linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, item.roomId))}
notificationMode={getRoomNotificationMode( notificationMode={getRoomNotificationMode(
notificationPreferences, notificationPreferences,
@@ -460,6 +481,7 @@ export function Home() {
)} )}
/> />
</div> </div>
</div>
</VirtualTile> </VirtualTile>
); );
})} })}

View File

@@ -445,8 +445,8 @@ export function Space() {
// Flatten hierarchy to include sub-rooms // Flatten hierarchy to include sub-rooms
type HierarchyItem = type HierarchyItem =
| { type: 'hierarchy'; roomId: string } | { type: 'hierarchy'; roomId: string }
| { type: 'subroom'; roomId: string; room: Room; depth: number } | { type: 'subroom'; roomId: string; room: Room; depth: number; isLast?: boolean }
| { type: 'unjoined-subroom'; roomId: string; depth: number }; | { type: 'unjoined-subroom'; roomId: string; depth: number; isLast?: boolean };
const flattenedHierarchy = useMemo(() => { const flattenedHierarchy = useMemo(() => {
const items: HierarchyItem[] = []; const items: HierarchyItem[] = [];
@@ -459,19 +459,20 @@ export function Space() {
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, ''); const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? []; const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
subRooms.forEach(subRoomId => { subRooms.forEach((subRoomId, index) => {
if (processedSubRooms.has(subRoomId)) return; if (processedSubRooms.has(subRoomId)) return;
processedSubRooms.add(subRoomId); processedSubRooms.add(subRoomId);
const isLast = index === subRooms.length - 1;
const subRoom = mx.getRoom(subRoomId); const subRoom = mx.getRoom(subRoomId);
if (subRoom) { if (subRoom) {
// Joined sub-room // Joined sub-room
items.push({ type: 'subroom', roomId: subRoomId, room: subRoom, depth: baseDepth + 1 }); items.push({ type: 'subroom', roomId: subRoomId, room: subRoom, depth: baseDepth + 1, isLast });
// Recursively add sub-rooms of sub-rooms // Recursively add sub-rooms of sub-rooms
addSubRooms(subRoomId, baseDepth + 1); addSubRooms(subRoomId, baseDepth + 1);
} else { } else {
// Unjoined sub-room - show with join button // Unjoined sub-room - show with join button
items.push({ type: 'unjoined-subroom', roomId: subRoomId, depth: baseDepth + 1 }); items.push({ type: 'unjoined-subroom', roomId: subRoomId, depth: baseDepth + 1, isLast });
} }
}); });
}; };
@@ -562,27 +563,47 @@ export function Space() {
if (item.type === 'unjoined-subroom') { if (item.type === 'unjoined-subroom') {
return ( return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}> <VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
<UnjoinedSubRoomItem roomId={item.roomId} depth={item.depth} /> <UnjoinedSubRoomItem roomId={item.roomId} depth={item.depth} isLast={item.isLast} />
</VirtualTile> </VirtualTile>
); );
} }
// Sub-room item (nested under parent) // Sub-room item (nested under parent)
if (item.type === 'subroom') { if (item.type === 'subroom') {
const paddingLeft = `${item.depth * 24}px`; const paddingLeft = '30px';
let treeIcon = '';
if (item.depth > 0) {
treeIcon = item.isLast ? '╰' : '├';
}
return ( return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}> <VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
<div style={{ paddingLeft }}> <div style={{ paddingLeft, display: 'flex', alignItems: 'center', minHeight: '1.5rem' }}>
{treeIcon && (
<span style={{
paddingRight: '2px',
opacity: 0.5,
fontFamily: 'monospace',
fontSize: '14px',
lineHeight: '1',
userSelect: 'none'
}}>
{treeIcon}
</span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<RoomNavItem <RoomNavItem
room={item.room} room={item.room}
selected={selectedRoomId === item.roomId} selected={selectedRoomId === item.roomId}
hideIcon={item.depth > 0}
showAvatar={mDirects.has(item.roomId)} showAvatar={mDirects.has(item.roomId)}
direct={mDirects.has(item.roomId)} direct={mDirects.has(item.roomId)}
linkPath={getToLink(item.roomId)} linkPath={getToLink(item.roomId)}
notificationMode={getRoomNotificationMode(notificationPreferences, item.room.roomId)} notificationMode={getRoomNotificationMode(notificationPreferences, item.room.roomId)}
/> />
</div> </div>
</div>
</VirtualTile> </VirtualTile>
); );
} }