refactor: Consolidate icon imports across components and streamline import statements for improved readability

This commit is contained in:
2026-07-06 03:29:18 +10:00
parent 1f71fd7a51
commit 09db721b7e
221 changed files with 3472 additions and 2128 deletions

View File

@@ -67,11 +67,7 @@ export function ForumAuthorIdentity({
<WrapTag className={theme.ForumSenderLabel} title={sender}>
{name}
</WrapTag>
{server ? (
<span className={theme.ForumAuthorServer} title="Matrix homeserver">
{server}
</span>
) : null}
{server ? <span className={theme.ForumAuthorServer}>{server}</span> : null}
</span>
</div>
);

View File

@@ -1,44 +1,14 @@
import React, { CSSProperties } from 'react';
import { Outlet } from 'react-router-dom';
import { Box } from 'folds';
import classNames from 'classnames';
import { useSpace } from '../../hooks/useSpace';
import React from 'react';
import { PageRoot } from '../../components/page';
import { AnimatedOutlet } from '../../components/AnimatedOutlet';
import { MobileFriendlyPageNav } from '../../pages/MobileFriendly';
import { SPACE_PATH } from '../../pages/paths';
import { Space } from '../../pages/client/space/Space';
import { shouldShowForumLobby } from '../../utils/room';
import { ContainerColor } from '../../styles/ContainerColor.css';
import * as theme from './forumTheme.css';
const forumOutletShell: CSSProperties = {
display: 'flex',
flex: 1,
flexDirection: 'column',
minHeight: 0,
minWidth: 0,
overflow: 'hidden',
};
/**
* Forum: server list only — forum fills main pane.
* Other spaces: space sidebar + outlet.
* Space sidebar + outlet. Forum spaces render ForumFeedSidebar in Space.tsx.
*/
export function ForumAwareSpaceLayout() {
const space = useSpace();
const isForum = shouldShowForumLobby(space);
if (isForum) {
return (
<Box grow="Yes" className={classNames(ContainerColor({ variant: 'Background' }), theme.ForumAppRoot)}>
<div style={forumOutletShell}>
<Outlet />
</div>
</Box>
);
}
return (
<PageRoot
nav={

View File

@@ -1,38 +1,39 @@
import React, { useEffect, useRef } from 'react';
import { Box, Icon, Icons, Text } from 'folds';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Scroll, Text, color } from 'folds';
import { Icon, Icons } from '../../components/icons';
import classNames from 'classnames';
import { useSetAtom } from 'jotai';
import { Page, PageContent } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
import { ThreadView } from '../room/ThreadView';
import { RoomInput } from '../room/RoomInput';
import { useEditor } from '../../components/editor';
import { Page, PageContent } from '../../components/page';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { useForumBoardContext } from './ForumBoardContext';
import * as css from './ForumBoardView.css';
import { ForumThreadDetail } from './ForumThreadDetail';
import type { ForumPost } from './types';
import * as theme from './forumTheme.css';
/** Thread detail + composer (main pane); post list lives in ForumFeedSidebar. */
/** Thread detail in the main pane; post list lives in ForumFeedSidebar. */
export function ForumBoardDetail() {
const mx = useMatrixClient();
const editor = useEditor();
const detailScrollRef = useRef<HTMLDivElement>(null);
const [optimisticPost, setOptimisticPost] = useState<ForumPost | null>(null);
const {
forumSpace,
query,
error,
selectedPostId,
activeTopicRoomId,
error,
threads,
recordThreadReply,
} = useForumBoardContext();
const activeTopicRoom = activeTopicRoomId ? mx.getRoom(activeTopicRoomId) : null;
const topicPowerLevels = usePowerLevels(activeTopicRoom ?? forumSpace);
const topicSelected = Boolean(query.topic);
const setActiveThreadId = useSetAtom(
activeThreadIdAtomFamily(activeTopicRoomId ?? forumSpace.roomId)
);
const topicSelected = Boolean(query.topic);
useEffect(() => {
if (selectedPostId && activeTopicRoomId) {
setActiveThreadId(selectedPostId);
@@ -42,51 +43,80 @@ export function ForumBoardDetail() {
return undefined;
}, [selectedPostId, activeTopicRoomId, setActiveThreadId]);
const showComposer = Boolean(activeTopicRoom && topicSelected);
useEffect(() => {
if (optimisticPost && optimisticPost.eventId !== selectedPostId) {
setOptimisticPost(null);
}
}, [optimisticPost, selectedPostId]);
const selectedThread = useMemo(
() => threads.find((t) => t.eventId === selectedPostId),
[threads, selectedPostId]
);
const handleReplySent = useCallback(
(_reply: ForumPost) => {
if (selectedPostId) {
recordThreadReply(selectedPostId);
}
},
[recordThreadReply, selectedPostId]
);
const showThread = Boolean(selectedPostId && activeTopicRoom);
const threadInitialPost =
optimisticPost?.eventId === selectedPostId ? optimisticPost : null;
const detailDropRef = useRef<HTMLDivElement>(null);
return (
<Page>
<PageContent>
<Box className={css.ForumDetailOnlyRoot}>
{error && (
<Box style={{ padding: '0.5rem 1rem' }}>
<Text style={{ color: 'var(--fc-critical, #f38ba8)' }}>{error}</Text>
</Box>
<Page className={classNames(ContainerColor({ variant: 'Background' }), theme.ForumAppRoot)}>
<PageContent className={theme.ForumPageContent}>
{error && (
<Text as="p" style={{ color: color.Critical.Main, padding: '0.5rem 1rem' }}>
{error}
</Text>
)}
<aside className={theme.ForumDetailPane}>
{showThread && activeTopicRoom ? (
<div ref={detailDropRef} className={theme.ForumTopicPostDetailThreadLayout}>
<ForumThreadDetail
roomId={activeTopicRoom.roomId}
rootEventId={selectedPostId!}
titleHint={selectedThread?.title}
initialPost={threadInitialPost}
fileDropContainerRef={detailDropRef}
onReplySent={handleReplySent}
/>
</div>
) : (
<Scroll
id="topicPostDetail"
className={theme.ForumTopicPostDetailScroll}
variant="Surface"
direction="Vertical"
size="300"
hideTrack
visibility="Hover"
>
<div className={theme.ForumTopicPostDetailInner}>
<Box
className={theme.ForumEmptyDetail}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
>
<Icon src={Icons.Thread} size="600" />
<Text size="T300" priority="300">
{topicSelected
? 'Select a post to read the thread'
: 'Pick a category and topic, then choose a post'}
</Text>
</Box>
</div>
</Scroll>
)}
<Box ref={detailScrollRef} className={css.ForumDetailPanelFull}>
{selectedPostId && activeTopicRoom ? (
<PowerLevelsContextProvider value={topicPowerLevels}>
<ThreadView room={activeTopicRoom} threadRootId={selectedPostId} />
</PowerLevelsContextProvider>
) : (
<Box grow="Yes" alignItems="Center" justifyContent="Center" direction="Column" gap="200">
<Icon src={Icons.Thread} size="600" />
<Text priority="300">
{topicSelected
? 'Select a post from the list'
: 'Choose a topic in the sidebar'}
</Text>
</Box>
)}
{showComposer && activeTopicRoom && (
<Box
shrink="No"
direction="Column"
style={{ padding: '0.75rem', borderTop: '1px solid var(--fc-surface-container)' }}
>
<PowerLevelsContextProvider value={topicPowerLevels}>
<RoomInput
room={activeTopicRoom}
editor={editor}
roomId={activeTopicRoom.roomId}
fileDropContainerRef={detailScrollRef}
/>
</PowerLevelsContextProvider>
</Box>
)}
</Box>
</Box>
</aside>
</PageContent>
</Page>
);

View File

@@ -1,4 +1,4 @@
import { style } from '@vanilla-extract/css';
import { style, globalStyle } from '@vanilla-extract/css';
import { color, config } from 'folds';
const border = color.Surface.Container;
@@ -292,6 +292,14 @@ export const ForumPageNav = style({
width: 'min(400px, 38vw)',
minWidth: '300px',
maxWidth: '420px',
height: '100%',
minHeight: 0,
});
/** PageNav column must shrink so the feed list can scroll inside. */
globalStyle(`${ForumPageNav} > div`, {
minHeight: 0,
height: '100%',
});
export const ForumFeedSidebar = style({

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { Box, Icon, Icons, Text } from 'folds';
import { Box, Text } from 'folds';
import { Icon, Icons } from '../../components/icons';
import { Room } from 'matrix-js-sdk';
import { useSetAtom } from 'jotai';
import { Page, PageContent } from '../../components/page';

View File

@@ -2,7 +2,8 @@ import React, { KeyboardEventHandler, useCallback, useRef, useState } from 'reac
import { isKeyHotkey } from 'is-hotkey';
import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import { Icon, IconButton, Icons, Line, PopOut } from 'folds';
import { IconButton, Line, PopOut } from 'folds';
import { Icon, Icons } from '../../components/icons';
import {
CustomEditor,
Toolbar,

View File

@@ -0,0 +1,64 @@
import React, { useCallback, useMemo } from 'react';
import { EventType, Room } from 'matrix-js-sdk';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { getEditedEvent } from '../../utils/room';
import { GetContentCallback } from '../../../types/matrix/room';
import { ForumMessageBody } from './ForumMessageBody';
import { useForumMessageRenderOptions } from './useForumMessageRenderOptions';
import * as theme from './forumTheme.css';
type ForumEventContentProps = {
room: Room | null;
eventId: string;
body: string;
bodyHtml?: string;
senderDisplayName?: string;
};
/** Renders a forum post/reply with the same media + rich text pipeline as room messages. */
export function ForumEventContent({
room,
eventId,
body,
bodyHtml,
senderDisplayName,
}: ForumEventContentProps) {
const renderOptions = useForumMessageRenderOptions(room);
const event = room?.findEventById(eventId);
const timelineSet = room?.getUnfilteredTimelineSet();
const editedEvent = useMemo(() => {
if (!event || !timelineSet) return undefined;
return getEditedEvent(eventId, event, timelineSet);
}, [event, eventId, timelineSet]);
const getContent = useCallback<GetContentCallback>(
() => editedEvent?.getContent()['m.new_content'] ?? event?.getContent() ?? { body, msgtype: 'm.text' },
[editedEvent, event, body]
);
if (!event || event.getType() !== EventType.RoomMessage) {
return <ForumMessageBody body={body} bodyHtml={bodyHtml} />;
}
const content = getContent();
const msgType = typeof content.msgtype === 'string' ? content.msgtype : 'm.text';
return (
<div className={theme.ForumEventContentRoot}>
<RenderMessageContent
displayName={senderDisplayName ?? event.getSender() ?? ''}
msgType={msgType}
ts={event.getTs()}
edited={Boolean(editedEvent)}
getContent={getContent}
mediaAutoLoad={renderOptions.mediaAutoLoad}
urlPreview={renderOptions.showUrlPreview}
htmlReactParserOptions={renderOptions.htmlReactParserOptions}
linkifyOpts={renderOptions.linkifyOpts}
outlineAttachment={renderOptions.outlineAttachment}
disabledEmbedPatterns={renderOptions.disabledEmbedPatterns}
/>
</div>
);
}

View File

@@ -1,44 +1,21 @@
import React, { useCallback } from 'react';
import { Box, Text } from 'folds';
import { Text, color } from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getSpaceLobbyPath } from '../../pages/pathUtils';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { useSpace } from '../../hooks/useSpace';
import {
NavCategory,
NavCategoryHeader,
NavItem,
NavItemContent,
NavLink,
} from '../../components/nav';
import { RoomNavCategoryButton } from '../room-nav';
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
import { makeNavCategoryId } from '../../state/closedNavCategories';
import { useAtom } from 'jotai';
import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategories';
import { usePowerLevels } from '../../hooks/usePowerLevels';
import { PowerLevelsContextProvider } from '../../hooks/usePowerLevels';
import { useForumBoardContext } from './ForumBoardContext';
import type { ForumPublishedPost } from './types';
import { ForumFilterBar } from './ForumFilterBar';
import { ForumNewPostBox } from './ForumNewPostBox';
import { ForumPostList } from './ForumPostList';
import * as css from './ForumBoardView.css';
import { ForumNewPostBox } from './ForumNewPostBox';
import type { ForumPublishedPost } from './types';
import * as theme from './forumTheme.css';
/**
* matrixsso-style left column: category/topic nav + filters + post list.
* Renders inside the space PageNav instead of Lobby / ROOMS.
*/
/** Categories, filters, and post list in the space sidebar (matrixsso-style). */
export function ForumFeedSidebar() {
const mx = useMatrixClient();
const space = useSpace();
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
const lobbyPath = getSpaceLobbyPath(spaceIdOrAlias);
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
const {
forumSpace,
sections,
threads,
query,
@@ -47,6 +24,7 @@ export function ForumFeedSidebar() {
error,
selectedPostId,
setQueryParam,
setCategory,
selectPost,
loadMorePosts,
hasMorePosts,
@@ -54,20 +32,7 @@ export function ForumFeedSidebar() {
insertThreadFromNewPost,
} = useForumBoardContext();
const topicSelected = Boolean(query.topic);
const lobbyBase = lobbyPath;
const buildLobbyTo = useCallback(
(params: Record<string, string | undefined>) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v) sp.set(k, v);
});
const q = sp.toString();
return q ? `${lobbyBase}?${q}` : lobbyBase;
},
[lobbyBase]
);
const spacePowerLevels = usePowerLevels(forumSpace);
const getTopicRoom = useCallback(
(roomId: string | undefined) => (roomId ? mx.getRoom(roomId) : null),
@@ -83,103 +48,44 @@ export function ForumFeedSidebar() {
);
return (
<Box className={css.ForumFeedSidebar} direction="Column" gap="200">
{error && (
<Text size="T200" style={{ color: 'var(--fc-critical, #f38ba8)' }}>
{error}
</Text>
)}
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={!query.topic && !query.category}>
<NavLink to={buildLobbyTo({})}>
<NavItemContent>
<Text size="L400">All posts</Text>
</NavItemContent>
</NavLink>
</NavItem>
</NavCategory>
{sections.map((section) => {
const categoryId = makeNavCategoryId(space.roomId, `forum-${section.title}`);
const closed = closedCategories.has(categoryId);
const categoryActive = query.category === section.title;
return (
<NavCategory key={section.title}>
<NavCategoryHeader>
<RoomNavCategoryButton
data-category-id={categoryId}
onClick={handleCategoryClick}
closed={closed}
>
{section.title}
</RoomNavCategoryButton>
</NavCategoryHeader>
{!closed && (
<>
<NavItem variant="Background" radii="400" aria-selected={categoryActive && !query.topic}>
<NavLink to={buildLobbyTo({ category: section.title })}>
<NavItemContent>
<Text size="T200" priority="300">
All topics
</Text>
</NavItemContent>
</NavLink>
</NavItem>
{section.topics.map((topic) => (
<NavItem
key={topic.roomId}
variant="Background"
radii="400"
aria-selected={query.topic === topic.roomId}
>
<NavLink to={buildLobbyTo({ category: section.title, topic: topic.roomId })}>
<NavItemContent>
<Text size="L400" truncate>
{topic.name}
</Text>
</NavItemContent>
</NavLink>
</NavItem>
))}
</>
)}
</NavCategory>
);
})}
<Box className={css.ForumFiltersCard} shrink="No" style={{ padding: 0, border: 'none' }}>
<ForumFilterBar
sections={sections}
threads={threads}
query={query}
hideCategoryTopic
onQueryChange={setQueryParam}
/>
</Box>
<Box className={css.ForumListCard} grow="Yes" style={{ padding: 0, border: 'none', minHeight: 0 }}>
<ForumNewPostBox
sections={sections}
postCount={threads.length}
loading={loadingSections || loadingThreads}
hasMore={hasMorePosts}
defaultCategory={query.category}
defaultTopicRoomId={query.topic}
onPublished={handlePostPublished}
/>
<ForumPostList
threads={threads}
selectedPostId={selectedPostId}
loading={loadingSections || loadingThreads}
getTopicRoom={getTopicRoom}
onSelectPost={selectPost}
hasMore={hasMorePosts}
loadingMore={loadingMore}
onLoadMore={loadMorePosts}
/>
</Box>
</Box>
<PowerLevelsContextProvider value={spacePowerLevels}>
<div className={theme.ForumFeedSidebarRoot}>
<section className={theme.ForumFiltersCard}>
<ForumFilterBar
sections={sections}
threads={threads}
query={query}
onQueryChange={setQueryParam}
onCategoryChange={setCategory}
/>
</section>
<section className={theme.ForumListCard}>
{error && (
<Text as="p" className={theme.ForumListLoading} style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
<ForumNewPostBox
sections={sections}
postCount={threads.length}
loading={loadingSections || loadingThreads}
hasMore={hasMorePosts}
defaultCategory={query.category}
defaultTopicRoomId={query.topic}
onPublished={handlePostPublished}
/>
<ForumPostList
threads={threads}
selectedPostId={selectedPostId}
loading={loadingSections || loadingThreads}
getTopicRoom={getTopicRoom}
onSelectPost={selectPost}
hasMore={hasMorePosts}
loadingMore={loadingMore}
onLoadMore={loadMorePosts}
/>
</section>
</div>
</PowerLevelsContextProvider>
);
}

View File

@@ -1,5 +1,6 @@
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
import { Icon, IconButton, Text, Tooltip, TooltipProvider } from 'folds';
import { IconButton, Text, Tooltip, TooltipProvider } from 'folds';
import { Icon } from '../../components/icons';
import type { ForumBoardQuery, ForumSection, ForumThreadSummary } from './types';
import { ForumSortIcons } from './forumLucideIcons';
import { ForumTopicSearchInput } from './ForumTopicSearchInput';

View File

@@ -1,29 +1,175 @@
import React from 'react';
import { Icon, Icons } from 'folds';
import React, { MouseEventHandler, useEffect, useRef, useState } from 'react';
import { Box, IconButton, Menu, PopOut } from 'folds';
import { Icon, Icons } from '../../components/icons';
import { Room } from 'matrix-js-sdk';
import { EmojiBoard } from '../../components/emoji-board';
import { copyToClipboard } from '../../utils/dom';
import { PluginButtonSlot } from '../settings/plugins/PluginButtonSlot';
import { useForumMessageMenu } from './ForumMessageMenuContext';
import * as theme from './forumTheme.css';
type ForumMessageActionsProps = {
imagePackRooms: Room[];
eventId?: string;
body?: string;
canSendReaction?: boolean;
onReactionToggle?: (targetEventId: string, key: string, shortcode?: string) => void;
onReply: () => void;
replyActive?: boolean;
onEdit?: () => void;
canEdit?: boolean;
onDelete?: () => void;
canDelete?: boolean;
};
export function ForumMessageActions({ onReply, replyActive }: ForumMessageActionsProps) {
export function ForumMessageActions({
imagePackRooms,
eventId,
body,
canSendReaction,
onReactionToggle,
onReply,
replyActive,
onEdit,
canEdit,
onDelete,
canDelete,
}: ForumMessageActionsProps) {
const menu = useForumMessageMenu();
const emojiBtnRef = useRef<HTMLButtonElement>(null);
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<DOMRect>();
const [shiftHeld, setShiftHeld] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') setShiftHeld(true);
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') setShiftHeld(false);
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
const handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement> = () => {
setEmojiBoardAnchor(emojiBtnRef.current?.getBoundingClientRect());
};
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget.parentElement ?? evt.currentTarget;
menu?.openMenu(target.getBoundingClientRect());
};
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.stopPropagation();
onReply();
};
return (
<div className={theme.ForumMessageActions}>
<button
type="button"
className={
replyActive
? `${theme.ForumMessageActionBtn} ${theme.ForumMessageActionBtnActive}`
: theme.ForumMessageActionBtn
}
onClick={onReply}
<Menu className={theme.ForumMessageOptionsBar} variant="SurfaceVariant">
<Box gap="100" direction="Row" alignItems="Center">
{canSendReaction && eventId && onReactionToggle && (
<PopOut
position="Bottom"
align="End"
anchor={emojiBoardAnchor}
content={
<EmojiBoard
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
allowTextCustomEmoji
onEmojiSelect={(key) => {
onReactionToggle(eventId, key);
setEmojiBoardAnchor(undefined);
}}
onCustomEmojiSelect={(mxc, shortcode) => {
onReactionToggle(eventId, mxc, shortcode);
setEmojiBoardAnchor(undefined);
}}
requestClose={() => setEmojiBoardAnchor(undefined)}
/>
}
>
<IconButton
ref={emojiBtnRef}
onClick={handleOpenEmojiBoard}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Add reaction"
title="Add reaction"
aria-pressed={!!emojiBoardAnchor}
>
<Icon src={Icons.SmilePlus} size="100" />
</IconButton>
</PopOut>
)}
<IconButton
onClick={handleReplyClick}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Reply"
title="Reply"
aria-pressed={replyActive}
className={replyActive ? theme.ForumMessageActionBtnActive : undefined}
>
<Icon className={theme.ForumMessageActionIcon} src={Icons.ReplyArrow} size="200" />
</button>
</div>
<Icon src={Icons.ReplyArrow} size="100" />
</IconButton>
{canEdit && onEdit && (
<IconButton
onClick={onEdit}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Edit"
title="Edit"
>
<Icon src={Icons.Pencil} size="100" />
</IconButton>
)}
{body && (
<IconButton
onClick={() => copyToClipboard(body)}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Copy raw text"
title="Copy raw text"
>
<Icon src={Icons.File} size="100" />
</IconButton>
)}
{shiftHeld && canDelete && onDelete && (
<IconButton
onClick={onDelete}
variant="Critical"
size="300"
radii="300"
aria-label="Delete"
title="Delete message (Shift held)"
>
<Icon src={Icons.Delete} size="100" />
</IconButton>
)}
<PluginButtonSlot location="message-actions" />
{menu && (
<IconButton
onClick={handleOpenMenu}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Message menu"
title="More actions"
>
<Icon src={Icons.VerticalDots} size="100" />
</IconButton>
)}
</Box>
</Menu>
);
}

View File

@@ -0,0 +1,256 @@
import React, { MouseEventHandler, ReactNode, useCallback, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import {
Box,
Line,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
} from 'folds';
import { Icon, Icons } from '../../components/icons';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { stopPropagation } from '../../utils/keyboard';
import { EmojiBoard } from '../../components/emoji-board';
import {
MessageAllReactionItem,
MessageCopyLinkItem,
MessageCopyRawTextItem,
MessageDeleteItem,
MessagePinItem,
MessageQuickReactions,
MessageReadReceiptItem,
MessageReportItem,
MessageSourceCodeItem,
} from '../room/message/Message';
import * as menuCss from '../room/message/styles.css';
import { ForumMessageMenuProvider } from './ForumMessageMenuContext';
import { useForumMessageReactions } from './useForumMessageReactions';
type ForumMessageContextMenuProps = {
room: Room;
mEvent: MatrixEvent | undefined;
imagePackRooms: Room[];
disabled?: boolean;
canEdit?: boolean;
canDelete?: boolean;
onReply: () => void;
onEdit?: () => void;
onDeleted?: () => void;
className?: string;
children: ReactNode;
};
export function ForumMessageContextMenu({
room,
mEvent,
imagePackRooms,
disabled,
canEdit,
canDelete,
onReply,
onEdit,
onDeleted,
className,
children,
}: ForumMessageContextMenuProps) {
const mx = useMatrixClient();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const {
canSendReaction,
canPinEvent,
reactionRelations,
hasReactions,
handleReactionToggle,
eventId,
} = useForumMessageReactions(room, mEvent);
const openMenu = useCallback((anchor: RectCords) => {
setMenuAnchor(anchor);
}, []);
const closeMenu = useCallback(() => {
setMenuAnchor(undefined);
}, []);
const handleContextMenu: MouseEventHandler<HTMLDivElement> = useCallback(
(evt) => {
if (disabled || !mEvent || evt.altKey || !window.getSelection()?.isCollapsed) return;
const tag = (evt.target as HTMLElement).tagName;
if (tag.toLowerCase() === 'a' || tag.toLowerCase() === 'img') return;
evt.preventDefault();
setMenuAnchor({
x: evt.clientX,
y: evt.clientY,
width: 0,
height: 0,
});
},
[disabled, mEvent]
);
const handleAddReactions: MouseEventHandler<HTMLButtonElement> = useCallback(() => {
const rect = menuAnchor;
closeMenu();
setTimeout(() => {
setEmojiBoardAnchor(rect);
}, 100);
}, [closeMenu, menuAnchor]);
if (!mEvent) {
return <div className={className}>{children}</div>;
}
return (
<ForumMessageMenuProvider openMenu={openMenu}>
<div className={className} onContextMenu={handleContextMenu}>
{children}
<PopOut
anchor={menuAnchor}
position="Bottom"
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
offset={menuAnchor?.width === 0 ? 0 : undefined}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: closeMenu,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
{canSendReaction && (
<MessageQuickReactions
onReaction={(key, shortcode) => {
if (eventId) handleReactionToggle(eventId, key, shortcode);
closeMenu();
}}
/>
)}
<Box direction="Column" gap="100" className={menuCss.MessageMenuGroup}>
{canSendReaction && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.SmilePlus} />}
radii="300"
onClick={handleAddReactions}
>
<Text className={menuCss.MessageMenuItemText} as="span" size="T300" truncate>
Add Reaction
</Text>
</MenuItem>
)}
{hasReactions && reactionRelations && (
<MessageAllReactionItem
room={room}
relations={reactionRelations}
onClose={closeMenu}
/>
)}
<MenuItem
size="300"
after={<Icon size="100" src={Icons.ReplyArrow} />}
radii="300"
onClick={() => {
onReply();
closeMenu();
}}
>
<Text className={menuCss.MessageMenuItemText} as="span" size="T300" truncate>
Reply
</Text>
</MenuItem>
{canEdit && onEdit && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Pencil} />}
radii="300"
onClick={() => {
onEdit();
closeMenu();
}}
>
<Text className={menuCss.MessageMenuItemText} as="span" size="T300" truncate>
Edit Message
</Text>
</MenuItem>
)}
{!hideActivity && eventId && (
<MessageReadReceiptItem room={room} eventId={eventId} onClose={closeMenu} />
)}
{developerTools && (
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
<MessageCopyRawTextItem mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && (
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
</Box>
{((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && (
<>
<Line size="300" />
<Box direction="Column" gap="100" className={menuCss.MessageMenuGroup}>
{!mEvent.isRedacted() && canDelete && (
<MessageDeleteItem
room={room}
mEvent={mEvent}
onClose={() => {
closeMenu();
onDeleted?.();
}}
/>
)}
{mEvent.getSender() !== mx.getUserId() && (
<MessageReportItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
</Box>
</>
)}
</Menu>
</FocusTrap>
}
/>
{canSendReaction && (
<PopOut
position="Bottom"
align={emojiBoardAnchor?.width === 0 ? 'Start' : 'End'}
offset={emojiBoardAnchor?.width === 0 ? 0 : undefined}
anchor={emojiBoardAnchor}
content={
<EmojiBoard
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
allowTextCustomEmoji
onEmojiSelect={(key) => {
if (eventId) handleReactionToggle(eventId, key);
setEmojiBoardAnchor(undefined);
}}
onCustomEmojiSelect={(mxc, shortcode) => {
if (eventId) handleReactionToggle(eventId, mxc, shortcode);
setEmojiBoardAnchor(undefined);
}}
requestClose={() => setEmojiBoardAnchor(undefined)}
/>
}
>
<span hidden aria-hidden />
</PopOut>
)}
</div>
</ForumMessageMenuProvider>
);
}
export { useForumMessageReactions };

View File

@@ -0,0 +1,26 @@
import React, { createContext, useContext } from 'react';
import { RectCords } from 'folds';
type ForumMessageMenuContextValue = {
openMenu: (anchor: RectCords) => void;
};
const ForumMessageMenuContext = createContext<ForumMessageMenuContextValue | null>(null);
export function ForumMessageMenuProvider({
openMenu,
children,
}: {
openMenu: (anchor: RectCords) => void;
children: React.ReactNode;
}) {
return (
<ForumMessageMenuContext.Provider value={{ openMenu }}>
{children}
</ForumMessageMenuContext.Provider>
);
}
export function useForumMessageMenu() {
return useContext(ForumMessageMenuContext);
}

View File

@@ -1,7 +1,8 @@
import React, { useCallback, useState } from 'react';
import { Box, Button, Icon, Icons, Text } from 'folds';
import { Box, Button, Text } from 'folds';
import { Icon, Icons } from '../../components/icons';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import type { ForumPost, ForumPublishedPost } from './types';
import type { ForumPost, ForumPublishedPost, ForumSection } from './types';
import { ForumNewPostModal } from './ForumNewPostModal';
import * as t from './forumTheme.css';

View File

@@ -1,24 +1,8 @@
import React, { FormEventHandler, KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { isKeyHotkey } from 'is-hotkey';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Header,
Icon,
IconButton,
Icons,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Line,
Scroll,
Text,
color,
config,
toRem,
} from 'folds';
import { Box, Button, Header, IconButton, Modal, Overlay, OverlayBackdrop, OverlayCenter, Line, Scroll, Text, color, config, toRem } from 'folds';
import { Icon, Icons } from '../../components/icons';
import {
CustomEditor,
Toolbar,
@@ -32,10 +16,11 @@ import {
} from '../../components/editor';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { sendForumPost } from './forumFeed';
import { sendForumPost, sendForumMediaRoot, sendForumThreadAttachment } from './forumFeed';
import { FORUM_EDITOR_OUTPUT_OPTS } from './forumRichText';
import type { ForumPublishedPost, ForumSection } from './types';
import { ForumTopicTargetFields } from './ForumTopicTargetFields';
import { ForumPostUploadField, useForumPostUploads } from './ForumPostUploadField';
import * as t from './forumTheme.css';
type ForumNewPostModalProps = {
@@ -61,6 +46,14 @@ export function ForumNewPostModal({
const [topicRoomId, setTopicRoomId] = useState(initialTopicRoomId);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const uploadRoomKey = topicRoomId || '__forum_new_post_draft__';
const {
selectedFiles,
uploadBoardHandlers,
handleUploadsReady,
collectUploadContents,
clearUploads,
} = useForumPostUploads(uploadRoomKey);
const categoryTopics = useMemo(() => {
if (category) {
@@ -80,8 +73,9 @@ export function ForumNewPostModal({
if (sending) return;
resetEditor(editor);
resetEditorHistory(editor);
clearUploads();
onClose();
}, [editor, onClose, sending]);
}, [clearUploads, editor, onClose, sending]);
const handleSubmit: FormEventHandler<HTMLFormElement> = useCallback(
async (evt) => {
@@ -94,6 +88,9 @@ export function ForumNewPostModal({
toMatrixCustomHTML(editor.children, FORUM_EDITOR_OUTPUT_OPTS)
);
const hasText = !isEmptyEditor(editor);
const hasUploads = selectedFiles.length > 0;
if (!topicRoomId) {
setError('Select a topic for this post.');
return;
@@ -102,27 +99,53 @@ export function ForumNewPostModal({
setError('Post title is required.');
return;
}
if (isEmptyEditor(editor)) {
setError('Post body is required.');
if (!hasText && !hasUploads) {
setError('Post body or an attachment is required.');
return;
}
setSending(true);
setError(null);
try {
const eventId = await sendForumPost(mx, topicRoomId, {
title: trimmedTitle,
plainText,
formattedHtml,
});
const attachmentContents = hasUploads ? await collectUploadContents() : [];
let eventId: string;
let publishedPlainText = plainText;
let publishedHtml = formattedHtml;
if (hasText) {
eventId = await sendForumPost(mx, topicRoomId, {
title: trimmedTitle,
plainText,
formattedHtml,
});
} else {
const [first, ...rest] = attachmentContents;
if (!first) {
throw new Error('Attachments failed to upload.');
}
eventId = await sendForumMediaRoot(mx, topicRoomId, trimmedTitle, first);
publishedPlainText = typeof first.body === 'string' ? first.body : trimmedTitle;
publishedHtml = '';
for (const content of rest) {
await sendForumThreadAttachment(mx, topicRoomId, eventId, content);
}
}
if (hasText) {
for (const content of attachmentContents) {
await sendForumThreadAttachment(mx, topicRoomId, eventId, content);
}
}
resetEditor(editor);
resetEditorHistory(editor);
clearUploads();
onPublished?.({
eventId,
topicRoomId,
title: trimmedTitle,
plainText,
formattedHtml,
plainText: publishedPlainText,
formattedHtml: publishedHtml,
});
onClose();
} catch (err) {
@@ -131,7 +154,18 @@ export function ForumNewPostModal({
setSending(false);
}
},
[editor, mx, onClose, onPublished, sending, title, topicRoomId]
[
clearUploads,
collectUploadContents,
editor,
mx,
onClose,
onPublished,
selectedFiles.length,
sending,
title,
topicRoomId,
]
);
const handleEditorKeyDown: KeyboardEventHandler = useCallback(
@@ -230,7 +264,16 @@ export function ForumNewPostModal({
bottom={
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
<Box alignItems="Center" gap="200" style={{ padding: `0 ${config.space.S200}` }}>
<ForumPostUploadField
roomId={uploadRoomKey}
disabled={sending || !topicRoomId}
uploadBoardHandlers={uploadBoardHandlers}
onUploadsReady={handleUploadsReady}
/>
<Box grow="Yes" />
<Toolbar />
</Box>
</div>
}
/>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
import React, { KeyboardEventHandler } from 'react';
import { Box, Button, Scroll, Spinner, Text } from 'folds';
import { Icon, Icons } from '../../components/icons';
import { Room } from 'matrix-js-sdk';
import type { ForumThreadSummary } from './types';
import { formatForumTimeAgo } from './forumTime';
@@ -38,7 +39,14 @@ export function ForumPostList({
}: ForumPostListProps) {
return (
<>
<div className={t.ForumPostScroll}>
<Scroll
className={t.ForumPostScroll}
variant="Background"
direction="Vertical"
size="300"
hideTrack
visibility="Hover"
>
{loading && threads.length === 0 && (
<Box alignItems="Center" gap="200" style={{ padding: '1rem' }}>
<Spinner size="200" />
@@ -56,14 +64,24 @@ export function ForumPostList({
{threads.map((thread) => {
const isActive = thread.eventId === selectedPostId;
const topicRoom = getTopicRoom(thread.topicRoomId);
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelectPost(thread.eventId, thread.topicRoomId);
}
};
return (
<li key={thread.eventId} className={t.ForumPostItem}>
<button
type="button"
<div
role="button"
tabIndex={0}
className={t.ForumPostLink}
data-active={isActive ? 'true' : undefined}
data-pinned={thread.isPinned ? 'true' : undefined}
onClick={() => onSelectPost(thread.eventId, thread.topicRoomId)}
onKeyDown={handleKeyDown}
>
<div className={t.ForumPostHead}>
<div className={t.ForumPostHeadMain}>
@@ -99,12 +117,12 @@ export function ForumPostList({
</Text>
</div>
</div>
</button>
</div>
</li>
);
})}
</ul>
</div>
</Scroll>
{hasMore && onLoadMore && (
<div className={t.ForumListFooter}>
<Button

View File

@@ -0,0 +1,248 @@
import React, { MutableRefObject, useCallback, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import { IContent, MatrixClient } from 'matrix-js-sdk';
import { Box, IconButton, Scroll } from 'folds';
import { Icon, Icons } from '../../components/icons';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useFilePicker } from '../../hooks/useFilePicker';
import { safeFile } from '../../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import {
TUploadContent,
TUploadItem,
TUploadMetadata,
roomIdToUploadItemsAtomFamily,
roomUploadAtomFamily,
} from '../../state/room/roomInputDrafts';
import {
Upload,
UploadStatus,
UploadSuccess,
createUploadFamilyObserverAtom,
} from '../../state/upload';
import { UploadCardRenderer } from '../../components/upload-card';
import {
UploadBoard,
UploadBoardContent,
UploadBoardHeader,
UploadBoardImperativeHandlers,
} from '../../components/upload-board';
import {
getAudioMsgContent,
getFileMsgContent,
getImageMsgContent,
getVideoMsgContent,
} from '../room/msgContent';
export async function buildContentsFromUploads(
mx: MatrixClient,
items: TUploadItem[],
uploads: UploadSuccess[]
): Promise<IContent[]> {
const contentsPromises = uploads.map(async (upload) => {
const fileItem = items.find((f) => f.file === upload.file);
if (!fileItem) throw new Error('Broken upload');
if (fileItem.file.type.startsWith('image')) {
return getImageMsgContent(mx, fileItem, upload.mxc);
}
if (fileItem.file.type.startsWith('video')) {
return getVideoMsgContent(mx, fileItem, upload.mxc);
}
if (fileItem.file.type.startsWith('audio')) {
return getAudioMsgContent(fileItem, upload.mxc);
}
return getFileMsgContent(fileItem, upload.mxc);
});
return fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
}
type ForumPostUploadFieldProps = {
roomId: string;
disabled?: boolean;
uploadBoardHandlers: MutableRefObject<UploadBoardImperativeHandlers | undefined>;
onUploadsReady: (uploads: UploadSuccess[]) => Promise<void>;
};
export function ForumPostUploadField({
roomId,
disabled,
uploadBoardHandlers,
onUploadsReady,
}: ForumPostUploadFieldProps) {
const mx = useMatrixClient();
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
const [uploadBoard, setUploadBoard] = useState(true);
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
roomUploadAtomFamily,
selectedFiles.map((f) => f.file)
);
const handleFiles = useCallback(
(files: File[]) => {
if (disabled) return;
const safe = files.map(safeFile).filter((f): f is File => f !== null);
if (safe.length === 0) return;
setSelectedFiles({
type: 'PUT',
item: safe.map((file) => ({
file,
originalFile: file,
metadata: { markedAsSpoiler: false } satisfies TUploadMetadata,
encInfo: undefined,
})),
});
},
[disabled, setSelectedFiles]
);
const pickFiles = useFilePicker(handleFiles, true);
const handleFileMetadata = useCallback(
(fileItem: TUploadItem, metadata: TUploadMetadata) => {
setSelectedFiles({
type: 'REPLACE',
item: fileItem,
replacement: { ...fileItem, metadata },
});
},
[setSelectedFiles]
);
const handleRemoveUpload = useCallback(
(upload: TUploadContent | TUploadContent[]) => {
const uploads = Array.isArray(upload) ? upload : [upload];
setSelectedFiles({
type: 'DELETE',
item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)),
});
uploads.forEach((u) => roomUploadAtomFamily.remove(u));
},
[selectedFiles, setSelectedFiles]
);
const handleCancelUpload = (uploads: Upload[]) => {
uploads.forEach((upload) => {
if (upload.status === UploadStatus.Loading) {
mx.cancelUpload(upload.promise);
}
});
handleRemoveUpload(uploads.map((upload) => upload.file));
};
if (selectedFiles.length === 0) {
return (
<IconButton
type="button"
variant="SurfaceVariant"
size="300"
radii="300"
onClick={pickFiles}
disabled={disabled}
aria-label="Attach files"
>
<Icon src={Icons.Attachment} size="200" />
</IconButton>
);
}
return (
<Box direction="Column" gap="200">
<Box direction="Row" gap="200" alignItems="Center">
<IconButton
type="button"
variant="SurfaceVariant"
size="300"
radii="300"
onClick={pickFiles}
disabled={disabled}
aria-label="Attach more files"
>
<Icon src={Icons.Attachment} size="200" />
</IconButton>
</Box>
<UploadBoard
header={
<UploadBoardHeader
open={uploadBoard}
onToggle={() => setUploadBoard(!uploadBoard)}
uploadFamilyObserverAtom={uploadFamilyObserverAtom}
onSend={onUploadsReady}
imperativeHandlerRef={uploadBoardHandlers}
onCancel={handleCancelUpload}
/>
}
>
{uploadBoard && (
<Scroll size="300" hideTrack visibility="Hover">
<UploadBoardContent>
{Array.from(selectedFiles)
.reverse()
.map((fileItem, index) => (
<UploadCardRenderer
// eslint-disable-next-line react/no-array-index-key
key={index}
isEncrypted={!!fileItem.encInfo}
fileItem={fileItem}
setMetadata={handleFileMetadata}
onRemove={handleRemoveUpload}
/>
))}
</UploadBoardContent>
</Scroll>
)}
</UploadBoard>
</Box>
);
}
export function useForumPostUploads(roomId: string) {
const mx = useMatrixClient();
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
const uploadResolverRef = useRef<{
resolve: (contents: IContent[]) => void;
reject: (err: unknown) => void;
} | null>(null);
const clearUploads = useCallback(() => {
if (selectedFiles.length > 0) {
setSelectedFiles({ type: 'DELETE', item: selectedFiles });
selectedFiles.forEach((item) => roomUploadAtomFamily.remove(item.file));
}
}, [selectedFiles, setSelectedFiles]);
const handleUploadsReady = useCallback(
async (uploads: UploadSuccess[]) => {
try {
const contents = await buildContentsFromUploads(mx, selectedFiles, uploads);
uploads.forEach((upload) => roomUploadAtomFamily.remove(upload.file));
if (selectedFiles.length > 0) {
setSelectedFiles({ type: 'DELETE', item: selectedFiles });
}
uploadResolverRef.current?.resolve(contents);
} catch (err) {
uploadResolverRef.current?.reject(err);
} finally {
uploadResolverRef.current = null;
}
},
[mx, selectedFiles, setSelectedFiles]
);
const collectUploadContents = useCallback(async (): Promise<IContent[]> => {
if (selectedFiles.length === 0) return [];
return new Promise<IContent[]>((resolve, reject) => {
uploadResolverRef.current = { resolve, reject };
uploadBoardHandlers.current?.handleSend().catch(reject);
});
}, [selectedFiles.length]);
return {
selectedFiles,
uploadBoardHandlers,
handleUploadsReady,
collectUploadContents,
clearUploads,
};
}

View File

@@ -0,0 +1,62 @@
import React, { RefObject, useCallback } from 'react';
import { Editor } from 'slate';
import { Room } from 'matrix-js-sdk';
import { Button, Text } from 'folds';
import { resetEditor, resetEditorHistory } from '../../components/editor';
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
import { RoomInput } from '../room/RoomInput';
import * as t from './forumTheme.css';
type ForumReplyComposerProps = {
editor: Editor;
room: Room;
roomId: string;
threadRootId: string;
replyToEventId: string;
fileDropContainerRef: RefObject<HTMLElement>;
onSent?: () => void;
onCancel?: () => void;
};
/** Thread reply composer with uploads, emoji, and rich text (same as room messages). */
export function ForumReplyComposer({
editor,
room,
roomId,
threadRootId,
replyToEventId,
fileDropContainerRef,
onSent,
onCancel,
}: ForumReplyComposerProps) {
const powerLevels = usePowerLevels(room);
const handleMessageSent = useCallback(() => {
resetEditor(editor);
resetEditorHistory(editor);
onSent?.();
}, [editor, onSent]);
return (
<div className={t.ForumFeedReplyForm}>
<PowerLevelsContextProvider value={powerLevels}>
<RoomInput
room={room}
roomId={roomId}
editor={editor}
threadRootId={threadRootId}
threadReplyToEventId={replyToEventId}
hideReplyThreadIndicator
fileDropContainerRef={fileDropContainerRef}
onMessageSent={handleMessageSent}
/>
</PowerLevelsContextProvider>
{onCancel && (
<div className={t.ForumReplyFormActions}>
<Button type="button" variant="SurfaceVariant" size="300" onClick={onCancel}>
<Text size="B400">Cancel</Text>
</Button>
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Icon, Icons, Text, color } from 'folds';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Text, color, Scroll } from 'folds';
import { Icon, Icons } from '../../components/icons';
import classNames from 'classnames';
import { useSetAtom } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -99,6 +100,7 @@ export function ForumSpaceShell() {
const threadInitialPost =
optimisticPost?.eventId === selectedPostId ? optimisticPost : null;
const detailDropRef = useRef<HTMLDivElement>(null);
return (
<PowerLevelsContextProvider value={spacePowerLevels}>
@@ -149,32 +151,43 @@ export function ForumSpaceShell() {
<aside className={theme.ForumDetailPane}>
{showThread && activeTopicRoom ? (
<div id="topicPostDetail" className={theme.ForumTopicPostDetail}>
<div ref={detailDropRef} className={theme.ForumTopicPostDetailThreadLayout}>
<ForumThreadDetail
roomId={activeTopicRoom.roomId}
rootEventId={selectedPostId!}
titleHint={selectedThread?.title}
initialPost={threadInitialPost}
fileDropContainerRef={detailDropRef}
onReplySent={handleReplySent}
/>
</div>
) : (
<div id="topicPostDetail" className={theme.ForumTopicPostDetail}>
<Box
className={theme.ForumEmptyDetail}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
>
<Icon src={Icons.Thread} size="600" />
<Text size="T300" priority="300">
{topicSelected
? 'Select a post to read the thread'
: 'Pick a category and topic, then choose a post'}
</Text>
</Box>
</div>
<Scroll
id="topicPostDetail"
className={theme.ForumTopicPostDetailScroll}
variant="Surface"
direction="Vertical"
size="300"
hideTrack
visibility="Hover"
>
<div className={theme.ForumTopicPostDetailInner}>
<Box
className={theme.ForumEmptyDetail}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
>
<Icon src={Icons.Thread} size="600" />
<Text size="T300" priority="300">
{topicSelected
? 'Select a post to read the thread'
: 'Pick a category and topic, then choose a post'}
</Text>
</Box>
</div>
</Scroll>
)}
</aside>
</section>

View File

@@ -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 (
<button
type="button"
className={
isRootBranch ? theme.ForumThreadCollapsedRowRoot : theme.ForumThreadCollapsedRowNested
}
aria-label={`Show ${morePostsLabel(hiddenCount)}`}
title={`Show ${morePostsLabel(hiddenCount)}`}
onClick={handleToggle}
>
<span
className={
isRootBranch ? theme.ForumThreadCollapsedRailRoot : theme.ForumThreadCollapsedRailNested
}
aria-hidden
/>
<span className={theme.ForumThreadCollapsedMiniPost}>
<span className={theme.ForumThreadCollapsedMoreLabel}>{morePostsLabel(hiddenCount)}</span>
</span>
</button>
);
}
return (
<article className={theme.ForumThreadReply} data-event-id={reply.eventId}>
<div className={theme.ForumThreadReplyInner}>
<div className={`${className} ${theme.ForumThreadCollapsible}`}>
<button
type="button"
className={railClassName}
aria-label="Collapse replies"
title="Collapse replies"
onClick={handleToggle}
/>
{children}
</div>
);
}
type ForumThreadMessageProps = {
post: ForumPost;
room: Room;
roomId: string;
threadRootId: string;
fileDropContainerRef: RefObject<HTMLElement>;
editEventId?: string;
onEditEventId: (eventId?: string) => void;
onComposerSent: () => void;
onDeleted: () => void;
onReplyTo: (eventId: string) => void;
replyTargetEventId?: string;
collapsedBranches: Set<string>;
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 (
<article className={theme.ForumThreadReply} data-event-id={post.eventId}>
<ForumMessageContextMenu
room={room}
mEvent={mEvent}
imagePackRooms={imagePackRooms}
disabled={isEditing}
canEdit={canEdit}
canDelete={canDelete}
onReply={() => onReplyTo(post.eventId)}
onEdit={() => onEditEventId(post.eventId)}
onDeleted={onDeleted}
className={forumMessageSurfaceClass(theme.ForumThreadReplyInner, post.eventId, replyTargetEventId)}
>
<header className={theme.ForumPostHeader}>
<div className={theme.ForumPostHeaderMain}>
<ForumAuthorIdentity room={room} sender={reply.sender} displayName={reply.senderDisplayName} />
<time className={theme.ForumPostTime} dateTime={new Date(reply.timestamp).toISOString()}>
{formatForumTimeAgo(reply.timestamp)}
<ForumAuthorIdentity room={room} sender={post.sender} displayName={post.senderDisplayName} />
<time className={theme.ForumPostTime} dateTime={new Date(post.timestamp).toISOString()}>
{formatForumTimeAgo(post.timestamp)}
</time>
</div>
</header>
{reply.replyToDeleted && (
{post.replyToDeleted && (
<p className={theme.ForumReplyToDeleted} role="note">
Replying to a deleted message
</p>
)}
<ForumMessageBody body={reply.body} bodyHtml={reply.bodyHtml} />
{!replyOpen && (
<div className={theme.ForumReplyActionsFooter}>
<ForumMessageActions onReply={() => setReplyOpen(true)} />
</div>
)}
{replyOpen && (
<div className={theme.ForumMessageActionPanels}>
<ForumRichReplyComposer
roomId={roomId}
threadRootId={threadRootId}
replyToEventId={reply.eventId}
onSent={(sentReply) => {
setReplyOpen(false);
onReplySent(sentReply);
}}
onCancel={() => setReplyOpen(false)}
/>
</div>
)}
</div>
{reply.replies.length > 0 && (
<div className={theme.ForumThreadChildren}>
{reply.replies.map((child) => (
<ForumThreadReply
key={child.eventId}
reply={child}
{isEditing && mEvent ? (
<MessageEditor
room={room}
roomId={roomId}
mEvent={mEvent}
imagePackRooms={imagePackRooms}
onCancel={handleEditClose}
/>
) : (
<>
<ForumEventContent
room={room}
threadRootId={threadRootId}
roomId={roomId}
depth={depth + 1}
onReplySent={onReplySent}
eventId={post.eventId}
body={post.body}
bodyHtml={post.bodyHtml}
senderDisplayName={post.senderDisplayName}
/>
))}
</div>
{reactionRelations && (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={post.eventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
)}
</>
)}
{!isEditing && (
<div className={theme.ForumMessageOptionsBase}>
<ForumMessageActions
imagePackRooms={imagePackRooms}
eventId={post.eventId}
body={post.body}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
onReply={() => onReplyTo(post.eventId)}
replyActive={replyTargetEventId === post.eventId}
onEdit={() => onEditEventId(post.eventId)}
canEdit={canEdit}
onDelete={handleDelete}
canDelete={canDelete}
/>
</div>
)}
</ForumMessageContextMenu>
{post.replies.length > 0 && (
<ForumThreadBranch
className={theme.ForumThreadChildren}
railClassName={theme.ForumThreadRailHitNested}
collapsed={repliesCollapsed}
hiddenCount={replyCount}
onToggle={() => onToggleBranch(branchKey)}
>
{!repliesCollapsed &&
post.replies.map((child) => (
<ForumThreadMessage
key={child.eventId}
post={child}
room={room}
roomId={roomId}
threadRootId={threadRootId}
fileDropContainerRef={fileDropContainerRef}
editEventId={editEventId}
onEditEventId={onEditEventId}
onComposerSent={onComposerSent}
onDeleted={onDeleted}
onReplyTo={onReplyTo}
replyTargetEventId={replyTargetEventId}
collapsedBranches={collapsedBranches}
onToggleBranch={onToggleBranch}
/>
))}
</ForumThreadBranch>
)}
</article>
);
@@ -91,31 +286,324 @@ type ForumThreadDetailProps = {
rootEventId: string;
titleHint?: string;
initialPost?: ForumPost | null;
fileDropContainerRef?: RefObject<HTMLElement>;
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<string | undefined>();
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<Set<string>>(() => new Set());
const localDropRef = useRef<HTMLDivElement>(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 (
<article ref={localDropRef} className={theme.ForumTopicDetailRoot} data-event-id={post.eventId}>
<Scroll
className={theme.ForumTopicDetailBodyScroll}
variant="Surface"
direction="Vertical"
size="300"
hideTrack
visibility="Hover"
>
<div className={theme.ForumTopicDetailBody}>
<header className={theme.ForumTopicDetailHead}>
<div className={theme.ForumTopicDetailHeadMain}>
<h1 className={theme.ForumDetailTitle}>{title}</h1>
<div className={theme.ForumTopicDetailAuthor}>
<ForumAuthorIdentity room={room} sender={post.sender} displayName={post.senderDisplayName} />
<span className={theme.ForumPostTime}>· {formatForumTimeAgo(post.timestamp)}</span>
</div>
</div>
</header>
<ForumMessageContextMenu
room={room}
mEvent={rootEvent}
imagePackRooms={imagePackRooms}
disabled={rootEditing}
canEdit={canEditRoot}
canDelete={canDeleteRoot}
onReply={() => handleReplyTo(post.eventId)}
onEdit={() => setEditEventId(rootEventId)}
onDeleted={handleDeleted}
className={forumMessageSurfaceClass(theme.ForumTopicDetailOp, post.eventId, replyTargetEventId)}
>
{rootEditing && rootEvent ? (
<MessageEditor
room={room}
roomId={roomId}
mEvent={rootEvent}
imagePackRooms={imagePackRooms}
onCancel={handleEditRootClose}
/>
) : (
<>
<ForumEventContent
room={room}
eventId={post.eventId}
body={post.body}
bodyHtml={post.bodyHtml}
senderDisplayName={post.senderDisplayName}
/>
{reactionRelations && (
<Reactions
style={{ marginTop: config.space.S200 }}
room={room}
relations={reactionRelations}
mEventId={post.eventId}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
/>
)}
</>
)}
{!rootEditing && (
<div className={theme.ForumMessageOptionsBase}>
<ForumMessageActions
imagePackRooms={imagePackRooms}
eventId={post.eventId}
body={post.body}
canSendReaction={canSendReaction}
onReactionToggle={handleReactionToggle}
onReply={() => handleReplyTo(post.eventId)}
replyActive={replyTargetEventId === post.eventId}
onEdit={() => setEditEventId(rootEventId)}
canEdit={canEditRoot}
onDelete={handleDeleteRoot}
canDelete={canDeleteRoot}
/>
</div>
)}
</ForumMessageContextMenu>
{post.replies.length > 0 && (
<ForumThreadBranch
className={theme.ForumThread}
railClassName={theme.ForumThreadRailHitRoot}
collapsed={rootRepliesCollapsed}
hiddenCount={rootReplyCount}
onToggle={() => toggleBranch(rootBranchKey)}
>
{!rootRepliesCollapsed &&
post.replies.map((reply) => (
<ForumThreadMessage
key={reply.eventId}
post={reply}
room={room}
roomId={roomId}
threadRootId={rootEventId}
fileDropContainerRef={dropRef}
editEventId={editEventId}
onEditEventId={setEditEventId}
onComposerSent={() => {
void handleComposerSent();
}}
onDeleted={handleDeleted}
onReplyTo={handleReplyTo}
replyTargetEventId={replyTargetEventId}
collapsedBranches={collapsedBranches}
onToggleBranch={toggleBranch}
/>
))}
</ForumThreadBranch>
)}
</div>
</Scroll>
<div className={theme.ForumThreadFooterComposer}>
<ForumReplyComposer
editor={editor}
room={room}
roomId={roomId}
threadRootId={rootEventId}
replyToEventId={replyToEventId}
fileDropContainerRef={dropRef}
onSent={() => {
void handleComposerSent();
}}
/>
</div>
</article>
);
}
export function ForumThreadDetail({
roomId,
rootEventId,
titleHint,
initialPost,
fileDropContainerRef,
onReplySent,
}: ForumThreadDetailProps) {
const mx = useMatrixClient();
const [post, setPost] = useState<ForumPost | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<Text as="p" className={theme.ForumListLoading} size="T300" priority="300">
{error || 'Post not found.'}
@@ -168,56 +656,16 @@ export function ForumThreadDetail({
);
}
const title = post.title || titleHint || '(untitled post)';
return (
<article className={theme.ForumTopicDetailRoot} data-event-id={post.eventId}>
<header className={theme.ForumTopicDetailHead}>
<div className={theme.ForumTopicDetailHeadMain}>
<h1 className={theme.ForumDetailTitle}>{title}</h1>
<div className={theme.ForumTopicDetailAuthor}>
<ForumAuthorIdentity room={room} sender={post.sender} displayName={post.senderDisplayName} />
<span className={theme.ForumPostTime}>· {formatForumTimeAgo(post.timestamp)}</span>
</div>
</div>
</header>
<div className={theme.ForumTopicDetailOp}>
<ForumMessageBody body={post.body} bodyHtml={post.bodyHtml} />
{!rootReplyOpen && (
<div className={theme.ForumReplyActionsFooter}>
<ForumMessageActions onReply={() => setRootReplyOpen(true)} />
</div>
)}
{rootReplyOpen && (
<div className={theme.ForumMessageActionPanels}>
<ForumRichReplyComposer
roomId={roomId}
threadRootId={rootEventId}
replyToEventId={post.eventId}
onSent={(sentReply) => {
setRootReplyOpen(false);
handleReplySent(sentReply);
}}
onCancel={() => setRootReplyOpen(false)}
/>
</div>
)}
</div>
{post.replies.length > 0 && (
<div className={theme.ForumThread} id="topicReplyThread">
{post.replies.map((reply) => (
<ForumThreadReply
key={reply.eventId}
reply={reply}
room={room}
threadRootId={rootEventId}
roomId={roomId}
depth={0}
onReplySent={handleReplySent}
/>
))}
</div>
)}
</article>
<ForumThreadDetailBody
roomId={roomId}
rootEventId={rootEventId}
titleHint={titleHint}
post={post}
room={room}
fileDropContainerRef={fileDropContainerRef}
onReplySent={onReplySent}
onPostUpdate={setPost}
/>
);
}

View File

@@ -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,

View File

@@ -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<string, unknown>): RawRelatio
return relates as RawRelation;
}
function isAnnotationRoomMessage(content: Record<string, unknown>): 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<string, unknown>): boolean {
if (typeof content['com.matrixsso.title'] === 'string') return true;
const formatted = content.formatted_body;
return typeof formatted === 'string' && /<h1[\s>]/i.test(formatted);
}
function syncForumRootContentAfterEdit(
merged: Record<string, unknown>,
baseContent: Record<string, unknown>
): Record<string, unknown> {
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<string, unknown> = {
...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<string, unknown> {
const baseContent = event.content as Record<string, unknown>;
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<string, unknown>);
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<string, unknown>)['m.new_content'];
if (!newContent || typeof newContent !== 'object') return baseContent;
const merged = { ...baseContent, ...(newContent as Record<string, unknown>) };
return syncForumRootContentAfterEdit(merged, baseContent);
}
function parseBundledThreadReplyCount(event: IRoomEvent): number | undefined {
const relations = event.unsigned?.['m.relations'] as Record<string, unknown> | undefined;
const thread = relations?.['m.thread'] as { count?: unknown } | undefined;
@@ -38,8 +115,12 @@ function postTitleFromContent(content: Record<string, unknown>): { 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<string, unknown>): { title: string
return { title, body: body || rawBody };
}
/** Title + body for a forum matrix message payload (including edits). */
export function forumPostTitleFromContent(content: Record<string, unknown>): 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<string, unknown>;
const rawContent = event.content as Record<string, unknown>;
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<string>();
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<string>();
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<string>): IRoomEvent[] {
const events: IRoomEvent[] = [];
const seen = new Set<string>();
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<string>();
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<ForumPost | null> {
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<string>();
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<string> {
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<string> {
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;

View File

@@ -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(/^<h1[^>]*>([\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(/^<h1[^>]*>[\s\S]*?<\/h1>\s*/i, '').trim();
return html.replace(/^\s*<h1[^>]*>[\s\S]*?<\/h1>\s*/i, '').trim();
}
export function bodyHtmlFromMessageContent(

View File

@@ -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' });

View File

@@ -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';

View File

@@ -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<string | null>(null);
const [error, setError] = useState<string | null>(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(

View File

@@ -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,
};
}

View File

@@ -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<LinkifyOpts>(
() => ({
...LINKIFY_OPTS,
render: factoryRenderLinkifyWithMention((href) =>
renderMatrixMention(mx, room?.roomId ?? '', href, makeMentionCustomProps(mentionClickHandler))
),
}),
[mx, room?.roomId, mentionClickHandler]
);
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
() =>
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,
};
}

View File

@@ -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]);
}