feat: add thread functionality to room features
- Enhance RoomInput to support sending messages as replies in threads by adding `threadRootId` prop. - Update RoomTimeline to manage active thread state and handle opening threads. - Implement ThreadView component to display thread messages and input. - Create HomeThreadsCategory to list threads user has participated in. - Introduce activeThreadIdAtomFamily to manage active thread state across rooms.
This commit is contained in:
@@ -67,6 +67,7 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
||||
import { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt';
|
||||
import { _RoomSearchParams } from '../../paths';
|
||||
import { HomeThreadsCategory } from './HomeThreadsCategory';
|
||||
|
||||
type HomeMenuProps = {
|
||||
requestClose: () => void;
|
||||
@@ -395,6 +396,7 @@ export function Home() {
|
||||
})}
|
||||
</div>
|
||||
</NavCategory>
|
||||
<HomeThreadsCategory rooms={rooms} />
|
||||
</Box>
|
||||
</PageNavContent>
|
||||
)}
|
||||
|
||||
184
src/app/pages/client/home/HomeThreadsCategory.tsx
Normal file
184
src/app/pages/client/home/HomeThreadsCategory.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Icon,
|
||||
Icons,
|
||||
Text,
|
||||
Badge,
|
||||
} from 'folds';
|
||||
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>
|
||||
<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) &&
|
||||
threads.map(({ room, thread }) => (
|
||||
<ThreadNavItem
|
||||
key={`${room.roomId}:${thread.id}`}
|
||||
room={room}
|
||||
thread={thread}
|
||||
selected={false}
|
||||
/>
|
||||
))}
|
||||
</NavCategory>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user