Files
cinny/src/app/pages/client/home/HomeThreadsCategory.tsx
litruv 61d41900cc Add Stationery and Stationery Dark themes with notebook chrome.
Manila folders, ruled chat, post-it nav, and folder tabs; dark variant uses Catppuccin Mocha colors with muted stickies and soft glows.
2026-07-23 00:24:10 +10:00

182 lines
6.1 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Avatar, Box, Text, Badge } from 'folds';
import { Icon, Icons } from '../../../components/icons';
import { useAtom, useSetAtom } from 'jotai';
import { Room, Thread, ThreadEvent } from 'matrix-js-sdk';
import { NavCategory, NavCategoryHeader, NavItem, NavItemContent, NavButton } from '../../../components/nav';
import { RoomNavCategoryButton } from '../../../features/room-nav';
import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
import { activeThreadIdAtomFamily } from '../../../state/activeThread';
import { getCanonicalAliasOrRoomId, getMxIdLocalPart } from '../../../utils/matrix';
import { getHomeRoomPath } from '../../pathUtils';
import { getMemberDisplayName } from '../../../utils/room';
const THREADS_CATEGORY_ID = makeNavCategoryId('home', 'threads');
type ThreadEntry = {
room: Room;
thread: Thread;
};
type ThreadNavItemProps = {
room: Room;
thread: Thread;
selected: boolean;
};
/**
* A single nav item representing a thread the user has participated in.
* Navigates to the room and opens the thread when clicked.
*/
function ThreadNavItem({ room, thread, selected }: ThreadNavItemProps) {
const navigate = useNavigate();
const setActiveThreadId = useSetAtom(activeThreadIdAtomFamily(room.roomId));
const mx = useMatrixClient();
const { rootEvent } = thread;
const senderId = rootEvent?.getSender() ?? '';
const senderName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const rootBody = rootEvent?.getContent()?.body ?? '';
const replyCount = thread.length;
const handleClick = useCallback(() => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
navigate(getHomeRoomPath(roomIdOrAlias));
// Defer slightly so RoomView has time to render before we set the atom.
requestAnimationFrame(() => {
// eslint-disable-next-line no-console
console.log('[HomeThreadsCategory] Opening thread', {
threadId: thread.id,
rootEventId: thread.rootEvent?.getId(),
roomId: room.roomId,
});
// Use rootEvent ID if available, fallback to thread.id
const threadIdToUse = thread.rootEvent?.getId() || thread.id;
setActiveThreadId(threadIdToUse);
});
}, [navigate, mx, room.roomId, setActiveThreadId, thread.id, thread.rootEvent]);
return (
<NavItem variant="Background" radii="400" aria-selected={selected}>
<NavButton onClick={handleClick}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Thread} size="100" />
</Avatar>
<Box as="span" grow="Yes" direction="Column" style={{ overflow: 'hidden', minWidth: 0 }}>
<Text as="span" size="Inherit" truncate>
{senderName}
</Text>
{rootBody && (
<Text as="span" size="T200" priority="300" truncate>
{rootBody}
</Text>
)}
</Box>
{replyCount > 0 && (
<Badge variant="Secondary" size="400" fill="Soft" radii="Pill">
<Text as="span" size="L400">
{replyCount}
</Text>
</Badge>
)}
</Box>
</NavItemContent>
</NavButton>
</NavItem>
);
}
type HomeThreadsCategoryProps = {
rooms: string[];
};
/**
* Nav category for the left sidebar showing threads in Home rooms where the
* current user has participated.
*/
export function HomeThreadsCategory({ rooms }: HomeThreadsCategoryProps) {
const mx = useMatrixClient();
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const getParticipatedThreads = useCallback((): ThreadEntry[] =>
rooms.reduce<ThreadEntry[]>((acc, roomId) => {
const room = mx.getRoom(roomId);
if (!room) return acc;
return acc.concat(
room
.getThreads()
.filter((t) => t.hasCurrentUserParticipated)
.map((t) => ({ room, thread: t }))
);
}, []),
[mx, rooms]);
const [threads, setThreads] = useState<ThreadEntry[]>(getParticipatedThreads);
// Re-compute when thread events fire in any home room.
useEffect(() => {
const handleUpdate = () => setThreads(getParticipatedThreads());
const roomObjects = rooms.reduce<Room[]>((acc, roomId) => {
const room = mx.getRoom(roomId);
if (room) acc.push(room);
return acc;
}, []);
roomObjects.forEach((room) => {
room.getThreads().forEach((thread) => {
thread.on(ThreadEvent.Update, handleUpdate);
thread.on(ThreadEvent.NewReply, handleUpdate);
});
});
return () => {
roomObjects.forEach((room) => {
room.getThreads().forEach((thread) => {
thread.off(ThreadEvent.Update, handleUpdate);
thread.off(ThreadEvent.NewReply, handleUpdate);
});
});
};
}, [mx, rooms, getParticipatedThreads]);
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
if (threads.length === 0) return null;
return (
<NavCategory data-nav-dropdown="">
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(THREADS_CATEGORY_ID)}
data-category-id={THREADS_CATEGORY_ID}
onClick={handleCategoryClick}
>
My Threads
</RoomNavCategoryButton>
</NavCategoryHeader>
{!closedCategories.has(THREADS_CATEGORY_ID) && (
<div data-nav-rooms="">
{threads.map(({ room, thread }) => (
<ThreadNavItem
key={`${room.roomId}:${thread.id}`}
room={room}
thread={thread}
selected={false}
/>
))}
</div>
)}
</NavCategory>
);
}