+
) : (
-
-
-
-
- {topicSelected
- ? 'Select a post to read the thread'
- : 'Pick a category and topic, then choose a post'}
-
-
-
+
+
+
+
+
+ {topicSelected
+ ? 'Select a post to read the thread'
+ : 'Pick a category and topic, then choose a post'}
+
+
+
+
)}
diff --git a/src/app/features/forum/ForumThreadDetail.tsx b/src/app/features/forum/ForumThreadDetail.tsx
index 8a0c343..adc2295 100644
--- a/src/app/features/forum/ForumThreadDetail.tsx
+++ b/src/app/features/forum/ForumThreadDetail.tsx
@@ -1,86 +1,281 @@
// @refresh reset
-import React, { useEffect, useState } from 'react';
-import { Box, Spinner, Text } from 'folds';
-import { Room } from 'matrix-js-sdk';
+import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode, type RefObject } from 'react';
+import { useAtom, useAtomValue } from 'jotai';
+import { Box, Scroll, Spinner, Text, config } from 'folds';
+import { RelationType, Room } from 'matrix-js-sdk';
+import { ReactEditor } from 'slate-react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { usePowerLevels } from '../../hooks/usePowerLevels';
+import { useRoomPermissions } from '../../hooks/useRoomPermissions';
+import { useRoomCreators } from '../../hooks/useRoomCreators';
+import { useImagePackRooms } from '../../hooks/useImagePackRooms';
+import { roomToParentsAtom } from '../../state/room/roomToParents';
+import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
+import { canEditEvent, getEditedEvent } from '../../utils/room';
+import { MessageEditor } from '../room/message/MessageEditor';
+import { Reactions } from '../room/message';
import { formatForumTimeAgo } from './forumTime';
-import { loadPostThread } from './forumFeed';
-import { appendReplyToThread } from './forumTopicHelpers';
+import { loadPostThread, loadPostThreadLocal } from './forumFeed';
+import { countAllReplies } from './forumTopicHelpers';
import type { ForumPost } from './types';
import { ForumAuthorIdentity } from './ForumAuthorIdentity';
-import { ForumMessageBody } from './ForumMessageBody';
+import { ForumEventContent } from './ForumEventContent';
+import { ForumMessageContextMenu } from './ForumMessageContextMenu';
import { ForumMessageActions } from './ForumMessageActions';
-import { ForumRichReplyComposer } from './ForumRichReplyComposer';
+import { ForumReplyComposer } from './ForumReplyComposer';
+import { useForumMessageReactions } from './useForumMessageReactions';
+import { useForumRoomLiveUpdates } from './useForumRoomLiveUpdates';
+import { resetEditor, resetEditorHistory, useEditor } from '../../components/editor';
+import { mobileOrTablet } from '../../utils/user-agent';
import * as theme from './forumTheme.css';
-function ForumThreadReply({
- reply,
- room,
- threadRootId,
- roomId,
- depth,
- onReplySent,
-}: {
- reply: ForumPost;
- room: Room | null;
- threadRootId: string;
- roomId: string;
- depth: number;
- onReplySent: (reply: ForumPost) => void;
-}) {
- const [replyOpen, setReplyOpen] = useState(false);
+function forumBranchKey(eventId: string): string {
+ return `branch:${eventId}`;
+}
+
+function forumMessageSurfaceClass(
+ baseClass: string,
+ eventId: string,
+ replyTargetEventId?: string
+): string {
+ if (replyTargetEventId && replyTargetEventId === eventId) {
+ return `${baseClass} ${theme.ForumThreadReplyTarget}`;
+ }
+ return baseClass;
+}
+
+type ForumThreadBranchProps = {
+ className: string;
+ railClassName: string;
+ collapsed: boolean;
+ hiddenCount: number;
+ onToggle: () => void;
+ children: ReactNode;
+};
+
+function morePostsLabel(count: number): string {
+ return `${count} more ${count === 1 ? 'post' : 'posts'}`;
+}
+
+function ForumThreadBranch({
+ className,
+ railClassName,
+ collapsed,
+ hiddenCount,
+ onToggle,
+ children,
+}: ForumThreadBranchProps) {
+ const isRootBranch = className === theme.ForumThread;
+
+ const handleToggle = (event: MouseEvent) => {
+ event.stopPropagation();
+ onToggle();
+ };
+
+ if (collapsed && hiddenCount > 0) {
+ return (
+
+ );
+ }
return (
-
-
+
+
+ {children}
+
+ );
+}
+
+type ForumThreadMessageProps = {
+ post: ForumPost;
+ room: Room;
+ roomId: string;
+ threadRootId: string;
+ fileDropContainerRef: RefObject
;
+ editEventId?: string;
+ onEditEventId: (eventId?: string) => void;
+ onComposerSent: () => void;
+ onDeleted: () => void;
+ onReplyTo: (eventId: string) => void;
+ replyTargetEventId?: string;
+ collapsedBranches: Set;
+ onToggleBranch: (branchKey: string) => void;
+};
+
+function ForumThreadMessage({
+ post,
+ room,
+ roomId,
+ threadRootId,
+ fileDropContainerRef,
+ editEventId,
+ onEditEventId,
+ onComposerSent,
+ onDeleted,
+ onReplyTo,
+ replyTargetEventId,
+ collapsedBranches,
+ onToggleBranch,
+}: ForumThreadMessageProps) {
+ const mx = useMatrixClient();
+ const mEvent = room.findEventById(post.eventId);
+ const isEditing = editEventId === post.eventId;
+ const branchKey = forumBranchKey(post.eventId);
+ const replyCount = countAllReplies(post.replies);
+ const repliesCollapsed = collapsedBranches.has(branchKey);
+ const powerLevels = usePowerLevels(room);
+ const creators = useRoomCreators(room);
+ const permissions = useRoomPermissions(creators, powerLevels);
+ const roomToParents = useAtomValue(roomToParentsAtom);
+ const imagePackRooms = useImagePackRooms(roomId, roomToParents);
+
+ const canEdit = mEvent ? canEditEvent(mx, mEvent) : false;
+ const canDelete = mEvent
+ ? permissions.action('redact', mx.getSafeUserId()) || mEvent.getSender() === mx.getUserId()
+ : false;
+ const { canSendReaction, reactionRelations, handleReactionToggle } = useForumMessageReactions(
+ room,
+ mEvent
+ );
+
+ const handleDelete = useCallback(() => {
+ if (!mEvent) return;
+ const eventId = mEvent.getId();
+ if (!eventId) return;
+ mx.redactEvent(roomId, eventId).then(onDeleted);
+ }, [mEvent, mx, onDeleted, roomId]);
+
+ const handleEditClose = useCallback(() => {
+ onEditEventId(undefined);
+ onComposerSent();
+ }, [onComposerSent, onEditEventId]);
+
+ return (
+
+ onReplyTo(post.eventId)}
+ onEdit={() => onEditEventId(post.eventId)}
+ onDeleted={onDeleted}
+ className={forumMessageSurfaceClass(theme.ForumThreadReplyInner, post.eventId, replyTargetEventId)}
+ >
- {reply.replyToDeleted && (
+ {post.replyToDeleted && (
Replying to a deleted message
)}
-
- {!replyOpen && (
-
- setReplyOpen(true)} />
-
- )}
- {replyOpen && (
-
- {
- setReplyOpen(false);
- onReplySent(sentReply);
- }}
- onCancel={() => setReplyOpen(false)}
- />
-
- )}
-
- {reply.replies.length > 0 && (
-
- {reply.replies.map((child) => (
-
+ ) : (
+ <>
+
- ))}
-
+ {reactionRelations && (
+
+ )}
+ >
+ )}
+ {!isEditing && (
+
+ onReplyTo(post.eventId)}
+ replyActive={replyTargetEventId === post.eventId}
+ onEdit={() => onEditEventId(post.eventId)}
+ canEdit={canEdit}
+ onDelete={handleDelete}
+ canDelete={canDelete}
+ />
+
+ )}
+
+ {post.replies.length > 0 && (
+ onToggleBranch(branchKey)}
+ >
+ {!repliesCollapsed &&
+ post.replies.map((child) => (
+
+ ))}
+
)}
);
@@ -91,31 +286,324 @@ type ForumThreadDetailProps = {
rootEventId: string;
titleHint?: string;
initialPost?: ForumPost | null;
+ fileDropContainerRef?: RefObject
;
onReplySent?: (reply: ForumPost) => void;
};
+type ForumThreadDetailBodyProps = ForumThreadDetailProps & {
+ post: ForumPost;
+ room: Room;
+ onPostUpdate: (post: ForumPost | null) => void;
+};
+
+function ForumThreadDetailBody({
+ roomId,
+ rootEventId,
+ titleHint,
+ post,
+ room,
+ fileDropContainerRef,
+ onReplySent,
+ onPostUpdate,
+}: ForumThreadDetailBodyProps) {
+ const mx = useMatrixClient();
+ const editor = useEditor();
+ const [editEventId, setEditEventId] = useState();
+ const [replyToEventId, setReplyToEventId] = useState(rootEventId);
+ const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
+ const timelineSet = useMemo(() => room.getUnfilteredTimelineSet(), [room]);
+
+ useEffect(
+ () => () => {
+ resetEditor(editor);
+ resetEditorHistory(editor);
+ },
+ [editor]
+ );
+
+ useEffect(() => {
+ setReplyToEventId(rootEventId);
+ setReplyDraft(undefined);
+ }, [rootEventId, setReplyDraft]);
+
+ useEffect(() => {
+ if (!replyDraft) {
+ setReplyToEventId(rootEventId);
+ }
+ }, [replyDraft, rootEventId]);
+ const [contentRevision, setContentRevision] = useState(0);
+ const [collapsedBranches, setCollapsedBranches] = useState>(() => new Set());
+ const localDropRef = useRef(null);
+ const dropRef = fileDropContainerRef ?? localDropRef;
+ const powerLevels = usePowerLevels(room);
+ const creators = useRoomCreators(room);
+ const permissions = useRoomPermissions(creators, powerLevels);
+ const roomToParents = useAtomValue(roomToParentsAtom);
+ const imagePackRooms = useImagePackRooms(roomId, roomToParents);
+
+ const reloadThread = useCallback(async () => {
+ const local = loadPostThreadLocal(mx, roomId, rootEventId);
+ if (local) onPostUpdate(local);
+
+ const loaded = await loadPostThread(mx, roomId, rootEventId);
+ onPostUpdate(loaded);
+ return loaded;
+ }, [mx, onPostUpdate, roomId, rootEventId]);
+
+ const handleComposerSent = useCallback(async () => {
+ const applyLoaded = (loaded: ForumPost | null) => {
+ if (!loaded) return;
+ onReplySent?.({
+ eventId: loaded.eventId,
+ title: loaded.title,
+ body: loaded.body,
+ sender: loaded.sender,
+ timestamp: Date.now(),
+ replies: loaded.replies,
+ });
+ };
+
+ const loaded = await reloadThread();
+ applyLoaded(loaded);
+
+ // Local echo can land slightly after send; refresh once more.
+ window.setTimeout(() => {
+ void reloadThread().then(applyLoaded);
+ }, 400);
+ }, [onReplySent, reloadThread]);
+
+ const handleDeleted = useCallback(() => {
+ void handleComposerSent();
+ }, [handleComposerSent]);
+
+ useForumRoomLiveUpdates(
+ [room],
+ () => {
+ setContentRevision((revision) => revision + 1);
+ void reloadThread();
+ },
+ { threadRootId: rootEventId, wait: 150 }
+ );
+
+ const rootEvent = room.findEventById(rootEventId);
+ const rootEditing = editEventId === rootEventId;
+ const canEditRoot = rootEvent ? canEditEvent(mx, rootEvent) : false;
+ const canDeleteRoot = rootEvent
+ ? permissions.action('redact', mx.getSafeUserId()) || rootEvent.getSender() === mx.getUserId()
+ : false;
+ const { canSendReaction, reactionRelations, handleReactionToggle } = useForumMessageReactions(
+ room,
+ rootEvent
+ );
+
+ const handleDeleteRoot = useCallback(() => {
+ if (!rootEvent) return;
+ const eventId = rootEvent.getId();
+ if (!eventId) return;
+ mx.redactEvent(roomId, eventId).then(() => {
+ void handleComposerSent();
+ });
+ }, [handleComposerSent, mx, rootEvent, roomId]);
+
+ const handleEditRootClose = useCallback(() => {
+ setEditEventId(undefined);
+ setContentRevision((revision) => revision + 1);
+ void handleComposerSent();
+ }, [handleComposerSent]);
+
+ const toggleBranch = useCallback((branchKey: string) => {
+ setCollapsedBranches((prev) => {
+ const next = new Set(prev);
+ if (next.has(branchKey)) next.delete(branchKey);
+ else next.add(branchKey);
+ return next;
+ });
+ }, []);
+
+ const handleReplyTo = useCallback(
+ (eventId: string) => {
+ const replyEvt = room.findEventById(eventId);
+ if (!replyEvt) return;
+
+ const editedReply = getEditedEvent(eventId, replyEvt, timelineSet);
+ const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
+ const { body, formatted_body: formattedBody } = content;
+ const senderId = replyEvt.getSender();
+
+ setReplyToEventId(eventId);
+ if (senderId && typeof body === 'string') {
+ setReplyDraft({
+ userId: senderId,
+ eventId,
+ body,
+ formattedBody,
+ relation: { rel_type: RelationType.Thread, event_id: rootEventId },
+ });
+ }
+
+ window.setTimeout(() => {
+ const target = document.querySelector(`[data-event-id="${eventId}"]`);
+ target?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ if (!mobileOrTablet()) ReactEditor.focus(editor);
+ }, 0);
+ },
+ [editor, rootEventId, room, setReplyDraft, timelineSet]
+ );
+
+ const rootBranchKey = forumBranchKey(rootEventId);
+ const rootReplyCount = countAllReplies(post.replies);
+ const rootRepliesCollapsed = collapsedBranches.has(rootBranchKey);
+
+ const replyTargetEventId = replyDraft?.eventId;
+
+ const title = useMemo(() => {
+ const local = loadPostThreadLocal(mx, roomId, rootEventId);
+ return local?.title || post.title || titleHint || '(untitled post)';
+ }, [contentRevision, mx, post.title, roomId, rootEventId, titleHint]);
+
+ return (
+
+
+
+
+
handleReplyTo(post.eventId)}
+ onEdit={() => setEditEventId(rootEventId)}
+ onDeleted={handleDeleted}
+ className={forumMessageSurfaceClass(theme.ForumTopicDetailOp, post.eventId, replyTargetEventId)}
+ >
+ {rootEditing && rootEvent ? (
+
+ ) : (
+ <>
+
+ {reactionRelations && (
+
+ )}
+ >
+ )}
+ {!rootEditing && (
+
+ handleReplyTo(post.eventId)}
+ replyActive={replyTargetEventId === post.eventId}
+ onEdit={() => setEditEventId(rootEventId)}
+ canEdit={canEditRoot}
+ onDelete={handleDeleteRoot}
+ canDelete={canDeleteRoot}
+ />
+
+ )}
+
+ {post.replies.length > 0 && (
+
toggleBranch(rootBranchKey)}
+ >
+ {!rootRepliesCollapsed &&
+ post.replies.map((reply) => (
+ {
+ void handleComposerSent();
+ }}
+ onDeleted={handleDeleted}
+ onReplyTo={handleReplyTo}
+ replyTargetEventId={replyTargetEventId}
+ collapsedBranches={collapsedBranches}
+ onToggleBranch={toggleBranch}
+ />
+ ))}
+
+ )}
+
+
+
+ {
+ void handleComposerSent();
+ }}
+ />
+
+
+ );
+}
+
export function ForumThreadDetail({
roomId,
rootEventId,
titleHint,
initialPost,
+ fileDropContainerRef,
onReplySent,
}: ForumThreadDetailProps) {
const mx = useMatrixClient();
const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const [rootReplyOpen, setRootReplyOpen] = useState(false);
const room = mx.getRoom(roomId);
- const handleReplySent = (reply: ForumPost) => {
- setPost((current) => {
- if (!current || !reply.replyToEventId) return current;
- return appendReplyToThread(current, reply.replyToEventId, reply);
- });
- onReplySent?.(reply);
- };
-
useEffect(() => {
let cancelled = false;
@@ -160,7 +648,7 @@ export function ForumThreadDetail({
);
}
- if (error || !post) {
+ if (error || !post || !room) {
return (
{error || 'Post not found.'}
@@ -168,56 +656,16 @@ export function ForumThreadDetail({
);
}
- const title = post.title || titleHint || '(untitled post)';
-
return (
-
-
-
-
- {!rootReplyOpen && (
-
- setRootReplyOpen(true)} />
-
- )}
- {rootReplyOpen && (
-
- {
- setRootReplyOpen(false);
- handleReplySent(sentReply);
- }}
- onCancel={() => setRootReplyOpen(false)}
- />
-
- )}
-
- {post.replies.length > 0 && (
-
- {post.replies.map((reply) => (
-
- ))}
-
- )}
-
+
);
}
diff --git a/src/app/features/forum/ForumTopicSearchInput.tsx b/src/app/features/forum/ForumTopicSearchInput.tsx
index 0ebaeb9..0f274b0 100644
--- a/src/app/features/forum/ForumTopicSearchInput.tsx
+++ b/src/app/features/forum/ForumTopicSearchInput.tsx
@@ -1,6 +1,7 @@
import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
-import { Icon, IconButton, Icons } from 'folds';
+import { IconButton } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import {
TOPIC_SEARCH_QUALIFIERS,
collectAuthorHintsFromThreads,
diff --git a/src/app/features/forum/forumFeed.ts b/src/app/features/forum/forumFeed.ts
index 15f7989..ea5a74f 100644
--- a/src/app/features/forum/forumFeed.ts
+++ b/src/app/features/forum/forumFeed.ts
@@ -1,16 +1,19 @@
import {
Direction,
EventType,
+ type IContent,
type IEventRelation,
type IRoomEvent,
type MatrixClient,
+ type MatrixEvent,
+ type Room,
RelationType,
} from 'matrix-js-sdk';
import type { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { getMemberDisplayName, getSpaceChildren, isSpace } from '../../utils/room';
import type { ForumPost, ForumTopic } from './types';
import { annotatePostsWithRoomScope } from './forumTopicHelpers';
-import { bodyHtmlFromMessageContent } from './forumRichText';
+import { bodyHtmlFromMessageContent, titleFromFormattedBody } from './forumRichText';
type RawRelation = {
rel_type?: string;
@@ -24,6 +27,80 @@ function threadRelationFromContent(content: Record): RawRelatio
return relates as RawRelation;
}
+function isAnnotationRoomMessage(content: Record): boolean {
+ const relation = threadRelationFromContent(content);
+ if (!relation?.rel_type) return false;
+ return (
+ relation.rel_type === RelationType.Replace ||
+ relation.rel_type === 'm.replace' ||
+ relation.rel_type === RelationType.Annotation ||
+ relation.rel_type === 'm.annotation'
+ );
+}
+
+function isForumRootContent(content: Record): boolean {
+ if (typeof content['com.matrixsso.title'] === 'string') return true;
+ const formatted = content.formatted_body;
+ return typeof formatted === 'string' && /]/i.test(formatted);
+}
+
+function syncForumRootContentAfterEdit(
+ merged: Record,
+ baseContent: Record
+): Record {
+ if (!isForumRootContent(baseContent)) return merged;
+
+ const formattedTitle =
+ typeof merged.formatted_body === 'string'
+ ? titleFromFormattedBody(merged.formatted_body)
+ : undefined;
+ const { title, body } = postTitleFromContent({
+ ...merged,
+ // Ignore stale title so edits can rename the post.
+ 'com.matrixsso.title': formattedTitle ?? undefined,
+ });
+
+ const next: Record = {
+ ...merged,
+ 'com.matrixsso.title': title,
+ };
+
+ if (typeof merged.body === 'string') {
+ const plain = merged.body.trim();
+ if (body && plain !== body && !plain.startsWith(`${title}\n`)) {
+ next.body = `${title}\n\n${body}`;
+ }
+ }
+
+ return next;
+}
+
+function getLatestEditContent(
+ event: IRoomEvent,
+ timelineEvents: IRoomEvent[]
+): Record {
+ const baseContent = event.content as Record;
+ const targetId = event.event_id;
+ if (!targetId) return baseContent;
+
+ const edits = timelineEvents
+ .filter((candidate) => {
+ if (candidate.type !== EventType.RoomMessage || !candidate.content) return false;
+ const rel = threadRelationFromContent(candidate.content as Record);
+ return rel?.rel_type === RelationType.Replace && rel.event_id === targetId;
+ })
+ .sort((a, b) => (b.origin_server_ts || 0) - (a.origin_server_ts || 0));
+
+ const latestEdit = edits[0];
+ if (!latestEdit?.content) return baseContent;
+
+ const newContent = (latestEdit.content as Record)['m.new_content'];
+ if (!newContent || typeof newContent !== 'object') return baseContent;
+
+ const merged = { ...baseContent, ...(newContent as Record) };
+ return syncForumRootContentAfterEdit(merged, baseContent);
+}
+
function parseBundledThreadReplyCount(event: IRoomEvent): number | undefined {
const relations = event.unsigned?.['m.relations'] as Record | undefined;
const thread = relations?.['m.thread'] as { count?: unknown } | undefined;
@@ -38,8 +115,12 @@ function postTitleFromContent(content: Record): { title: string
typeof content['com.matrixsso.title'] === 'string'
? String(content['com.matrixsso.title']).trim()
: '';
+ const htmlTitle =
+ typeof content.formatted_body === 'string'
+ ? titleFromFormattedBody(content.formatted_body)
+ : undefined;
const firstLine = rawBody.split(/\r?\n/, 1)[0]?.trim() ?? '';
- const title = (customTitle || firstLine || '(untitled post)').slice(0, 160);
+ const title = (customTitle || htmlTitle || firstLine || '(untitled post)').slice(0, 160);
let body = rawBody;
if (customTitle && rawBody.startsWith(customTitle)) {
@@ -56,6 +137,11 @@ function postTitleFromContent(content: Record): { title: string
return { title, body: body || rawBody };
}
+/** Title + body for a forum matrix message payload (including edits). */
+export function forumPostTitleFromContent(content: Record): string {
+ return postTitleFromContent(content).title;
+}
+
function sortRepliesRecursively(posts: ForumPost[]) {
posts.sort((a, b) => a.timestamp - b.timestamp);
for (const post of posts) {
@@ -77,7 +163,10 @@ function buildPosts(mx: MatrixClient, roomId: string, timelineEvents: IRoomEvent
!redactedIds.has(event.event_id)
)
.map((event) => {
- const content = event.content as Record;
+ const rawContent = event.content as Record;
+ if (isAnnotationRoomMessage(rawContent)) return null;
+
+ const content = getLatestEditContent(event, timelineEvents);
const relation = threadRelationFromContent(content);
const isThreadReply =
relation?.rel_type === RelationType.Thread || relation?.rel_type === 'm.thread';
@@ -272,6 +361,9 @@ export async function listTopicFeedPosts(
if (!nextBatch || merged.length >= minRoots) break;
}
+ const localPosts = buildPosts(mx, roomId, collectLocalRootPosts(mx, roomId));
+ merged = mergeRootPosts(merged, localPosts);
+
return { posts: merged, nextBatch: nextBatch ?? null };
}
@@ -283,11 +375,164 @@ function mergeRootPosts(existing: ForumPost[], incoming: ForumPost[]): ForumPost
byId.set(post.eventId, post);
continue;
}
- previous.replies = [...previous.replies, ...post.replies];
+ const replyIds = new Set(previous.replies.map((reply) => reply.eventId));
+ const mergedReplies = [
+ ...previous.replies,
+ ...post.replies.filter((reply) => !replyIds.has(reply.eventId)),
+ ];
+ byId.set(post.eventId, { ...post, replies: mergedReplies });
}
return [...byId.values()].sort((a, b) => b.timestamp - a.timestamp);
}
+function mergeUniqueRoomEvents(...lists: IRoomEvent[][]): IRoomEvent[] {
+ const seen = new Set();
+ const unique: IRoomEvent[] = [];
+ for (const list of lists) {
+ for (const event of list) {
+ const id = event.event_id;
+ if (!id || seen.has(id)) continue;
+ seen.add(id);
+ unique.push(event);
+ }
+ }
+ return unique;
+}
+
+function isSecondaryMatrixEvent(mEvent: MatrixEvent): boolean {
+ const relation = mEvent.getRelation();
+ if (
+ relation?.rel_type === RelationType.Replace ||
+ relation?.rel_type === RelationType.Annotation
+ ) {
+ return true;
+ }
+ return isAnnotationRoomMessage(mEvent.getContent());
+}
+
+function eventBelongsToThread(mEvent: MatrixEvent, rootEventId: string): boolean {
+ if (mEvent.getId() === rootEventId) return true;
+
+ const relation = mEvent.getRelation();
+ if (relation?.rel_type === RelationType.Thread && relation.event_id === rootEventId) {
+ return true;
+ }
+
+ const contentRelation = threadRelationFromContent(mEvent.getContent());
+ if (
+ contentRelation?.rel_type === RelationType.Thread &&
+ contentRelation.event_id === rootEventId
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
+function matrixEventToRoomEvent(mEvent: MatrixEvent): IRoomEvent {
+ return mEvent.event as IRoomEvent;
+}
+
+/** Thread events already in the local room timeline (includes unsent/local echo). */
+export function collectLocalThreadEvents(
+ mx: MatrixClient,
+ roomId: string,
+ rootEventId: string
+): IRoomEvent[] {
+ const rootId = rootEventId.trim();
+ const room = mx.getRoom(roomId);
+ if (!rootId || !room) return [];
+
+ const seen = new Set();
+ const events: IRoomEvent[] = [];
+
+ const add = (mEvent: MatrixEvent | undefined | null) => {
+ if (!mEvent || mEvent.isRedacted()) return;
+ if (mEvent.getType() !== EventType.RoomMessage) return;
+ if (isSecondaryMatrixEvent(mEvent)) return;
+ if (!eventBelongsToThread(mEvent, rootId)) return;
+ const id = mEvent.getId();
+ if (!id || seen.has(id)) return;
+ seen.add(id);
+ events.push(matrixEventToRoomEvent(mEvent));
+ };
+
+ add(room.findEventById(rootId));
+
+ const thread = room.getThread(rootId);
+ if (thread) {
+ add(thread.rootEvent);
+ for (const reply of thread.events) add(reply);
+ for (const reply of thread.liveTimeline?.getEvents() ?? []) add(reply);
+ }
+
+ for (const mEvent of room.getUnfilteredTimelineSet().getLiveTimeline().getEvents()) {
+ add(mEvent);
+ }
+
+ const targetIds = new Set(seen);
+ events.push(...collectLocalReplaceEdits(room, targetIds));
+
+ return events;
+}
+
+function collectLocalReplaceEdits(room: Room, targetIds: Set): IRoomEvent[] {
+ const events: IRoomEvent[] = [];
+ const seen = new Set();
+
+ for (const mEvent of room.getUnfilteredTimelineSet().getLiveTimeline().getEvents()) {
+ if (mEvent.isRedacted() || mEvent.getType() !== EventType.RoomMessage) continue;
+ const relation = mEvent.getRelation();
+ if (relation?.rel_type !== RelationType.Replace) continue;
+ const targetId = relation.event_id;
+ if (!targetId || !targetIds.has(targetId)) continue;
+ const id = mEvent.getId();
+ if (!id || seen.has(id)) continue;
+ seen.add(id);
+ events.push(matrixEventToRoomEvent(mEvent));
+ }
+
+ return events;
+}
+
+function collectLocalRootPosts(mx: MatrixClient, roomId: string): IRoomEvent[] {
+ const room = mx.getRoom(roomId);
+ if (!room) return [];
+
+ const seen = new Set();
+ const events: IRoomEvent[] = [];
+
+ for (const mEvent of room.getUnfilteredTimelineSet().getLiveTimeline().getEvents()) {
+ if (mEvent.isRedacted() || mEvent.getType() !== EventType.RoomMessage) continue;
+ if (isSecondaryMatrixEvent(mEvent)) continue;
+ const relation = threadRelationFromContent(mEvent.getContent());
+ const isThreadReply =
+ relation?.rel_type === RelationType.Thread || relation?.rel_type === 'm.thread';
+ if (isThreadReply) continue;
+ const id = mEvent.getId();
+ if (!id || seen.has(id)) continue;
+ seen.add(id);
+ events.push(matrixEventToRoomEvent(mEvent));
+ }
+
+ const rootIds = new Set(events.map((event) => event.event_id).filter(Boolean) as string[]);
+ events.push(...collectLocalReplaceEdits(room, rootIds));
+
+ return events;
+}
+
+export function loadPostThreadLocal(
+ mx: MatrixClient,
+ roomId: string,
+ rootEventId: string
+): ForumPost | null {
+ const rootId = rootEventId.trim();
+ const events = collectLocalThreadEvents(mx, roomId, rootId);
+ if (events.length === 0) return null;
+ const posts = buildPosts(mx, roomId, events);
+ return posts.find((post) => post.eventId === rootId) ?? posts[0] ?? null;
+}
+
async function fetchRootEvent(
mx: MatrixClient,
roomId: string,
@@ -314,10 +559,10 @@ export async function loadPostThread(
rootEventId: string
): Promise {
const rootId = rootEventId.trim();
- const rootEvent = await fetchRootEvent(mx, roomId, rootId);
- if (!rootEvent?.event_id) return null;
+ const localEvents = collectLocalThreadEvents(mx, roomId, rootId);
- const events: IRoomEvent[] = [rootEvent];
+ const rootEvent = await fetchRootEvent(mx, roomId, rootId);
+ const apiEvents: IRoomEvent[] = rootEvent?.event_id ? [rootEvent] : [];
const collectRelations = async (
relationType: RelationType | null,
@@ -332,31 +577,32 @@ export async function loadPostThread(
recurse: true,
});
if (relPayload.chunk?.length) {
- events.push(...(relPayload.chunk as IRoomEvent[]));
+ apiEvents.push(...(relPayload.chunk as IRoomEvent[]));
}
from = relPayload.next_batch ?? undefined;
if (!from) break;
}
};
- try {
- await collectRelations(RelationType.Thread, EventType.RoomMessage);
- } catch {
+ if (rootEvent?.event_id) {
try {
- await collectRelations(null, EventType.RoomMessage);
+ await collectRelations(RelationType.Replace, EventType.RoomMessage);
} catch {
- // Still show root without replies.
+ // Root may have no replace relations.
+ }
+ try {
+ await collectRelations(RelationType.Thread, EventType.RoomMessage);
+ } catch {
+ try {
+ await collectRelations(null, EventType.RoomMessage);
+ } catch {
+ // Still show root without replies.
+ }
}
}
- const seen = new Set();
- const unique: IRoomEvent[] = [];
- for (const event of events) {
- const id = event.event_id;
- if (!id || seen.has(id)) continue;
- seen.add(id);
- unique.push(event);
- }
+ const unique = mergeUniqueRoomEvents(localEvents, apiEvents);
+ if (unique.length === 0) return null;
const posts = buildPosts(mx, roomId, unique);
return posts.find((post) => post.eventId === rootId) ?? posts[0] ?? null;
@@ -423,6 +669,54 @@ export async function sendForumPost(
return res.event_id;
}
+/** Image/file/video root post with a forum title. */
+export async function sendForumMediaRoot(
+ mx: MatrixClient,
+ roomId: string,
+ title: string,
+ content: IContent
+): Promise {
+ const trimmedTitle = title.trim();
+ if (!trimmedTitle) {
+ throw new Error('Post title is required.');
+ }
+ if (trimmedTitle.length > 140) {
+ throw new Error('Post title is too long.');
+ }
+
+ const res = await mx.sendMessage(roomId, {
+ ...content,
+ 'com.matrixsso.title': trimmedTitle,
+ });
+ return res.event_id;
+}
+
+/** Thread attachment (image, file, etc.) under a forum post root. */
+export async function sendForumThreadAttachment(
+ mx: MatrixClient,
+ roomId: string,
+ threadRootId: string,
+ content: IContent,
+ replyToEventId?: string
+): Promise {
+ const rootId = threadRootId.trim();
+ const parentId = replyToEventId?.trim() || rootId;
+ if (!rootId) {
+ throw new Error('Thread root event id is required.');
+ }
+
+ const res = await mx.sendMessage(roomId, {
+ ...content,
+ 'm.relates_to': {
+ rel_type: RelationType.Thread,
+ event_id: rootId,
+ is_falling_back: false,
+ 'm.in_reply_to': { event_id: parentId },
+ } as IEventRelation,
+ });
+ return res.event_id;
+}
+
export type ForumReplyPayload = {
plainText: string;
formattedHtml?: string;
diff --git a/src/app/features/forum/forumRichText.ts b/src/app/features/forum/forumRichText.ts
index b969b9d..a46f598 100644
--- a/src/app/features/forum/forumRichText.ts
+++ b/src/app/features/forum/forumRichText.ts
@@ -7,8 +7,16 @@ export const FORUM_EDITOR_OUTPUT_OPTS: OutputOptions = {
allowBlockMarkdown: true,
};
+export function titleFromFormattedBody(html: string | undefined): string | undefined {
+ if (!html) return undefined;
+ const match = html.trim().match(/^]*>([\s\S]*?)<\/h1>/i);
+ if (!match) return undefined;
+ const text = match[1].replace(/<[^>]+>/g, '').trim();
+ return text || undefined;
+}
+
export function stripLeadingTitleHeading(html: string): string {
- return html.replace(/^]*>[\s\S]*?<\/h1>\s*/i, '').trim();
+ return html.replace(/^\s*]*>[\s\S]*?<\/h1>\s*/i, '').trim();
}
export function bodyHtmlFromMessageContent(
diff --git a/src/app/features/forum/forumTheme.css.ts b/src/app/features/forum/forumTheme.css.ts
index 8f8ec01..9f3a949 100644
--- a/src/app/features/forum/forumTheme.css.ts
+++ b/src/app/features/forum/forumTheme.css.ts
@@ -96,6 +96,20 @@ export const ForumLeftColumn = style([
},
]);
+/** Feed column inside Cinny space PageNav. */
+export const ForumFeedSidebarRoot = style([
+ DefaultReset,
+ {
+ display: 'flex',
+ flex: '1 1 0',
+ flexDirection: 'column',
+ height: '100%',
+ minHeight: 0,
+ minWidth: 0,
+ overflow: 'hidden',
+ },
+]);
+
export const ForumLeftHeader = style({
flexShrink: 0,
borderBottom: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
@@ -126,6 +140,7 @@ export const ForumListCard = style({
export const ForumDetailPane = style({
display: 'flex',
+ flex: '1 1 0',
flexDirection: 'column',
minHeight: 0,
minWidth: 0,
@@ -144,18 +159,32 @@ export const ForumTopicTargetFields = style({
/** @deprecated use ForumDetailPane */
export const ForumDetailAside = ForumDetailPane;
-export const ForumTopicPostDetail = style({
+/** Scroll slot for folds `Scroll` in forum panels (post list + thread detail). */
+export const ForumPanelScroll = style({
flex: '1 1 0',
minHeight: 0,
- overflowX: 'hidden',
- overflowY: 'auto',
- overscrollBehavior: 'contain',
+ minWidth: 0,
+});
+
+export const ForumTopicPostDetailScroll = style([
+ ForumPanelScroll,
+ {
+ backgroundColor: color.Surface.Container,
+ },
+]);
+
+export const ForumTopicPostDetailInner = style({
+ boxSizing: 'border-box',
padding: `0 ${forumDetailPad} ${forumDetailPad}`,
paddingRight: forumDetailPadRight,
- backgroundColor: color.Surface.Container,
- boxSizing: 'border-box',
});
+/** @deprecated use ForumTopicPostDetailScroll + ForumTopicPostDetailInner */
+export const ForumTopicPostDetail = style([
+ ForumTopicPostDetailScroll,
+ ForumTopicPostDetailInner,
+]);
+
export const ForumTopicPostDetailWithThread = style({});
export const ForumComposerBar = style({
@@ -392,13 +421,7 @@ export const ForumPostCountMeta = style({
minWidth: 0,
});
-export const ForumPostScroll = style({
- flex: '1 1 0',
- minHeight: 0,
- overflowX: 'hidden',
- overflowY: 'auto',
- overscrollBehavior: 'contain',
-});
+export const ForumPostScroll = style([ForumPanelScroll]);
export const ForumPostList = style({
display: 'grid',
@@ -526,6 +549,148 @@ export const ForumCategoryLabel = style({
textTransform: 'lowercase',
});
+const forumThreadRailHitBase = {
+ background: 'transparent',
+ border: 0,
+ bottom: 0,
+ cursor: 'pointer',
+ left: 0,
+ margin: 0,
+ padding: 0,
+ position: 'absolute' as const,
+ top: 0,
+ width: toRem(20),
+ zIndex: 10,
+};
+
+export const ForumThreadRailHitRoot = style({
+ ...forumThreadRailHitBase,
+ selectors: {
+ '&::after': {
+ ...threadLineBefore,
+ background: threadLineBase,
+ width: threadLineWidth,
+ },
+ '&:hover::after': {
+ background: threadLineActive,
+ },
+ },
+});
+
+export const ForumThreadRailHitNested = style({
+ ...forumThreadRailHitBase,
+ selectors: {
+ '&::after': {
+ ...threadLineBefore,
+ background: threadLineChild,
+ width: threadLineWidth,
+ },
+ '&:hover::after': {
+ background: threadLineActive,
+ },
+ },
+});
+
+const forumThreadCollapsedRowBase = {
+ alignItems: 'stretch',
+ background: 'transparent',
+ border: 0,
+ boxSizing: 'border-box' as const,
+ cursor: 'pointer',
+ display: 'flex',
+ gap: threadRailGap,
+ marginBottom: threadLineGap,
+ marginTop: threadLineGap,
+ minWidth: 0,
+ padding: 0,
+ textAlign: 'left' as const,
+ width: '100%',
+};
+
+export const ForumThreadCollapsedRowRoot = style([DefaultReset, forumThreadCollapsedRowBase]);
+
+export const ForumThreadCollapsedRowNested = style([
+ DefaultReset,
+ forumThreadCollapsedRowBase,
+ {
+ marginLeft: threadLevelIndent,
+ width: `calc(100% - ${threadLevelIndent})`,
+ },
+]);
+
+export const ForumThreadCollapsedRailRoot = style({
+ alignSelf: 'stretch',
+ background: threadLineBase,
+ borderRadius: threadLineRadius,
+ flexShrink: 0,
+ minHeight: toRem(40),
+ transition: 'background 0.15s ease',
+ width: threadLineWidth,
+ selectors: {
+ [`${ForumThreadCollapsedRowRoot}:hover &`]: {
+ background: threadLineActive,
+ },
+ },
+});
+
+export const ForumThreadCollapsedRailNested = style({
+ alignSelf: 'stretch',
+ background: threadLineChild,
+ borderRadius: threadLineRadius,
+ flexShrink: 0,
+ minHeight: toRem(40),
+ transition: 'background 0.15s ease',
+ width: threadLineWidth,
+ selectors: {
+ [`${ForumThreadCollapsedRowNested}:hover &`]: {
+ background: threadLineActive,
+ },
+ },
+});
+
+export const ForumThreadCollapsedMiniPost = style({
+ background: color.Background.Container,
+ border: `${config.borderWidth.B300} solid ${threadLineBase}`,
+ borderRadius: toRem(6),
+ boxSizing: 'border-box',
+ display: 'flex',
+ flex: 1,
+ flexDirection: 'column',
+ gap: toRem(6),
+ justifyContent: 'center',
+ minHeight: toRem(40),
+ minWidth: 0,
+ padding: `${toRem(9)} ${toRem(11)}`,
+ transition: 'background 0.15s ease, border-color 0.15s ease',
+ selectors: {
+ [`${ForumThreadCollapsedRowRoot}:hover &, ${ForumThreadCollapsedRowNested}:hover &`]: {
+ background: color.Surface.ContainerHover,
+ borderColor: color.Primary.Main,
+ },
+ },
+});
+
+export const ForumThreadCollapsedMoreLabel = style({
+ color: muted,
+ fontSize: toRem(13),
+ fontWeight: 600,
+ lineHeight: 1.3,
+ selectors: {
+ [`${ForumThreadCollapsedRowRoot}:hover &, ${ForumThreadCollapsedRowNested}:hover &`]: {
+ color: color.Primary.Main,
+ },
+ },
+});
+
+export const ForumThreadCollapsible = style({
+ selectors: {
+ '&::before': {
+ opacity: 0,
+ pointerEvents: 'none',
+ },
+ },
+});
+
export const ForumReplyMeta = style({
alignItems: 'center',
display: 'inline-flex',
@@ -574,11 +739,38 @@ export const ForumEmptyDetail = style({
export const ForumTopicDetailRoot = style({
display: 'flex',
+ flex: '1 1 0',
flexDirection: 'column',
+ minHeight: 0,
minWidth: 0,
width: '100%',
});
+export const ForumTopicDetailBodyScroll = style([
+ ForumPanelScroll,
+ {
+ flex: '1 1 0',
+ minHeight: 0,
+ },
+]);
+
+export const ForumTopicDetailBody = style({
+ boxSizing: 'border-box',
+ padding: `0 ${forumDetailPad} ${forumDetailPad}`,
+ paddingRight: forumDetailPadRight,
+});
+
+export const ForumTopicPostDetailThreadLayout = style([
+ ForumPanelScroll,
+ {
+ backgroundColor: color.Surface.Container,
+ boxSizing: 'border-box',
+ display: 'flex',
+ flexDirection: 'column',
+ minHeight: 0,
+ },
+]);
+
export const ForumTopicDetailHead = style({
alignItems: 'flex-start',
background: color.Surface.Container,
@@ -610,7 +802,16 @@ export const ForumTopicDetailOp = style({
marginRight: 0,
maxWidth: '100%',
minWidth: 0,
- paddingBottom: toRem(36),
+ paddingBottom: toRem(12),
+ position: 'relative',
+});
+
+export const ForumThreadFooterComposer = style({
+ backgroundColor: color.Surface.Container,
+ borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
+ boxSizing: 'border-box',
+ flexShrink: 0,
+ padding: `${toRem(12)} ${forumDetailPadRight} ${toRem(8)} ${forumDetailPad}`,
position: 'relative',
});
@@ -630,6 +831,11 @@ export const ForumMessageBodyRich = style({
wordBreak: 'break-word',
});
+export const ForumEventContentRoot = style({
+ minWidth: 0,
+ width: '100%',
+});
+
globalStyle(`${ForumMessageBodyRich} p`, {
margin: '0 0 0.65em',
});
@@ -645,11 +851,35 @@ globalStyle(`${ForumMessageBodyRich} blockquote`, {
color: muted,
});
+const forumNativeScroll = `${ForumTopicPostDetailInner} pre, ${ForumMessageBodyRich} pre`;
+
globalStyle(`${ForumMessageBodyRich} pre`, {
background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300,
overflow: 'auto',
padding: config.space.S300,
+ scrollbarWidth: 'thin',
+ scrollbarColor: `${color.Surface.ContainerLine} transparent`,
+});
+
+globalStyle(`${forumNativeScroll}::-webkit-scrollbar`, {
+ height: toRem(6),
+ width: toRem(6),
+});
+
+globalStyle(`${forumNativeScroll}::-webkit-scrollbar-track`, {
+ background: 'transparent',
+});
+
+globalStyle(`${forumNativeScroll}::-webkit-scrollbar-thumb`, {
+ backgroundColor: color.Surface.ContainerLine,
+ border: '2px solid transparent',
+ backgroundClip: 'padding-box',
+ borderRadius: config.radii.Pill,
+});
+
+globalStyle(`${forumNativeScroll}::-webkit-scrollbar-thumb:hover`, {
+ backgroundColor: color.SurfaceVariant.ContainerLine,
});
globalStyle(`${ForumMessageBodyRich} code`, {
@@ -672,28 +902,29 @@ globalStyle(`${ForumMessageBodyRich} img`, {
borderRadius: config.radii.R300,
});
-export const ForumReplyActionsFooter = style({
- bottom: toRem(6),
- display: 'flex',
- justifyContent: 'flex-end',
- margin: 0,
- pointerEvents: 'none',
- position: 'absolute',
- right: toRem(10),
- zIndex: 2,
- maxWidth: `calc(100% - ${toRem(12)})`,
-});
+export const ForumMessageOptionsBase = style([
+ DefaultReset,
+ {
+ position: 'absolute',
+ top: toRem(6),
+ right: toRem(6),
+ zIndex: 3,
+ // Extend hitbox for easier hover targeting (matches room messages).
+ paddingLeft: toRem(80),
+ marginLeft: toRem(-80),
+ pointerEvents: 'none',
+ },
+]);
-export const ForumMessageActions = style({
- alignItems: 'center',
- display: 'flex',
- flexShrink: 0,
- gap: toRem(3),
- marginLeft: 'auto',
- opacity: 0,
- pointerEvents: 'none',
- transition: 'opacity 0.16s ease',
-});
+export const ForumMessageOptionsBar = style([
+ DefaultReset,
+ {
+ padding: config.space.S100,
+ opacity: 0,
+ pointerEvents: 'none',
+ transition: 'opacity 0.12s ease',
+ },
+]);
export const ForumMessageActionBtn = style({
alignItems: 'center',
@@ -751,6 +982,10 @@ export const ForumFeedReplyForm = style({
marginTop: 0,
});
+export const ForumThreadReplyTarget = style({
+ borderLeft: `3px solid ${color.Primary.Main}`,
+});
+
export const ForumReplyToDeleted = style({
borderLeft: `4px solid ${color.SurfaceVariant.ContainerLine}`,
color: muted,
@@ -1044,33 +1279,45 @@ globalStyle(`${ForumPostModalEditorWrap} .${editorCss.EditorTextarea}`, {
minHeight: toRem(180),
});
-globalStyle(`${ForumThreadReplyInner}:hover ${ForumReplyActionsFooter} ${ForumMessageActions}`, {
- opacity: 1,
- pointerEvents: 'auto',
-});
+const forumMessageOptionsVisible = `${ForumMessageOptionsBase}:hover ${ForumMessageOptionsBar}, ${ForumMessageOptionsBase}:focus-within ${ForumMessageOptionsBar}`;
-globalStyle(`${ForumThreadReplyInner}:focus-within ${ForumReplyActionsFooter} ${ForumMessageActions}`, {
- opacity: 1,
- pointerEvents: 'auto',
-});
+globalStyle(
+ `${ForumThreadReplyInner}:hover ${ForumMessageOptionsBase}, ${ForumThreadReplyInner}:focus-within ${ForumMessageOptionsBase}`,
+ {
+ pointerEvents: 'auto',
+ }
+);
-globalStyle(`${ForumThreadReplyInner}:has(${ForumMessageActionPanels}) ${ForumReplyActionsFooter}`, {
+globalStyle(
+ `${ForumThreadReplyInner}:hover ${ForumMessageOptionsBar}, ${ForumThreadReplyInner}:focus-within ${ForumMessageOptionsBar}, ${forumMessageOptionsVisible}`,
+ {
+ opacity: 1,
+ pointerEvents: 'auto',
+ }
+);
+
+globalStyle(`${ForumThreadReplyInner}:has(${ForumMessageActionPanels}) ${ForumMessageOptionsBase}`, {
display: 'none',
});
-globalStyle(`${ForumTopicDetailOp}:has(${ForumMessageActionPanels}) ${ForumReplyActionsFooter}`, {
+globalStyle(`${ForumTopicDetailOp}:has(${ForumMessageActionPanels}) ${ForumMessageOptionsBase}`, {
display: 'none',
});
-globalStyle(`${ForumTopicDetailOp}:hover ${ForumReplyActionsFooter} ${ForumMessageActions}`, {
- opacity: 1,
- pointerEvents: 'auto',
-});
+globalStyle(
+ `${ForumTopicDetailOp}:hover ${ForumMessageOptionsBase}, ${ForumTopicDetailOp}:focus-within ${ForumMessageOptionsBase}`,
+ {
+ pointerEvents: 'auto',
+ }
+);
-globalStyle(`${ForumTopicDetailOp}:focus-within ${ForumReplyActionsFooter} ${ForumMessageActions}`, {
- opacity: 1,
- pointerEvents: 'auto',
-});
+globalStyle(
+ `${ForumTopicDetailOp}:hover ${ForumMessageOptionsBar}, ${ForumTopicDetailOp}:focus-within ${ForumMessageOptionsBar}, ${ForumTopicDetailOp} ${forumMessageOptionsVisible}`,
+ {
+ opacity: 1,
+ pointerEvents: 'auto',
+ }
+);
globalStyle(
`${ForumThread}:has(> ${ForumThreadReply} > ${ForumThreadReplyInner}:hover)::before, ${ForumThread}:has(> ${ForumThreadReply} > ${ForumThreadReplyInner}:focus-within)::before`,
@@ -1110,7 +1357,7 @@ globalStyle(`${ForumTopicDetailOp} ${ForumMessageBody}, ${ForumTopicDetailOp} ${
// Legacy aliases
export const ForumApp = ForumTopicLiveApp;
-export const ForumDetailScroll = ForumTopicPostDetail;
+export const ForumDetailScroll = ForumTopicPostDetailScroll;
export const ForumDetailComposerBar = ForumComposerBar;
export const ForumChrome = style({ display: 'none' });
export const ForumBrand = style({ display: 'none' });
diff --git a/src/app/features/forum/index.ts b/src/app/features/forum/index.ts
index 65a6e58..304cadb 100644
--- a/src/app/features/forum/index.ts
+++ b/src/app/features/forum/index.ts
@@ -1,4 +1,5 @@
-export { ForumBoardView } from './ForumBoardView';
+export { ForumFeedSidebar } from './ForumFeedSidebar';
+export { ForumBoardDetail } from './ForumBoardDetail';
export { ForumSortIcons } from './forumLucideIcons';
export { ForumSpaceShell } from './ForumSpaceShell';
export { ForumThreadDetail } from './ForumThreadDetail';
diff --git a/src/app/features/forum/useForumBoard.ts b/src/app/features/forum/useForumBoard.ts
index 04c3ce5..87c7f64 100644
--- a/src/app/features/forum/useForumBoard.ts
+++ b/src/app/features/forum/useForumBoard.ts
@@ -1,8 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { useSearchParams } from 'react-router-dom';
+import { useLocation, useNavigate } from 'react-router-dom';
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
+import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
+import { getSpaceLobbyPath } from '../../pages/pathUtils';
import { createRoomModalAtom } from '../../state/createRoomModal';
import { createSpaceModalAtom } from '../../state/createSpaceModal';
import { StateEvent } from '../../../types/matrix/room';
@@ -18,7 +21,14 @@ import {
listTopicFeedPosts,
loadAggregatedTopicFeed,
} from './forumFeed';
-import type { ForumBoardQuery, ForumPublishedPost, ForumSection, ForumThreadSummary } from './types';
+import type {
+ ForumBoardQuery,
+ ForumPost,
+ ForumPublishedPost,
+ ForumSection,
+ ForumThreadSummary,
+} from './types';
+import { useForumRoomLiveUpdates } from './useForumRoomLiveUpdates';
export type ForumBoardScope = {
forumSpaceId: string;
@@ -62,17 +72,24 @@ function scopesFromSelection(
export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
const mx = useMatrixClient();
- const [searchParams, setSearchParams] = useSearchParams();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const selectedRoomId = useSelectedRoom();
+
+ const searchParams = useMemo(
+ () => new URLSearchParams(location.search),
+ [location.search]
+ );
const filterQuery: ForumBoardQuery = useMemo(
() => ({
category: searchParams.get('category') || undefined,
- topic: scope.topicRoomId || searchParams.get('topic') || undefined,
+ topic: searchParams.get('topic') || undefined,
q: searchParams.get('q') || undefined,
status: (searchParams.get('status') as ForumBoardQuery['status']) || 'all',
sort: (searchParams.get('sort') as ForumBoardQuery['sort']) || 'hot',
}),
- [searchParams, scope.topicRoomId]
+ [searchParams]
);
const query: ForumBoardQuery = useMemo(
@@ -92,41 +109,53 @@ export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
const [postsNextBatch, setPostsNextBatch] = useState(null);
const [error, setError] = useState(null);
- const setQueryParam = useCallback(
- (key: string, value: string | null) => {
- setSearchParams(
- (prev) => {
- const next = new URLSearchParams(prev);
- if (!value) next.delete(key);
- else next.set(key, value);
- if (key !== 'post') {
- next.delete('post');
- next.delete('postRoom');
- }
- return next;
+ const applyForumSearchParams = useCallback(
+ (mutate: (next: URLSearchParams) => void) => {
+ const next = new URLSearchParams(location.search);
+ mutate(next);
+ const search = next.toString();
+
+ const onTopicRoomRoute =
+ Boolean(selectedRoomId) && selectedRoomId !== forumSpace.roomId;
+
+ navigate(
+ {
+ pathname: onTopicRoomRoute
+ ? getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, forumSpace.roomId))
+ : location.pathname,
+ search: search ? `?${search}` : '',
},
{ replace: true }
);
},
- [setSearchParams]
+ [location.pathname, location.search, navigate, mx, forumSpace.roomId, selectedRoomId]
+ );
+
+ const setQueryParam = useCallback(
+ (key: string, value: string | null) => {
+ applyForumSearchParams((next) => {
+ if (!value) next.delete(key);
+ else next.set(key, value);
+ if (key !== 'post') {
+ next.delete('post');
+ next.delete('postRoom');
+ }
+ });
+ },
+ [applyForumSearchParams]
);
const setCategory = useCallback(
(category: string | null) => {
- setSearchParams(
- (prev) => {
- const next = new URLSearchParams(prev);
- if (!category) next.delete('category');
- else next.set('category', category);
- next.delete('topic');
- next.delete('post');
- next.delete('postRoom');
- return next;
- },
- { replace: true }
- );
+ applyForumSearchParams((next) => {
+ if (!category) next.delete('category');
+ else next.set('category', category);
+ next.delete('topic');
+ next.delete('post');
+ next.delete('postRoom');
+ });
},
- [setSearchParams]
+ [applyForumSearchParams]
);
const loadSections = useCallback(async () => {
@@ -142,8 +171,8 @@ export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
}
}, [mx, scope.forumSpaceId]);
- const refreshThreads = useCallback(async () => {
- setLoadingThreads(true);
+ const refreshThreads = useCallback(async (options?: { silent?: boolean }) => {
+ if (!options?.silent) setLoadingThreads(true);
setError(null);
try {
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
@@ -172,7 +201,7 @@ export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load forum posts');
} finally {
- setLoadingThreads(false);
+ if (!options?.silent) setLoadingThreads(false);
}
}, [mx, sections, filterQuery]);
@@ -214,17 +243,29 @@ export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
}, [createSpaceModal, createRoomModal, loadSections]);
useEffect(() => {
- if (!loadingSections) {
- refreshThreads();
- }
- }, [loadingSections, refreshThreads]);
+ if (loadingSections) return;
+ void refreshThreads();
+ }, [
+ loadingSections,
+ refreshThreads,
+ filterQuery.category,
+ filterQuery.topic,
+ filterQuery.q,
+ filterQuery.status,
+ filterQuery.sort,
+ ]);
- useEffect(() => {
- const timer = window.setInterval(() => {
- refreshThreads();
- }, 30_000);
- return () => window.clearInterval(timer);
- }, [refreshThreads]);
+ const scopedRooms = useMemo(() => {
+ if (loadingSections) return [];
+ const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
+ return roomScopes
+ .map((scope) => mx.getRoom(scope.roomId))
+ .filter((room): room is Room => room !== null);
+ }, [loadingSections, sections, filterQuery.category, filterQuery.topic, mx]);
+
+ useForumRoomLiveUpdates(scopedRooms, () => {
+ void refreshThreads({ silent: true });
+ });
const selectedPostId = query.post ?? null;
const selectedThread = threads.find((t) => t.eventId === selectedPostId);
@@ -233,23 +274,18 @@ export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
const selectPost = useCallback(
(eventId: string | null, topicRoomId?: string) => {
- setSearchParams(
- (prev) => {
- const next = new URLSearchParams(prev);
- if (!eventId) {
- next.delete('post');
- next.delete('postRoom');
- } else {
- next.set('post', eventId);
- if (topicRoomId) next.set('postRoom', topicRoomId);
- else next.delete('postRoom');
- }
- return next;
- },
- { replace: true }
- );
+ applyForumSearchParams((next) => {
+ if (!eventId) {
+ next.delete('post');
+ next.delete('postRoom');
+ } else {
+ next.set('post', eventId);
+ if (topicRoomId) next.set('postRoom', topicRoomId);
+ else next.delete('postRoom');
+ }
+ });
},
- [setSearchParams]
+ [applyForumSearchParams]
);
const insertThreadFromNewPost = useCallback(
diff --git a/src/app/features/forum/useForumMessageReactions.ts b/src/app/features/forum/useForumMessageReactions.ts
new file mode 100644
index 0000000..0fae7e7
--- /dev/null
+++ b/src/app/features/forum/useForumMessageReactions.ts
@@ -0,0 +1,135 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ EventType,
+ MatrixEvent,
+ RelationType,
+ Room,
+ RoomEvent,
+} from 'matrix-js-sdk';
+import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
+import { MessageEvent } from '../../../types/matrix/room';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { usePowerLevels } from '../../hooks/usePowerLevels';
+import { useRoomCreators } from '../../hooks/useRoomCreators';
+import { useRoomPermissions } from '../../hooks/useRoomPermissions';
+import { getEventReactions, getReactionContent } from '../../utils/room';
+import { eventWithShortcode, factoryEventSentBy } from '../../utils/matrix';
+
+export function useForumMessageReactions(room: Room, mEvent: MatrixEvent | undefined) {
+ const mx = useMatrixClient();
+ const [relationsRevision, setRelationsRevision] = useState(0);
+ const powerLevels = usePowerLevels(room);
+ const creators = useRoomCreators(room);
+ const permissions = useRoomPermissions(creators, powerLevels);
+
+ const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
+ const canPinEvent = permissions.stateEvent('m.room.pinned_events', mx.getSafeUserId());
+
+ const roomTimelineSet = room.getUnfilteredTimelineSet();
+ const eventId = mEvent?.getId();
+
+ const bumpRelations = useCallback(() => {
+ setRelationsRevision((revision) => revision + 1);
+ }, []);
+
+ useEffect(() => {
+ if (!eventId) return undefined;
+
+ const isReactionForEvent = (matrixEvent: MatrixEvent) => {
+ if (matrixEvent.getType() !== EventType.Reaction) return false;
+ return matrixEvent.getRelation()?.event_id === eventId;
+ };
+
+ const handleTimeline = (matrixEvent: MatrixEvent, eventRoom?: Room) => {
+ if (eventRoom?.roomId !== room.roomId) return;
+ if (isReactionForEvent(matrixEvent)) {
+ bumpRelations();
+ }
+ };
+
+ const handleRedaction = (_matrixEvent: MatrixEvent, eventRoom?: Room) => {
+ if (eventRoom?.roomId !== room.roomId) return;
+ bumpRelations();
+ };
+
+ mx.on(RoomEvent.Timeline, handleTimeline);
+ mx.on(RoomEvent.Redaction, handleRedaction);
+
+ return () => {
+ mx.removeListener(RoomEvent.Timeline, handleTimeline);
+ mx.removeListener(RoomEvent.Redaction, handleRedaction);
+ };
+ }, [bumpRelations, eventId, mx, room.roomId]);
+
+ useEffect(() => {
+ if (!eventId) return undefined;
+
+ const relations = getEventReactions(roomTimelineSet, eventId);
+ if (!relations) return undefined;
+
+ const handleRelationsUpdate = () => {
+ bumpRelations();
+ };
+
+ relations.on(RelationsEvent.Add, handleRelationsUpdate);
+ relations.on(RelationsEvent.Redaction, handleRelationsUpdate);
+ relations.on(RelationsEvent.Remove, handleRelationsUpdate);
+
+ return () => {
+ relations.removeListener(RelationsEvent.Add, handleRelationsUpdate);
+ relations.removeListener(RelationsEvent.Redaction, handleRelationsUpdate);
+ relations.removeListener(RelationsEvent.Remove, handleRelationsUpdate);
+ };
+ }, [bumpRelations, eventId, relationsRevision, roomTimelineSet]);
+
+ const reactionRelations = useMemo(() => {
+ if (!eventId) return undefined;
+ return getEventReactions(roomTimelineSet, eventId);
+ // relationsRevision forces re-read after timeline / relation updates
+ }, [eventId, relationsRevision, roomTimelineSet]);
+
+ const hasReactions = Boolean(reactionRelations?.getSortedAnnotationsByKey()?.length);
+
+ const handleReactionToggle = useCallback(
+ (targetEventId: string, key: string, shortcode?: string) => {
+ const relations = getEventReactions(roomTimelineSet, targetEventId);
+ const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
+ const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
+ const reactions = reactionsSet ? Array.from(reactionsSet) : [];
+ const myReaction = reactions.find(factoryEventSentBy(mx.getSafeUserId()));
+
+ if (myReaction?.isRelation()) {
+ const myReactionId = myReaction.getId();
+ if (myReactionId) {
+ mx.redactEvent(room.roomId, myReactionId).finally(() => {
+ bumpRelations();
+ window.setTimeout(bumpRelations, 250);
+ });
+ }
+ return;
+ }
+
+ const rShortcode =
+ shortcode ||
+ (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
+ mx.sendEvent(
+ room.roomId,
+ MessageEvent.Reaction as never,
+ getReactionContent(targetEventId, key, rShortcode)
+ ).finally(() => {
+ bumpRelations();
+ window.setTimeout(bumpRelations, 250);
+ });
+ },
+ [bumpRelations, mx, room.roomId, roomTimelineSet]
+ );
+
+ return {
+ canSendReaction,
+ canPinEvent,
+ reactionRelations,
+ hasReactions,
+ handleReactionToggle,
+ eventId,
+ };
+}
diff --git a/src/app/features/forum/useForumMessageRenderOptions.ts b/src/app/features/forum/useForumMessageRenderOptions.ts
new file mode 100644
index 0000000..8997919
--- /dev/null
+++ b/src/app/features/forum/useForumMessageRenderOptions.ts
@@ -0,0 +1,70 @@
+import { useMemo } from 'react';
+import { Room } from 'matrix-js-sdk';
+import { HTMLReactParserOptions } from 'html-react-parser';
+import { Opts as LinkifyOpts } from 'linkifyjs';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
+import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
+import {
+ LINKIFY_OPTS,
+ factoryRenderLinkifyWithMention,
+ getReactCustomHtmlParser,
+ makeMentionCustomProps,
+ renderMatrixMention,
+} from '../../plugins/react-custom-html-parser';
+import {
+ combineEmbedFilters,
+ useRoomEmbedFilters,
+ useRoomWideEmbedFilters,
+} from '../../hooks/useRoomEmbedFilters';
+import { useSetting } from '../../state/hooks/settings';
+import { MessageLayout, settingsAtom } from '../../state/settings';
+
+export function useForumMessageRenderOptions(room: Room | null | undefined) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
+ const [showUrlPreview] = useSetting(settingsAtom, 'urlPreview');
+ const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
+
+ const [personalEmbedFilters] = useRoomEmbedFilters(room ?? undefined);
+ const roomWideEmbedFilters = useRoomWideEmbedFilters(room ?? undefined);
+ const combinedEmbedFilters = useMemo(
+ () => combineEmbedFilters(personalEmbedFilters, roomWideEmbedFilters),
+ [personalEmbedFilters, roomWideEmbedFilters]
+ );
+
+ const mentionClickHandler = useMentionClickHandler(room?.roomId ?? '');
+ const spoilerClickHandler = useSpoilerClickHandler();
+
+ const linkifyOpts = useMemo(
+ () => ({
+ ...LINKIFY_OPTS,
+ render: factoryRenderLinkifyWithMention((href) =>
+ renderMatrixMention(mx, room?.roomId ?? '', href, makeMentionCustomProps(mentionClickHandler))
+ ),
+ }),
+ [mx, room?.roomId, mentionClickHandler]
+ );
+
+ const htmlReactParserOptions = useMemo(
+ () =>
+ getReactCustomHtmlParser(mx, room?.roomId ?? '', {
+ linkifyOpts,
+ useAuthentication,
+ handleSpoilerClick: spoilerClickHandler,
+ handleMentionClick: mentionClickHandler,
+ }),
+ [mx, room?.roomId, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication]
+ );
+
+ return {
+ linkifyOpts,
+ htmlReactParserOptions,
+ mediaAutoLoad,
+ showUrlPreview,
+ outlineAttachment: messageLayout === MessageLayout.Bubble,
+ disabledEmbedPatterns: combinedEmbedFilters,
+ };
+}
diff --git a/src/app/features/forum/useForumRoomLiveUpdates.ts b/src/app/features/forum/useForumRoomLiveUpdates.ts
new file mode 100644
index 0000000..c7b4eae
--- /dev/null
+++ b/src/app/features/forum/useForumRoomLiveUpdates.ts
@@ -0,0 +1,114 @@
+import { useEffect, useMemo, useRef } from 'react';
+import {
+ EventType,
+ MatrixEvent,
+ RelationType,
+ Room,
+ RoomEvent,
+} from 'matrix-js-sdk';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+
+type ForumRoomLiveOptions = {
+ /** When set, only sync for events in this thread root. */
+ threadRootId?: string;
+ /** Debounce window in ms. */
+ wait?: number;
+};
+
+function isForumThreadEvent(mEvent: MatrixEvent, threadRootId: string): boolean {
+ if (mEvent.getId() === threadRootId) return true;
+
+ const relation = mEvent.getRelation();
+ if (relation?.rel_type === RelationType.Thread && relation.event_id === threadRootId) {
+ return true;
+ }
+
+ const content = mEvent.getContent();
+ const relates = content['m.relates_to'];
+ if (relates && typeof relates === 'object') {
+ const rel = relates as { rel_type?: string; event_id?: string };
+ if (rel.rel_type === RelationType.Thread && rel.event_id === threadRootId) {
+ return true;
+ }
+ }
+
+ if (relation?.rel_type === RelationType.Replace && relation.event_id === threadRootId) {
+ return true;
+ }
+
+ return false;
+}
+
+function isSecondaryMatrixEvent(mEvent: MatrixEvent): boolean {
+ const relation = mEvent.getRelation();
+ return (
+ relation?.rel_type === RelationType.Replace ||
+ relation?.rel_type === RelationType.Annotation
+ );
+}
+
+function isForumFeedEvent(mEvent: MatrixEvent): boolean {
+ const type = mEvent.getType();
+ return type === EventType.RoomMessage || type === EventType.RoomMessageEncrypted;
+}
+
+function isRelevantEvent(mEvent: MatrixEvent, threadRootId?: string): boolean {
+ if (mEvent.getType() === EventType.Reaction) return false;
+ if (isSecondaryMatrixEvent(mEvent)) {
+ if (!threadRootId) return false;
+ const targetId = mEvent.getRelation()?.event_id;
+ return targetId === threadRootId;
+ }
+ if (threadRootId) {
+ return isForumThreadEvent(mEvent, threadRootId);
+ }
+ return isForumFeedEvent(mEvent);
+}
+
+/** Debounced live sync when topic room timelines change. */
+export function useForumRoomLiveUpdates(
+ rooms: Room[],
+ onUpdate: () => void,
+ options: ForumRoomLiveOptions = {}
+) {
+ const mx = useMatrixClient();
+ const { threadRootId, wait = 200 } = options;
+ const onUpdateRef = useRef(onUpdate);
+ onUpdateRef.current = onUpdate;
+
+ const roomIds = useMemo(() => new Set(rooms.map((room) => room.roomId)), [rooms]);
+ const roomKey = useMemo(() => [...roomIds].sort().join(','), [roomIds]);
+
+ useEffect(() => {
+ if (!roomKey) return undefined;
+
+ let timeoutId: number | undefined;
+
+ const scheduleUpdate = () => {
+ if (timeoutId) window.clearTimeout(timeoutId);
+ timeoutId = window.setTimeout(() => {
+ onUpdateRef.current();
+ }, wait);
+ };
+
+ const handleTimeline = (mEvent: MatrixEvent, eventRoom?: Room) => {
+ if (!eventRoom || !roomIds.has(eventRoom.roomId)) return;
+ if (!isRelevantEvent(mEvent, threadRootId)) return;
+ scheduleUpdate();
+ };
+
+ const handleRedaction = (_mEvent: MatrixEvent, eventRoom?: Room) => {
+ if (!eventRoom || !roomIds.has(eventRoom.roomId)) return;
+ scheduleUpdate();
+ };
+
+ mx.on(RoomEvent.Timeline, handleTimeline);
+ mx.on(RoomEvent.Redaction, handleRedaction);
+
+ return () => {
+ if (timeoutId) window.clearTimeout(timeoutId);
+ mx.removeListener(RoomEvent.Timeline, handleTimeline);
+ mx.removeListener(RoomEvent.Redaction, handleRedaction);
+ };
+ }, [mx, roomKey, roomIds, threadRootId, wait]);
+}
diff --git a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
index 4b1e03c..f43c80b 100644
--- a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
+++ b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
@@ -1,5 +1,6 @@
import React from 'react';
-import { Box, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds';
+import { Box, IconButton, Scroll, Text, toRem } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import { useAtomValue } from 'jotai';
import { RoomCard } from '../../components/room-card';
import { RoomTopicViewer } from '../../components/room-topic-viewer';
diff --git a/src/app/features/lobby/DnD.tsx b/src/app/features/lobby/DnD.tsx
index 5fd7e90..b82f7aa 100644
--- a/src/app/features/lobby/DnD.tsx
+++ b/src/app/features/lobby/DnD.tsx
@@ -7,7 +7,8 @@ import {
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import classNames from 'classnames';
-import { Box, Icon, Icons, as } from 'folds';
+import { Box, as } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import * as css from './DnD.css';
diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx
index c6aff74..dc039de 100644
--- a/src/app/features/lobby/HierarchyItemMenu.tsx
+++ b/src/app/features/lobby/HierarchyItemMenu.tsx
@@ -1,20 +1,7 @@
import React, { MouseEventHandler, useCallback, useEffect, useState } from 'react';
import FocusTrap from 'focus-trap-react';
-import {
- Box,
- IconButton,
- Icon,
- Icons,
- PopOut,
- Menu,
- MenuItem,
- Text,
- RectCords,
- config,
- Line,
- Spinner,
- toRem,
-} from 'folds';
+import { Box, IconButton, PopOut, Menu, MenuItem, Text, RectCords, config, Line, Spinner, toRem } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx
index 5154f8b..9c26484 100644
--- a/src/app/features/lobby/Lobby.tsx
+++ b/src/app/features/lobby/Lobby.tsx
@@ -1,5 +1,6 @@
import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react';
-import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config } from 'folds';
+import { Box, Chip, IconButton, Line, Scroll, Spinner, Text, config } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom';
@@ -41,7 +42,7 @@ import { StateEvent } from '../../../types/matrix/room';
import { CanDropCallback, useDnDMonitor } from './DnD';
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
import { getStateEvent, shouldShowForumLobby } from '../../utils/room';
-import { ForumSpaceShell } from '../forum/ForumSpaceShell';
+import { ForumBoardDetail } from '../forum/ForumBoardDetail';
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
import {
makeCinnySpacesContent,
@@ -151,7 +152,7 @@ const useCanDropLobbyItem = (
};
function ForumSpaceLobby() {
- return ;
+ return ;
}
export function Lobby() {
diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx
index bd00fe3..0a8540d 100644
--- a/src/app/features/lobby/LobbyHeader.tsx
+++ b/src/app/features/lobby/LobbyHeader.tsx
@@ -1,21 +1,6 @@
import React, { MouseEventHandler, forwardRef, useEffect, useMemo, useRef, useState } from 'react';
-import {
- Avatar,
- Box,
- Icon,
- IconButton,
- Icons,
- Line,
- Menu,
- MenuItem,
- PopOut,
- RectCords,
- Text,
- Tooltip,
- TooltipProvider,
- config,
- toRem,
-} from 'folds';
+import { Avatar, Box, IconButton, Line, Menu, MenuItem, PopOut, RectCords, Text, Tooltip, TooltipProvider, config, toRem } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import FocusTrap from 'focus-trap-react';
import { PageHeader } from '../../components/page';
import { useSetSetting } from '../../state/hooks/settings';
diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx
index 994cda0..1cce168 100644
--- a/src/app/features/lobby/RoomItem.tsx
+++ b/src/app/features/lobby/RoomItem.tsx
@@ -1,23 +1,6 @@
import React, { MouseEventHandler, ReactNode, useCallback, useRef } from 'react';
-import {
- Avatar,
- Badge,
- Box,
- Chip,
- Icon,
- Icons,
- Line,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Spinner,
- Text,
- Tooltip,
- TooltipProvider,
- as,
- color,
- toRem,
-} from 'folds';
+import { Avatar, Badge, Box, Chip, Line, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as, color, toRem } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import FocusTrap from 'focus-trap-react';
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
diff --git a/src/app/features/lobby/SpaceAddControls.tsx b/src/app/features/lobby/SpaceAddControls.tsx
index 2eaa425..5588e33 100644
--- a/src/app/features/lobby/SpaceAddControls.tsx
+++ b/src/app/features/lobby/SpaceAddControls.tsx
@@ -1,6 +1,7 @@
import React, { MouseEventHandler, useState } from 'react';
import FocusTrap from 'focus-trap-react';
-import { Box, Chip, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config } from 'folds';
+import { Box, Chip, Menu, MenuItem, PopOut, RectCords, Text, config } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import type { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx
index 1f39236..d0d15c3 100644
--- a/src/app/features/lobby/SpaceItem.tsx
+++ b/src/app/features/lobby/SpaceItem.tsx
@@ -1,21 +1,6 @@
import React, { MouseEventHandler, ReactNode, useCallback, useRef, useState } from 'react';
-import {
- Box,
- Avatar,
- Text,
- Chip,
- Icon,
- Icons,
- as,
- Badge,
- toRem,
- Spinner,
- PopOut,
- Menu,
- MenuItem,
- RectCords,
- config,
-} from 'folds';
+import { Box, Avatar, Text, Chip, as, Badge, toRem, Spinner, PopOut, Menu, MenuItem, RectCords, config } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { MatrixError, Room } from 'matrix-js-sdk';
diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx
index 26085b5..bfe90eb 100644
--- a/src/app/features/message-search/MessageSearch.tsx
+++ b/src/app/features/message-search/MessageSearch.tsx
@@ -1,5 +1,6 @@
import React, { RefObject, useEffect, useMemo, useRef } from 'react';
-import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem } from 'folds';
+import { Text, Box, config, Spinner, IconButton, Line, toRem } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import { useAtomValue } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useInfiniteQuery } from '@tanstack/react-query';
diff --git a/src/app/features/message-search/SearchFilters.tsx b/src/app/features/message-search/SearchFilters.tsx
index 929dd1e..49edccd 100644
--- a/src/app/features/message-search/SearchFilters.tsx
+++ b/src/app/features/message-search/SearchFilters.tsx
@@ -6,25 +6,8 @@ import React, {
useRef,
useState,
} from 'react';
-import {
- Box,
- Chip,
- Text,
- Icon,
- Icons,
- Line,
- config,
- PopOut,
- Menu,
- MenuItem,
- Header,
- toRem,
- Scroll,
- Button,
- Input,
- Badge,
- RectCords,
-} from 'folds';
+import { Box, Chip, Text, Line, config, PopOut, Menu, MenuItem, Header, toRem, Scroll, Button, Input, Badge, RectCords } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import { SearchOrderBy } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { useVirtualizer } from '@tanstack/react-virtual';
diff --git a/src/app/features/message-search/SearchInput.tsx b/src/app/features/message-search/SearchInput.tsx
index 533eb5f..5b9a93a 100644
--- a/src/app/features/message-search/SearchInput.tsx
+++ b/src/app/features/message-search/SearchInput.tsx
@@ -1,6 +1,7 @@
import React, { FormEventHandler, RefObject } from 'react';
-import { Box, Text, Input, Icon, Icons, Spinner, Chip, config } from 'folds';
+import { Box, Text, Input, Spinner, Chip, config } from 'folds';
+import { Icon, Icons } from '../../components/icons';
type SearchProps = {
active?: boolean;
loading?: boolean;
diff --git a/src/app/features/message-search/SearchResultGroup.tsx b/src/app/features/message-search/SearchResultGroup.tsx
index e7a56eb..8d003ac 100644
--- a/src/app/features/message-search/SearchResultGroup.tsx
+++ b/src/app/features/message-search/SearchResultGroup.tsx
@@ -2,7 +2,8 @@
import React, { MouseEventHandler, useMemo } from 'react';
import { IEventWithRoomId, JoinRule, RelationType, Room } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
-import { Avatar, Box, Chip, Header, Icon, Icons, Text, config } from 'folds';
+import { Avatar, Box, Chip, Header, Text, config } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import {
diff --git a/src/app/features/room-nav/CallParticipantsIndicator.tsx b/src/app/features/room-nav/CallParticipantsIndicator.tsx
index ffef8ea..95cbd3d 100644
--- a/src/app/features/room-nav/CallParticipantsIndicator.tsx
+++ b/src/app/features/room-nav/CallParticipantsIndicator.tsx
@@ -1,5 +1,6 @@
import React from 'react';
-import { Avatar, Box, Text, Icon, Icons, config } from 'folds';
+import { Avatar, Box, Text, config } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomCallMembers } from '../call/useRoomCallMembers';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
diff --git a/src/app/features/room-nav/RoomNavCategoryButton.tsx b/src/app/features/room-nav/RoomNavCategoryButton.tsx
index 3af8aea..7551e22 100644
--- a/src/app/features/room-nav/RoomNavCategoryButton.tsx
+++ b/src/app/features/room-nav/RoomNavCategoryButton.tsx
@@ -1,5 +1,6 @@
import React from 'react';
-import { as, Chip, Icon, Icons, Text } from 'folds';
+import { as, Chip, Text } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import classNames from 'classnames';
import * as css from './styles.css';
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
index a1cc516..6ec0f42 100644
--- a/src/app/features/room-nav/RoomNavItem.tsx
+++ b/src/app/features/room-nav/RoomNavItem.tsx
@@ -2,25 +2,8 @@ import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react';
import { Room, EventTimeline } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom';
-import {
- Avatar,
- Box,
- Icon,
- IconButton,
- Icons,
- Text,
- Menu,
- MenuItem,
- config,
- PopOut,
- toRem,
- Line,
- RectCords,
- Badge,
- Spinner,
- Tooltip,
- TooltipProvider,
-} from 'folds';
+import { Avatar, Box, IconButton, Text, Menu, MenuItem, config, PopOut, toRem, Line, RectCords, Badge, Spinner, Tooltip, TooltipProvider } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
diff --git a/src/app/features/room-nav/UnjoinedSubRoomItem.tsx b/src/app/features/room-nav/UnjoinedSubRoomItem.tsx
index bdd6248..23333e0 100644
--- a/src/app/features/room-nav/UnjoinedSubRoomItem.tsx
+++ b/src/app/features/room-nav/UnjoinedSubRoomItem.tsx
@@ -1,17 +1,6 @@
import React, { useCallback } from 'react';
-import {
- Box,
- Chip,
- Icon,
- Icons,
- Spinner,
- Text,
- Tooltip,
- TooltipProvider,
- color,
- config,
- toRem,
-} from 'folds';
+import { Box, Chip, Spinner, Text, Tooltip, TooltipProvider, color, config, toRem } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import { MatrixError, Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx
index 904f325..42a3e9e 100644
--- a/src/app/features/room-settings/RoomSettings.tsx
+++ b/src/app/features/room-settings/RoomSettings.tsx
@@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react';
import { useAtomValue } from 'jotai';
-import { Avatar, Box, config, Icon, IconButton, Icons, IconSrc, MenuItem, Text } from 'folds';
+import { Avatar, Box, config, IconButton, MenuItem, Text } from 'folds';
+import { Icon, Icons, IconSrc } from '../../components/icons';
import { JoinRule } from 'matrix-js-sdk';
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx
index 92eb32e..d8c6aee 100644
--- a/src/app/features/room-settings/general/General.tsx
+++ b/src/app/features/room-settings/general/General.tsx
@@ -1,5 +1,6 @@
import React from 'react';
-import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
+import { Box, IconButton, Scroll, Text } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRoom } from '../../../hooks/useRoom';
diff --git a/src/app/features/room-settings/permissions/Permissions.tsx b/src/app/features/room-settings/permissions/Permissions.tsx
index 7572a71..44aae2b 100644
--- a/src/app/features/room-settings/permissions/Permissions.tsx
+++ b/src/app/features/room-settings/permissions/Permissions.tsx
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
-import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
+import { Box, IconButton, Scroll, Text } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { useRoom } from '../../../hooks/useRoom';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
diff --git a/src/app/features/room-settings/sub-rooms/SubRooms.tsx b/src/app/features/room-settings/sub-rooms/SubRooms.tsx
index 651f12d..87bad3b 100644
--- a/src/app/features/room-settings/sub-rooms/SubRooms.tsx
+++ b/src/app/features/room-settings/sub-rooms/SubRooms.tsx
@@ -1,15 +1,6 @@
import React, { useState, useCallback } from 'react';
-import {
- Box,
- Button,
- Text,
- Icon,
- Icons,
- config,
- IconButton,
- Chip,
- Line,
-} from 'folds';
+import { Box, Button, Text, config, IconButton, Chip, Line } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx
index d920507..0782a74 100644
--- a/src/app/features/room/MembersDrawer.tsx
+++ b/src/app/features/room/MembersDrawer.tsx
@@ -6,26 +6,8 @@ import React, {
useRef,
useState,
} from 'react';
-import {
- Avatar,
- Badge,
- Box,
- Chip,
- Header,
- Icon,
- IconButton,
- Icons,
- Input,
- MenuItem,
- PopOut,
- RectCords,
- Scroll,
- Spinner,
- Text,
- Tooltip,
- TooltipProvider,
- config,
-} from 'folds';
+import { Avatar, Badge, Box, Chip, Header, IconButton, Input, MenuItem, PopOut, RectCords, Scroll, Spinner, Text, Tooltip, TooltipProvider, config } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual';
import classNames from 'classnames';
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index d58dd6e..3735fc2 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -12,24 +12,9 @@ import { isKeyHotkey } from 'is-hotkey';
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { Transforms, Editor } from 'slate';
-import {
- Box,
- Dialog,
- Icon,
- IconButton,
- Icons,
- Line,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- PopOut,
- Scroll,
- Text,
- color,
- config,
- toRem,
-} from 'folds';
+import { Box, Dialog, IconButton, Line, Overlay, OverlayBackdrop, OverlayCenter, PopOut, Scroll, Text, color, config, toRem } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import {
CustomEditor,
@@ -131,10 +116,32 @@ interface RoomInputProps {
room: Room;
/** When provided, all messages are sent as replies in this thread. */
threadRootId?: string;
+ /** Thread parent for nested replies (no reply preview). */
+ threadReplyToEventId?: string;
+ /** Hide the "replying to" banner (forum thread composers). */
+ hideReplyPreview?: boolean;
+ /** Hide the thread badge in the reply preview (forum thread composers). */
+ hideReplyThreadIndicator?: boolean;
+ /** Optional highlight style for the reply preview (e.g. left border). */
+ replyPreviewHighlightClassName?: string;
onMessageSent?: () => void;
}
export const RoomInput = forwardRef(
- ({ editor, fileDropContainerRef, roomId, room, threadRootId, onMessageSent }, ref) => {
+ (
+ {
+ editor,
+ fileDropContainerRef,
+ roomId,
+ room,
+ threadRootId,
+ threadReplyToEventId,
+ hideReplyPreview,
+ hideReplyThreadIndicator,
+ replyPreviewHighlightClassName,
+ onMessageSent,
+ },
+ ref
+ ) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
@@ -440,11 +447,12 @@ export const RoomInput = forwardRef(
content['m.relates_to'].is_falling_back = false;
}
} else if (threadRootId) {
+ const inReplyToEventId = threadReplyToEventId ?? threadRootId;
content['m.relates_to'] = {
rel_type: RelationType.Thread,
event_id: threadRootId,
- is_falling_back: true,
- 'm.in_reply_to': { event_id: threadRootId },
+ is_falling_back: inReplyToEventId === threadRootId,
+ 'm.in_reply_to': { event_id: inReplyToEventId },
};
}
@@ -475,7 +483,7 @@ export const RoomInput = forwardRef(
setCommandHint(undefined);
sendTypingStatus(false);
onMessageSent?.();
- }, [mx, roomId, threadRootId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons, onMessageSent]);
+ }, [mx, roomId, threadRootId, threadReplyToEventId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons, onMessageSent]);
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
@@ -702,7 +710,7 @@ export const RoomInput = forwardRef(
)}
)}
- {replyDraft && (
+ {replyDraft && !hideReplyPreview && (
(
>
-
- {replyDraft.relation?.rel_type === RelationType.Thread && }
+
+ {!hideReplyThreadIndicator &&
+ replyDraft.relation?.rel_type === RelationType.Thread && }
;
-
function renderPluginButtonIcon(button: {
icon?: string;
lucideIcon?: string;
@@ -54,8 +50,14 @@ function renderPluginButtonIcon(button: {
? button.icon.slice('lucide:'.length)
: undefined);
+ if (lucideName && lucideName in Icons) {
+ return ;
+ }
+
if (lucideName) {
- const IconComponent = (lucide as Record)[lucideName];
+ const IconComponent = (lucide as Record | undefined>)[
+ lucideName
+ ];
if (IconComponent) {
return ;
}
diff --git a/src/app/features/settings/plugins/Plugins.tsx b/src/app/features/settings/plugins/Plugins.tsx
index 2a47198..a1e812c 100644
--- a/src/app/features/settings/plugins/Plugins.tsx
+++ b/src/app/features/settings/plugins/Plugins.tsx
@@ -1,26 +1,6 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
-import {
- Avatar,
- Badge,
- Box,
- Button,
- color,
- config,
- Header,
- Icon,
- IconButton,
- Icons,
- Input,
- Menu,
- MenuItem,
- PopOut,
- RectCords,
- Scroll,
- Spinner,
- Switch,
- Text,
- toRem,
-} from 'folds';
+import { Avatar, Badge, Box, Button, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Spinner, Switch, Text, toRem } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { HexColorPicker } from 'react-colorful';
import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
diff --git a/src/app/features/space-settings/SpaceSettings.tsx b/src/app/features/space-settings/SpaceSettings.tsx
index e565fb9..44fc1fb 100644
--- a/src/app/features/space-settings/SpaceSettings.tsx
+++ b/src/app/features/space-settings/SpaceSettings.tsx
@@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react';
import { useAtomValue } from 'jotai';
-import { Avatar, Box, config, Icon, IconButton, Icons, IconSrc, MenuItem, Text } from 'folds';
+import { Avatar, Box, config, IconButton, MenuItem, Text } from 'folds';
+import { Icon, Icons, IconSrc } from '../../components/icons';
import { JoinRule } from 'matrix-js-sdk';
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
diff --git a/src/app/features/space-settings/general/General.tsx b/src/app/features/space-settings/general/General.tsx
index 641bfa7..c53ddf9 100644
--- a/src/app/features/space-settings/general/General.tsx
+++ b/src/app/features/space-settings/general/General.tsx
@@ -1,5 +1,6 @@
import React from 'react';
-import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
+import { Box, IconButton, Scroll, Text } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRoom } from '../../../hooks/useRoom';
diff --git a/src/app/features/space-settings/permissions/Permissions.tsx b/src/app/features/space-settings/permissions/Permissions.tsx
index 5697843..477f0a0 100644
--- a/src/app/features/space-settings/permissions/Permissions.tsx
+++ b/src/app/features/space-settings/permissions/Permissions.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useMemo, useState } from 'react';
-import { Box, Button, Icon, IconButton, Icons, Scroll, Spinner, Text, color } from 'folds';
+import { Box, Button, IconButton, Scroll, Spinner, Text, color } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { useAtomValue } from 'jotai';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { useRoom } from '../../../hooks/useRoom';
diff --git a/src/app/features/space/SpaceOptionsMenu.tsx b/src/app/features/space/SpaceOptionsMenu.tsx
index a9b092a..7157cac 100644
--- a/src/app/features/space/SpaceOptionsMenu.tsx
+++ b/src/app/features/space/SpaceOptionsMenu.tsx
@@ -1,7 +1,8 @@
import React, { forwardRef, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
-import { Box, Icon, Icons, Line, Menu, MenuItem, Text, config, toRem } from 'folds';
+import { Box, Line, Menu, MenuItem, Text, config, toRem } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import type { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
diff --git a/src/app/hooks/useMemberEventParser.tsx b/src/app/hooks/useMemberEventParser.tsx
index 8a59aaa..a45327d 100644
--- a/src/app/hooks/useMemberEventParser.tsx
+++ b/src/app/hooks/useMemberEventParser.tsx
@@ -1,5 +1,6 @@
+import { IconSrc, Icons } from '../components/icons';
import React, { ReactNode } from 'react';
-import { IconSrc, Icons } from 'folds';
+
import { MatrixEvent } from 'matrix-js-sdk';
import { IMemberContent, Membership } from '../../types/matrix/room';
import { getMxIdLocalPart } from '../utils/matrix';
diff --git a/src/app/hooks/useRoomsNotificationPreferences.ts b/src/app/hooks/useRoomsNotificationPreferences.ts
index b078896..a126ef9 100644
--- a/src/app/hooks/useRoomsNotificationPreferences.ts
+++ b/src/app/hooks/useRoomsNotificationPreferences.ts
@@ -1,6 +1,7 @@
+import { Icons, IconSrc } from '../components/icons';
import { createContext, useCallback, useContext, useMemo } from 'react';
import { ConditionKind, IPushRules, MatrixClient, PushRuleKind } from 'matrix-js-sdk';
-import { Icons, IconSrc } from 'folds';
+
import { AccountDataEvent } from '../../types/matrix/accountData';
import { useAccountData } from './useAccountData';
import { isRoomId } from '../utils/matrix';
diff --git a/src/app/pages/auth/FiledError.tsx b/src/app/pages/auth/FiledError.tsx
index d96fc87..5cd5bdb 100644
--- a/src/app/pages/auth/FiledError.tsx
+++ b/src/app/pages/auth/FiledError.tsx
@@ -1,6 +1,7 @@
import React from 'react';
-import { Box, Icon, Icons, color, Text } from 'folds';
+import { Box, color, Text } from 'folds';
+import { Icon, Icons } from '../../components/icons';
export function FieldError({ message }: { message: string }) {
return (
diff --git a/src/app/pages/auth/ServerPicker.tsx b/src/app/pages/auth/ServerPicker.tsx
index 7b89041..1e67cf8 100644
--- a/src/app/pages/auth/ServerPicker.tsx
+++ b/src/app/pages/auth/ServerPicker.tsx
@@ -7,19 +7,8 @@ import React, {
useRef,
useState,
} from 'react';
-import {
- Header,
- Icon,
- IconButton,
- Icons,
- Input,
- Menu,
- MenuItem,
- PopOut,
- RectCords,
- Text,
- config,
-} from 'folds';
+import { Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Text, config } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import FocusTrap from 'focus-trap-react';
import { useDebounce } from '../../hooks/useDebounce';
diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx
index 59f93b1..96d659d 100644
--- a/src/app/pages/auth/login/PasswordLoginForm.tsx
+++ b/src/app/pages/auth/login/PasswordLoginForm.tsx
@@ -1,22 +1,6 @@
import React, { FormEventHandler, MouseEventHandler, useCallback, useState } from 'react';
-import {
- Box,
- Button,
- Header,
- Icon,
- IconButton,
- Icons,
- Input,
- Menu,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- PopOut,
- RectCords,
- Spinner,
- Text,
- config,
-} from 'folds';
+import { Box, Button, Header, IconButton, Input, Menu, Overlay, OverlayBackdrop, OverlayCenter, PopOut, RectCords, Spinner, Text, config } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import FocusTrap from 'focus-trap-react';
import { Link } from 'react-router-dom';
import { MatrixError } from 'matrix-js-sdk';
diff --git a/src/app/pages/auth/login/TokenLogin.tsx b/src/app/pages/auth/login/TokenLogin.tsx
index dc21dcc..f4590e3 100644
--- a/src/app/pages/auth/login/TokenLogin.tsx
+++ b/src/app/pages/auth/login/TokenLogin.tsx
@@ -1,15 +1,5 @@
-import {
- Box,
- Icon,
- Icons,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Spinner,
- Text,
- color,
- config,
-} from 'folds';
+import { Box, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, color, config } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import React, { useCallback, useEffect } from 'react';
import { MatrixError } from 'matrix-js-sdk';
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx
index 4534809..c445d94 100644
--- a/src/app/pages/client/ClientRoot.tsx
+++ b/src/app/pages/client/ClientRoot.tsx
@@ -1,18 +1,5 @@
-import {
- Box,
- Button,
- config,
- Dialog,
- Icon,
- IconButton,
- Icons,
- Menu,
- MenuItem,
- PopOut,
- RectCords,
- Spinner,
- Text,
-} from 'folds';
+import { Box, Button, config, Dialog, IconButton, Menu, MenuItem, PopOut, RectCords, Spinner, Text } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient, SyncState } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useRef, useState } from 'react';
diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx
index 5a4564a..37bbb06 100644
--- a/src/app/pages/client/WelcomePage.tsx
+++ b/src/app/pages/client/WelcomePage.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
-import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
+import { Box, Button, Text, config, toRem } from 'folds';
+import { Icon, Icons } from '../../components/icons';
import { Page, PageHero, PageHeroSection } from '../../components/page';
import PaarrotSVG from '../../../../public/res/svg/paarrot.svg';
import { getVersion } from '@tauri-apps/api/app';
diff --git a/src/app/pages/client/create/Create.tsx b/src/app/pages/client/create/Create.tsx
index 288169b..7820687 100644
--- a/src/app/pages/client/create/Create.tsx
+++ b/src/app/pages/client/create/Create.tsx
@@ -1,5 +1,6 @@
import React from 'react';
-import { Box, Icon, Icons, Scroll } from 'folds';
+import { Box, Scroll } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import {
Page,
PageContent,
diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx
index bc32bd5..78e63e9 100644
--- a/src/app/pages/client/direct/Direct.tsx
+++ b/src/app/pages/client/direct/Direct.tsx
@@ -1,20 +1,7 @@
import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
import { useAtom, useAtomValue } from 'jotai';
-import {
- Avatar,
- Box,
- Button,
- Icon,
- IconButton,
- Icons,
- Menu,
- MenuItem,
- PopOut,
- RectCords,
- Text,
- config,
- toRem,
-} from 'folds';
+import { Avatar, Box, Button, IconButton, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { useVirtualizer } from '@tanstack/react-virtual';
import FocusTrap from 'focus-trap-react';
import { useNavigate } from 'react-router-dom';
diff --git a/src/app/pages/client/direct/DirectCreate.tsx b/src/app/pages/client/direct/DirectCreate.tsx
index 3deb0b6..127e61d 100644
--- a/src/app/pages/client/direct/DirectCreate.tsx
+++ b/src/app/pages/client/direct/DirectCreate.tsx
@@ -1,6 +1,7 @@
import React, { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
-import { Box, Icon, IconButton, Icons, Scroll } from 'folds';
+import { Box, IconButton, Scroll } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getDirectCreateSearchParams } from '../../pathSearchParam';
import { getDirectRoomPath } from '../../pathUtils';
diff --git a/src/app/pages/client/explore/Explore.tsx b/src/app/pages/client/explore/Explore.tsx
index dae8316..b5df778 100644
--- a/src/app/pages/client/explore/Explore.tsx
+++ b/src/app/pages/client/explore/Explore.tsx
@@ -1,23 +1,8 @@
import React, { FormEventHandler, useCallback, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import FocusTrap from 'focus-trap-react';
-import {
- Avatar,
- Box,
- Button,
- Dialog,
- Header,
- Icon,
- IconButton,
- Icons,
- Input,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Text,
- color,
- config,
-} from 'folds';
+import { Avatar, Box, Button, Dialog, Header, IconButton, Input, Overlay, OverlayBackdrop, OverlayCenter, Text, color, config } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import {
NavCategory,
NavCategoryHeader,
diff --git a/src/app/pages/client/explore/Featured.tsx b/src/app/pages/client/explore/Featured.tsx
index f056cbb..429e6a2 100644
--- a/src/app/pages/client/explore/Featured.tsx
+++ b/src/app/pages/client/explore/Featured.tsx
@@ -1,5 +1,6 @@
import React from 'react';
-import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
+import { Box, IconButton, Scroll, Text } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { useAtomValue } from 'jotai';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { RoomCard, RoomCardGrid } from '../../../components/room-card';
diff --git a/src/app/pages/client/explore/Server.tsx b/src/app/pages/client/explore/Server.tsx
index 48f267c..1d06996 100644
--- a/src/app/pages/client/explore/Server.tsx
+++ b/src/app/pages/client/explore/Server.tsx
@@ -8,25 +8,8 @@ import React, {
useRef,
useState,
} from 'react';
-import {
- Box,
- Button,
- Chip,
- Icon,
- IconButton,
- Icons,
- Input,
- Line,
- Menu,
- MenuItem,
- PopOut,
- RectCords,
- Scroll,
- Spinner,
- Text,
- config,
- toRem,
-} from 'folds';
+import { Box, Button, Chip, IconButton, Input, Line, Menu, MenuItem, PopOut, RectCords, Scroll, Spinner, Text, config, toRem } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai';
diff --git a/src/app/pages/client/home/CreateRoom.tsx b/src/app/pages/client/home/CreateRoom.tsx
index fddd75a..b2bf1d1 100644
--- a/src/app/pages/client/home/CreateRoom.tsx
+++ b/src/app/pages/client/home/CreateRoom.tsx
@@ -1,5 +1,6 @@
import React from 'react';
-import { Box, Icon, Icons, Scroll, IconButton } from 'folds';
+import { Box, Scroll, IconButton } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import {
Page,
PageContent,
diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx
index c70831f..06d1137 100644
--- a/src/app/pages/client/home/Home.tsx
+++ b/src/app/pages/client/home/Home.tsx
@@ -1,20 +1,7 @@
import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import {
- Avatar,
- Box,
- Button,
- Icon,
- IconButton,
- Icons,
- Menu,
- MenuItem,
- PopOut,
- RectCords,
- Text,
- config,
- toRem,
-} from 'folds';
+import { Avatar, Box, Button, IconButton, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai';
import FocusTrap from 'focus-trap-react';
diff --git a/src/app/pages/client/home/HomeThreadsCategory.tsx b/src/app/pages/client/home/HomeThreadsCategory.tsx
index 34957a6..9b144d2 100644
--- a/src/app/pages/client/home/HomeThreadsCategory.tsx
+++ b/src/app/pages/client/home/HomeThreadsCategory.tsx
@@ -1,13 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import {
- Avatar,
- Box,
- Icon,
- Icons,
- Text,
- Badge,
-} from 'folds';
+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';
diff --git a/src/app/pages/client/home/Search.tsx b/src/app/pages/client/home/Search.tsx
index d5ddfb7..9f3a3a5 100644
--- a/src/app/pages/client/home/Search.tsx
+++ b/src/app/pages/client/home/Search.tsx
@@ -1,5 +1,6 @@
import React, { useRef } from 'react';
-import { Box, Icon, Icons, Text, Scroll, IconButton } from 'folds';
+import { Box, Text, Scroll, IconButton } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { MessageSearch } from '../../../features/message-search';
import { useHomeRooms } from './useHomeRooms';
diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx
index 7ac9094..c0cf5e7 100644
--- a/src/app/pages/client/inbox/Inbox.tsx
+++ b/src/app/pages/client/inbox/Inbox.tsx
@@ -1,5 +1,6 @@
import React, { useRef } from 'react';
-import { Avatar, Box, Icon, Icons, Text } from 'folds';
+import { Avatar, Box, Text } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { useAtomValue } from 'jotai';
import { NavCategory, NavCategoryHeader, NavItem, NavItemContent, NavLink } from '../../../components/nav';
import { getInboxInvitesPath, getInboxNotificationsPath } from '../../pathUtils';
diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx
index 20518e5..b46c100 100644
--- a/src/app/pages/client/inbox/Invites.tsx
+++ b/src/app/pages/client/inbox/Invites.tsx
@@ -1,22 +1,6 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
-import {
- Avatar,
- Badge,
- Box,
- Button,
- Chip,
- Icon,
- IconButton,
- Icons,
- Overlay,
- OverlayBackdrop,
- OverlayCenter,
- Scroll,
- Spinner,
- Text,
- color,
- config,
-} from 'folds';
+import { Avatar, Badge, Box, Button, Chip, IconButton, Overlay, OverlayBackdrop, OverlayCenter, Scroll, Spinner, Text, color, config } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { useAtomValue } from 'jotai';
import { RoomTopicEventContent } from 'matrix-js-sdk/lib/types';
import FocusTrap from 'focus-trap-react';
diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx
index 6649376..ac4aa03 100644
--- a/src/app/pages/client/inbox/Notifications.tsx
+++ b/src/app/pages/client/inbox/Notifications.tsx
@@ -1,18 +1,7 @@
/* eslint-disable react/destructuring-assignment */
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import {
- Avatar,
- Box,
- Chip,
- Header,
- Icon,
- IconButton,
- Icons,
- Scroll,
- Text,
- config,
- toRem,
-} from 'folds';
+import { Avatar, Box, Chip, Header, IconButton, Scroll, Text, config, toRem } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { useSearchParams } from 'react-router-dom';
import {
INotification,
diff --git a/src/app/pages/client/sidebar/CreateTab.tsx b/src/app/pages/client/sidebar/CreateTab.tsx
index e6575cb..cf50962 100644
--- a/src/app/pages/client/sidebar/CreateTab.tsx
+++ b/src/app/pages/client/sidebar/CreateTab.tsx
@@ -1,5 +1,6 @@
import React, { MouseEventHandler, useState } from 'react';
-import { Box, config, Icon, Icons, Menu, PopOut, RectCords, Text } from 'folds';
+import { Box, config, Menu, PopOut, RectCords, Text } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import FocusTrap from 'focus-trap-react';
import { useNavigate } from 'react-router-dom';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx
index 0c978a5..d0d0996 100644
--- a/src/app/pages/client/sidebar/DirectTab.tsx
+++ b/src/app/pages/client/sidebar/DirectTab.tsx
@@ -1,6 +1,7 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
+import { Box, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai';
import PaarrotSVG from '../../../../../public/res/svg/paarrot.svg';
diff --git a/src/app/pages/client/sidebar/ExploreTab.tsx b/src/app/pages/client/sidebar/ExploreTab.tsx
index a45b583..f85b1df 100644
--- a/src/app/pages/client/sidebar/ExploreTab.tsx
+++ b/src/app/pages/client/sidebar/ExploreTab.tsx
@@ -1,5 +1,6 @@
+import { Icon, Icons } from '../../../components/icons';
import React from 'react';
-import { Icon, Icons } from 'folds';
+
import { useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx
index 707a5b5..61bc8fa 100644
--- a/src/app/pages/client/sidebar/HomeTab.tsx
+++ b/src/app/pages/client/sidebar/HomeTab.tsx
@@ -1,6 +1,7 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import { useNavigateWithTransition } from '../../../hooks/useViewTransitions';
-import { Box, Icon, Icons, Line, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
+import { Box, Line, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { useAtomValue } from 'jotai';
import FocusTrap from 'focus-trap-react';
import { useOrphanRooms } from '../../../state/hooks/roomList';
diff --git a/src/app/pages/client/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx
index 50bc093..6db9578 100644
--- a/src/app/pages/client/sidebar/InboxTab.tsx
+++ b/src/app/pages/client/sidebar/InboxTab.tsx
@@ -1,6 +1,7 @@
+import { Icon, Icons } from '../../../components/icons';
import React from 'react';
import { useNavigateWithTransition } from '../../../hooks/useViewTransitions';
-import { Icon, Icons } from 'folds';
+
import { useAtomValue } from 'jotai';
import {
SidebarAvatar,
diff --git a/src/app/pages/client/sidebar/SearchTab.tsx b/src/app/pages/client/sidebar/SearchTab.tsx
index 7ceb5c4..1a2b6e3 100644
--- a/src/app/pages/client/sidebar/SearchTab.tsx
+++ b/src/app/pages/client/sidebar/SearchTab.tsx
@@ -1,5 +1,6 @@
+import { Icon, Icons } from '../../../components/icons';
import React from 'react';
-import { Icon, Icons } from 'folds';
+
import { useAtom } from 'jotai';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
import { searchModalAtom } from '../../../state/searchModal';
diff --git a/src/app/pages/client/sidebar/SettingsTab.tsx b/src/app/pages/client/sidebar/SettingsTab.tsx
index 8cdacbe..ce0cc2e 100644
--- a/src/app/pages/client/sidebar/SettingsTab.tsx
+++ b/src/app/pages/client/sidebar/SettingsTab.tsx
@@ -1,5 +1,6 @@
import React, { MouseEventHandler, useState } from 'react';
-import { Box, config, Icon, Icons, Line, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
+import { Box, config, Line, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import FocusTrap from 'focus-trap-react';
import { useNavigateWithTransition } from '../../../hooks/useViewTransitions';
import { SidebarItem, SidebarAvatar } from '../../../components/sidebar';
diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx
index 3fb26ff..a2148bc 100644
--- a/src/app/pages/client/sidebar/SpaceTabs.tsx
+++ b/src/app/pages/client/sidebar/SpaceTabs.tsx
@@ -10,20 +10,8 @@ import React, {
useState,
} from 'react';
import { useNavigateWithTransition } from '../../../hooks/useViewTransitions';
-import {
- Box,
- Icon,
- IconButton,
- Icons,
- Line,
- Menu,
- MenuItem,
- PopOut,
- RectCords,
- Text,
- config,
- toRem,
-} from 'folds';
+import { Box, IconButton, Line, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { useAtom, useAtomValue } from 'jotai';
import { Room } from 'matrix-js-sdk';
import {
diff --git a/src/app/pages/client/sidebar/UnverifiedTab.tsx b/src/app/pages/client/sidebar/UnverifiedTab.tsx
index 0a2f117..759b9a2 100644
--- a/src/app/pages/client/sidebar/UnverifiedTab.tsx
+++ b/src/app/pages/client/sidebar/UnverifiedTab.tsx
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
-import { Badge, color, Icon, Icons, Text } from 'folds';
+import { Badge, color, Text } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import {
SidebarAvatar,
SidebarItem,
diff --git a/src/app/pages/client/space/Search.tsx b/src/app/pages/client/space/Search.tsx
index 017262b..7744f51 100644
--- a/src/app/pages/client/space/Search.tsx
+++ b/src/app/pages/client/space/Search.tsx
@@ -1,5 +1,6 @@
import React, { useRef } from 'react';
-import { Box, Icon, Icons, Text, Scroll, IconButton } from 'folds';
+import { Box, Text, Scroll, IconButton } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { useAtomValue } from 'jotai';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { MessageSearch } from '../../../features/message-search';
diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx
index 5ab3f2e..1a86b1d 100644
--- a/src/app/pages/client/space/Space.tsx
+++ b/src/app/pages/client/space/Space.tsx
@@ -7,24 +7,8 @@ import React, {
useState,
} from 'react';
import { useAtom, useAtomValue } from 'jotai';
-import {
- Avatar,
- Box,
- Button,
- Icon,
- IconButton,
- Icons,
- Line,
- Menu,
- MenuItem,
- PopOut,
- RectCords,
- Spinner,
- Text,
- color,
- config,
- toRem,
-} from 'folds';
+import { Avatar, Box, Button, IconButton, Line, Menu, MenuItem, PopOut, RectCords, Spinner, Text, color, config, toRem } from 'folds';
+import { Icon, Icons } from '../../../components/icons';
import { useVirtualizer } from '@tanstack/react-virtual';
import { JoinRule, Room, Thread, ThreadEvent } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
@@ -87,6 +71,8 @@ import { BreakWord } from '../../../styles/Text.css';
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
import { isSpace, shouldShowForumLobby } from '../../../utils/room';
import { SpaceOptionsMenu } from '../../../features/space/SpaceOptionsMenu';
+import { ForumFeedSidebar } from '../../../features/forum/ForumFeedSidebar';
+import * as forumBoardCss from '../../../features/forum/ForumBoardView.css';
function SpaceHeader() {
const space = useSpace();
@@ -350,7 +336,14 @@ export function Space() {
getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
if (shouldShowForumLobby(space)) {
- return null;
+ return (
+
+
+
+
+
+
+ );
}
return (
diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx
index 723e509..da484bd 100644
--- a/src/app/plugins/react-custom-html-parser.tsx
+++ b/src/app/plugins/react-custom-html-parser.tsx
@@ -17,7 +17,8 @@ import {
} from 'html-react-parser';
import { MatrixClient } from 'matrix-js-sdk';
import classNames from 'classnames';
-import { Box, Chip, config, Header, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds';
+import { Box, Chip, config, Header, IconButton, Scroll, Text, toRem } from 'folds';
+import { Icon, Icons } from '../components/icons';
import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs';
import Linkify from 'linkify-react';
import { ErrorBoundary } from 'react-error-boundary';
diff --git a/src/app/utils/common.ts b/src/app/utils/common.ts
index 90bc219..aa6570b 100644
--- a/src/app/utils/common.ts
+++ b/src/app/utils/common.ts
@@ -1,4 +1,5 @@
-import { IconName, IconSrc } from 'folds';
+import { IconName, IconSrc } from '../components/icons';
+
export const bytesToSize = (bytes: number): string => {
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts
index a864cde..c548a8d 100644
--- a/src/app/utils/room.ts
+++ b/src/app/utils/room.ts
@@ -1,4 +1,4 @@
-import { IconName, IconSrc } from 'folds';
+import { IconName, IconSrc } from '../components/icons';
import {
EventTimeline,