feat: Integrate forum layout support across various components and enhance room handling logic
This commit is contained in:
@@ -27,6 +27,10 @@
|
|||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
|
|
||||||
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Space+Grotesk:wght@400;600;700&display=swap"
|
||||||
|
/>
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ type CustomEditorProps = {
|
|||||||
before?: ReactNode;
|
before?: ReactNode;
|
||||||
after?: ReactNode;
|
after?: ReactNode;
|
||||||
maxHeight?: string;
|
maxHeight?: string;
|
||||||
|
/** Stretch the editable area to fill the parent (e.g. post modal). */
|
||||||
|
fillHeight?: boolean;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onKeyDown?: KeyboardEventHandler;
|
onKeyDown?: KeyboardEventHandler;
|
||||||
@@ -101,6 +103,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
before,
|
before,
|
||||||
after,
|
after,
|
||||||
maxHeight = '50vh',
|
maxHeight = '50vh',
|
||||||
|
fillHeight = false,
|
||||||
editor,
|
editor,
|
||||||
placeholder,
|
placeholder,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
@@ -223,11 +226,27 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const scrollStyle = fillHeight
|
||||||
|
? { flex: '1 1 auto', minHeight: maxHeight, height: '100%' }
|
||||||
|
: { maxHeight };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.Editor} ref={ref}>
|
<div
|
||||||
|
className={css.Editor}
|
||||||
|
ref={ref}
|
||||||
|
style={
|
||||||
|
fillHeight
|
||||||
|
? { display: 'flex', flexDirection: 'column', height: '100%', minHeight: maxHeight }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
||||||
{top}
|
{top}
|
||||||
<Box alignItems="Start">
|
<Box
|
||||||
|
alignItems="Start"
|
||||||
|
grow={fillHeight ? 'Yes' : undefined}
|
||||||
|
style={fillHeight ? { width: '100%', minHeight: 0, flex: '1 1 auto' } : undefined}
|
||||||
|
>
|
||||||
{before && (
|
{before && (
|
||||||
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
||||||
{before}
|
{before}
|
||||||
@@ -236,7 +255,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
<Scroll
|
<Scroll
|
||||||
className={css.EditorTextareaScroll}
|
className={css.EditorTextareaScroll}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
style={{ maxHeight }}
|
style={scrollStyle}
|
||||||
size="300"
|
size="300"
|
||||||
visibility="Hover"
|
visibility="Hover"
|
||||||
hideTrack
|
hideTrack
|
||||||
|
|||||||
@@ -28,14 +28,18 @@ export function PageRoot({ nav, children }: PageRootProps) {
|
|||||||
type ClientDrawerLayoutProps = {
|
type ClientDrawerLayoutProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
export function PageNav({ size, children }: ClientDrawerLayoutProps & css.PageNavVariants) {
|
export function PageNav({
|
||||||
|
size,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: ClientDrawerLayoutProps & css.PageNavVariants & { className?: string }) {
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const isMobile = screenSize === ScreenSize.Mobile;
|
const isMobile = screenSize === ScreenSize.Mobile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
grow={isMobile ? 'Yes' : undefined}
|
grow={isMobile ? 'Yes' : undefined}
|
||||||
className={css.PageNav({ size })}
|
className={classNames(css.PageNav({ size }), className)}
|
||||||
shrink={isMobile ? 'Yes' : 'No'}
|
shrink={isMobile ? 'Yes' : 'No'}
|
||||||
>
|
>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export const RoomCard = as<'div', RoomCardProps>(
|
|||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const joinedRoomId = useJoinedRoomId(allRooms, roomIdOrAlias);
|
const joinedRoomId = useJoinedRoomId(allRooms, roomIdOrAlias);
|
||||||
const joinedRoom = mx.getRoom(joinedRoomId);
|
const joinedRoom = mx.getRoom(joinedRoomId);
|
||||||
|
const createEvent = joinedRoom ? getStateEvent(joinedRoom, StateEvent.RoomCreate) : undefined;
|
||||||
const [topicEvent, setTopicEvent] = useState(() =>
|
const [topicEvent, setTopicEvent] = useState(() =>
|
||||||
joinedRoom ? getStateEvent(joinedRoom, StateEvent.RoomTopic) : undefined
|
joinedRoom ? getStateEvent(joinedRoom, StateEvent.RoomTopic) : undefined
|
||||||
);
|
);
|
||||||
@@ -210,6 +211,8 @@ export const RoomCard = as<'div', RoomCardProps>(
|
|||||||
|
|
||||||
const [showSpaceJoinPrompt, setShowSpaceJoinPrompt] = useState(false);
|
const [showSpaceJoinPrompt, setShowSpaceJoinPrompt] = useState(false);
|
||||||
const isSpace = roomType === RoomType.Space || joinedRoom?.isSpaceRoom();
|
const isSpace = roomType === RoomType.Space || joinedRoom?.isSpaceRoom();
|
||||||
|
const isForum =
|
||||||
|
roomType === RoomType.Forum || createEvent?.getContent<{ type?: string }>().type === RoomType.Forum;
|
||||||
|
|
||||||
const handleJoinClick = () => {
|
const handleJoinClick = () => {
|
||||||
if (isSpace) {
|
if (isSpace) {
|
||||||
@@ -252,6 +255,11 @@ export const RoomCard = as<'div', RoomCardProps>(
|
|||||||
<Text size="L400">Space</Text>
|
<Text size="L400">Space</Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{isForum && (
|
||||||
|
<Badge variant="Secondary" fill="Soft" outlined>
|
||||||
|
<Text size="L400">Forum</Text>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
<RoomCardName>{roomName}</RoomCardName>
|
<RoomCardName>{roomName}</RoomCardName>
|
||||||
|
|||||||
60
src/app/components/speech-bubble/SpeechBubble.css.ts
Normal file
60
src/app/components/speech-bubble/SpeechBubble.css.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
const tailTopBase = {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute' as const,
|
||||||
|
top: toRem(-5),
|
||||||
|
left: 'var(--speech-tail-x, 50%)',
|
||||||
|
transform: 'translateX(-50%) rotate(45deg)',
|
||||||
|
width: toRem(10),
|
||||||
|
height: toRem(10),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpeechBubble = style({
|
||||||
|
position: 'relative',
|
||||||
|
padding: `${toRem(8)} ${toRem(12)}`,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SpeechBubbleTailTop = style({
|
||||||
|
selectors: {
|
||||||
|
'&::before': {
|
||||||
|
...tailTopBase,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Popover menus (kebab, etc.) — surface matches folds Menu. */
|
||||||
|
export const SpeechBubbleMenu = style({
|
||||||
|
position: 'relative',
|
||||||
|
padding: 0,
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
boxShadow: '0 10px 28px rgb(0 0 0 / 28%), 0 2px 6px rgb(0 0 0 / 12%)',
|
||||||
|
overflow: 'visible',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SpeechBubbleMenuTailTop = style({
|
||||||
|
selectors: {
|
||||||
|
'&::before': {
|
||||||
|
...tailTopBase,
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${SpeechBubbleMenu} > *`, {
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
43
src/app/components/speech-bubble/SpeechBubble.tsx
Normal file
43
src/app/components/speech-bubble/SpeechBubble.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as css from './SpeechBubble.css';
|
||||||
|
|
||||||
|
type SpeechBubbleProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
/** `menu` for dropdowns; `tooltip` for hover cards. */
|
||||||
|
variant?: 'tooltip' | 'menu';
|
||||||
|
/** Tail position. Currently only 'top' is implemented. */
|
||||||
|
tail?: 'top' | 'none';
|
||||||
|
/** Percentage string, e.g. '50%'. */
|
||||||
|
tailX?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SpeechBubble({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
variant = 'tooltip',
|
||||||
|
tail = 'top',
|
||||||
|
tailX,
|
||||||
|
style,
|
||||||
|
}: SpeechBubbleProps) {
|
||||||
|
const isMenu = variant === 'menu';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
isMenu ? css.SpeechBubbleMenu : css.SpeechBubble,
|
||||||
|
tail === 'top' ? (isMenu ? css.SpeechBubbleMenuTailTop : css.SpeechBubbleTailTop) : undefined,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
...(tailX ? ({ ['--speech-tail-x' as string]: tailX } as React.CSSProperties) : null),
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
RoomVersionSelector,
|
RoomVersionSelector,
|
||||||
useAdditionalCreators,
|
useAdditionalCreators,
|
||||||
} from '../../components/create-room';
|
} from '../../components/create-room';
|
||||||
|
import { RoomType } from '../../../types/matrix/room';
|
||||||
|
|
||||||
const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
|
const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
|
||||||
if (kind === CreateRoomKind.Private) return Icons.HashLock;
|
if (kind === CreateRoomKind.Private) return Icons.HashLock;
|
||||||
@@ -73,6 +74,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||||||
const [federation, setFederation] = useState(true);
|
const [federation, setFederation] = useState(true);
|
||||||
const [encryption, setEncryption] = useState(false);
|
const [encryption, setEncryption] = useState(false);
|
||||||
const [knock, setKnock] = useState(false);
|
const [knock, setKnock] = useState(false);
|
||||||
|
const [forumLayout, setForumLayout] = useState(false);
|
||||||
const [advance, setAdvance] = useState(false);
|
const [advance, setAdvance] = useState(false);
|
||||||
|
|
||||||
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
|
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
|
||||||
@@ -118,6 +120,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||||||
|
|
||||||
create({
|
create({
|
||||||
version: selectedRoomVersion,
|
version: selectedRoomVersion,
|
||||||
|
type: forumLayout ? RoomType.Forum : undefined,
|
||||||
parent: space,
|
parent: space,
|
||||||
kind,
|
kind,
|
||||||
name: roomName,
|
name: roomName,
|
||||||
@@ -222,6 +225,25 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard
|
||||||
|
style={{ padding: config.space.S300 }}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="500"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Forum Layout"
|
||||||
|
description="Creates this room with m.forum type so Paarrot can apply forum-specific UI."
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={forumLayout}
|
||||||
|
onChange={setForumLayout}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
{advance && (allowKnock || allowKnockRestricted) && (
|
{advance && (allowKnock || allowKnockRestricted) && (
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
style={{ padding: config.space.S300 }}
|
style={{ padding: config.space.S300 }}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
import React, { FormEventHandler, MouseEventHandler, useCallback, useEffect, useState } from 'react';
|
||||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -9,6 +10,10 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
Icons,
|
Icons,
|
||||||
Input,
|
Input,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
PopOut,
|
||||||
|
RectCords,
|
||||||
Spinner,
|
Spinner,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
@@ -38,7 +43,9 @@ import {
|
|||||||
RoomVersionSelector,
|
RoomVersionSelector,
|
||||||
useAdditionalCreators,
|
useAdditionalCreators,
|
||||||
} from '../../components/create-room';
|
} from '../../components/create-room';
|
||||||
import { RoomType } from '../../../types/matrix/room';
|
import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { shouldShowForumLobby } from '../../utils/room';
|
||||||
|
|
||||||
const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => {
|
const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => {
|
||||||
if (kind === CreateRoomKind.Private) return Icons.SpaceLock;
|
if (kind === CreateRoomKind.Private) return Icons.SpaceLock;
|
||||||
@@ -46,6 +53,11 @@ const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => {
|
|||||||
return Icons.SpaceGlobe;
|
return Icons.SpaceGlobe;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const spaceTypeName: Record<RoomType.Space | RoomType.Forum, string> = {
|
||||||
|
[RoomType.Space]: 'Space',
|
||||||
|
[RoomType.Forum]: 'Forum Space',
|
||||||
|
};
|
||||||
|
|
||||||
type CreateSpaceFormProps = {
|
type CreateSpaceFormProps = {
|
||||||
defaultKind?: CreateRoomKind;
|
defaultKind?: CreateRoomKind;
|
||||||
space?: Room;
|
space?: Room;
|
||||||
@@ -74,12 +86,22 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||||||
useAdditionalCreators();
|
useAdditionalCreators();
|
||||||
const [federation, setFederation] = useState(true);
|
const [federation, setFederation] = useState(true);
|
||||||
const [knock, setKnock] = useState(false);
|
const [knock, setKnock] = useState(false);
|
||||||
|
const [spaceType, setSpaceType] = useState<RoomType.Space | RoomType.Forum>(RoomType.Space);
|
||||||
|
const [typeMenuAnchor, setTypeMenuAnchor] = useState<RectCords>();
|
||||||
const [advance, setAdvance] = useState(false);
|
const [advance, setAdvance] = useState(false);
|
||||||
|
|
||||||
|
const parentIsForum = Boolean(space && shouldShowForumLobby(space));
|
||||||
|
|
||||||
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
|
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
|
||||||
const allowKnockRestricted =
|
const allowKnockRestricted =
|
||||||
kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion);
|
kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (parentIsForum && spaceType === RoomType.Forum) {
|
||||||
|
setSpaceType(RoomType.Space);
|
||||||
|
}
|
||||||
|
}, [parentIsForum, spaceType]);
|
||||||
|
|
||||||
const handleRoomVersionChange = (version: string) => {
|
const handleRoomVersionChange = (version: string) => {
|
||||||
if (!restrictedSupported(version)) {
|
if (!restrictedSupported(version)) {
|
||||||
setKind(CreateRoomKind.Private);
|
setKind(CreateRoomKind.Private);
|
||||||
@@ -94,6 +116,15 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||||||
const error = createState.status === AsyncStatus.Error ? createState.error : undefined;
|
const error = createState.status === AsyncStatus.Error ? createState.error : undefined;
|
||||||
const disabled = createState.status === AsyncStatus.Loading;
|
const disabled = createState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const handleOpenTypeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setTypeMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTypeSelect = (type: RoomType.Space | RoomType.Forum) => {
|
||||||
|
setSpaceType(type);
|
||||||
|
setTypeMenuAnchor(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
@@ -119,7 +150,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||||||
|
|
||||||
create({
|
create({
|
||||||
version: selectedRoomVersion,
|
version: selectedRoomVersion,
|
||||||
type: RoomType.Space,
|
type: spaceType,
|
||||||
parent: space,
|
parent: space,
|
||||||
kind,
|
kind,
|
||||||
name: roomName,
|
name: roomName,
|
||||||
@@ -128,7 +159,21 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||||||
knock: roomKnock,
|
knock: roomKnock,
|
||||||
allowFederation: federation,
|
allowFederation: federation,
|
||||||
additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
|
additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
|
||||||
}).then((roomId) => {
|
}).then(async (roomId) => {
|
||||||
|
if (spaceType === RoomType.Forum) {
|
||||||
|
try {
|
||||||
|
await mx.sendStateEvent(
|
||||||
|
roomId,
|
||||||
|
StateEvent.PaarrotRoomKind,
|
||||||
|
{ kind: 'forum_space' },
|
||||||
|
''
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('Failed to set forum space marker state event:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (alive()) {
|
if (alive()) {
|
||||||
onCreate?.(roomId);
|
onCreate?.(roomId);
|
||||||
}
|
}
|
||||||
@@ -147,6 +192,84 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
|
|||||||
getIcon={getCreateSpaceKindToIcon}
|
getIcon={getCreateSpaceKindToIcon}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
{!parentIsForum && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Type</Text>
|
||||||
|
<SequenceCard
|
||||||
|
style={{ padding: config.space.S300 }}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Space Type"
|
||||||
|
description="Choose standard space navigation or forum-style space behavior."
|
||||||
|
after={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Primary"
|
||||||
|
outlined
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||||
|
onClick={handleOpenTypeMenu}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text size="T300">{spaceTypeName[spaceType]}</Text>
|
||||||
|
</Button>
|
||||||
|
<PopOut
|
||||||
|
anchor={typeMenuAnchor}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setTypeMenuAnchor(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
variant={spaceType === RoomType.Space ? 'Primary' : 'Surface'}
|
||||||
|
radii="300"
|
||||||
|
onClick={() => handleTypeSelect(RoomType.Space)}
|
||||||
|
>
|
||||||
|
<Text size="T300">Space</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
variant={spaceType === RoomType.Forum ? 'Primary' : 'Surface'}
|
||||||
|
radii="300"
|
||||||
|
onClick={() => handleTypeSelect(RoomType.Forum)}
|
||||||
|
>
|
||||||
|
<Text size="T300">Forum Space</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{parentIsForum && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Creates a forum category (subspace). Add topics inside it with Add Topic after selecting the
|
||||||
|
category in the filter bar.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Box shrink="No" direction="Column" gap="100">
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
<Text size="L400">Name</Text>
|
<Text size="L400">Name</Text>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
78
src/app/features/forum/ForumAuthorIdentity.tsx
Normal file
78
src/app/features/forum/ForumAuthorIdentity.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Avatar, Text } from 'folds';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||||
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
|
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import * as theme from './forumTheme.css';
|
||||||
|
|
||||||
|
function matrixUserServer(userId: string): string {
|
||||||
|
const parts = userId.split(':');
|
||||||
|
return parts.length > 1 ? parts[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function matrixUserLocalpart(userId: string): string {
|
||||||
|
return userId.split(':')[0]?.replace('@', '') || userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForumAuthorIdentityProps = {
|
||||||
|
room: Room | null;
|
||||||
|
sender: string;
|
||||||
|
displayName?: string;
|
||||||
|
wrapTag?: 'strong' | 'span';
|
||||||
|
compact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** matrixsso `author-identity` chip (avatar + name + server). */
|
||||||
|
export function ForumAuthorIdentity({
|
||||||
|
room,
|
||||||
|
sender,
|
||||||
|
displayName,
|
||||||
|
wrapTag: WrapTag = 'strong',
|
||||||
|
compact = false,
|
||||||
|
}: ForumAuthorIdentityProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const mxc = room ? getMemberAvatarMxc(room, sender) : undefined;
|
||||||
|
const avatarUrl = mxc
|
||||||
|
? mxcUrlToHttp(mx, mxc, useAuthentication, compact ? 36 : 48, compact ? 36 : 48, 'crop') ??
|
||||||
|
undefined
|
||||||
|
: undefined;
|
||||||
|
const name =
|
||||||
|
displayName || (room ? getMemberDisplayName(room, sender) : undefined) || matrixUserLocalpart(sender);
|
||||||
|
const server = matrixUserServer(sender);
|
||||||
|
|
||||||
|
const identityClass = compact
|
||||||
|
? `${theme.ForumAuthorIdentity} ${theme.ForumAuthorIdentityCompact}`
|
||||||
|
: theme.ForumAuthorIdentity;
|
||||||
|
const avatarClass = compact ? theme.ForumAuthorAvatarCompact : theme.ForumAuthorAvatar;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={identityClass}>
|
||||||
|
<span className={avatarClass} aria-hidden>
|
||||||
|
<Avatar size="200" radii="300" shrink="No">
|
||||||
|
<UserAvatar
|
||||||
|
userId={sender}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={name}
|
||||||
|
renderFallback={() => (
|
||||||
|
<Text size="L400">{name.slice(0, 1).toUpperCase()}</Text>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
</span>
|
||||||
|
<span className={theme.ForumAuthorMeta}>
|
||||||
|
<WrapTag className={theme.ForumSenderLabel} title={sender}>
|
||||||
|
{name}
|
||||||
|
</WrapTag>
|
||||||
|
{server ? (
|
||||||
|
<span className={theme.ForumAuthorServer} title="Matrix homeserver">
|
||||||
|
{server}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/app/features/forum/ForumAwareSpaceLayout.tsx
Normal file
53
src/app/features/forum/ForumAwareSpaceLayout.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
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 { 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.
|
||||||
|
*/
|
||||||
|
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={
|
||||||
|
<MobileFriendlyPageNav path={SPACE_PATH}>
|
||||||
|
<Space />
|
||||||
|
</MobileFriendlyPageNav>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AnimatedOutlet />
|
||||||
|
</PageRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/app/features/forum/ForumBoardContext.tsx
Normal file
32
src/app/features/forum/ForumBoardContext.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React, { createContext, useContext } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useForumBoard, type ForumBoardScope } from './useForumBoard';
|
||||||
|
|
||||||
|
type ForumBoardContextValue = ReturnType<typeof useForumBoard>;
|
||||||
|
|
||||||
|
const ForumBoardContext = createContext<ForumBoardContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ForumBoardProvider({
|
||||||
|
forumSpace,
|
||||||
|
scope,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
forumSpace: Room;
|
||||||
|
scope: ForumBoardScope;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const value = useForumBoard(scope, forumSpace);
|
||||||
|
return <ForumBoardContext.Provider value={value}>{children}</ForumBoardContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useForumBoardContext(): ForumBoardContextValue {
|
||||||
|
const ctx = useContext(ForumBoardContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useForumBoardContext must be used within ForumBoardProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useForumBoardContextOptionally(): ForumBoardContextValue | null {
|
||||||
|
return useContext(ForumBoardContext);
|
||||||
|
}
|
||||||
93
src/app/features/forum/ForumBoardDetail.tsx
Normal file
93
src/app/features/forum/ForumBoardDetail.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { Box, Icon, Icons, Text } from 'folds';
|
||||||
|
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 { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||||
|
import { useForumBoardContext } from './ForumBoardContext';
|
||||||
|
import * as css from './ForumBoardView.css';
|
||||||
|
|
||||||
|
/** Thread detail + composer (main pane); post list lives in ForumFeedSidebar. */
|
||||||
|
export function ForumBoardDetail() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const editor = useEditor();
|
||||||
|
const detailScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
forumSpace,
|
||||||
|
query,
|
||||||
|
selectedPostId,
|
||||||
|
activeTopicRoomId,
|
||||||
|
error,
|
||||||
|
} = useForumBoardContext();
|
||||||
|
|
||||||
|
const activeTopicRoom = activeTopicRoomId ? mx.getRoom(activeTopicRoomId) : null;
|
||||||
|
const topicPowerLevels = usePowerLevels(activeTopicRoom ?? forumSpace);
|
||||||
|
const setActiveThreadId = useSetAtom(
|
||||||
|
activeThreadIdAtomFamily(activeTopicRoomId ?? forumSpace.roomId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const topicSelected = Boolean(query.topic);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPostId && activeTopicRoomId) {
|
||||||
|
setActiveThreadId(selectedPostId);
|
||||||
|
return () => setActiveThreadId(undefined);
|
||||||
|
}
|
||||||
|
setActiveThreadId(undefined);
|
||||||
|
return undefined;
|
||||||
|
}, [selectedPostId, activeTopicRoomId, setActiveThreadId]);
|
||||||
|
|
||||||
|
const showComposer = Boolean(activeTopicRoom && topicSelected);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageContent>
|
||||||
|
<Box className={css.ForumDetailOnlyRoot}>
|
||||||
|
{error && (
|
||||||
|
<Box style={{ padding: '0.5rem 1rem' }}>
|
||||||
|
<Text style={{ color: 'var(--fc-critical, #f38ba8)' }}>{error}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
</PageContent>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
319
src/app/features/forum/ForumBoardView.css.ts
Normal file
319
src/app/features/forum/ForumBoardView.css.ts
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config } from 'folds';
|
||||||
|
|
||||||
|
const border = color.Surface.Container;
|
||||||
|
const panelBg = color.Surface.Container;
|
||||||
|
|
||||||
|
export const ForumBoardRoot = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumBoardGrid = style({
|
||||||
|
display: 'grid',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
gap: config.space.S300,
|
||||||
|
padding: config.space.S300,
|
||||||
|
gridTemplateColumns: 'minmax(300px, 400px) minmax(0, 1fr)',
|
||||||
|
gridTemplateRows: 'auto auto minmax(0, 1fr)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumFiltersCard = style({
|
||||||
|
gridColumn: '1',
|
||||||
|
gridRow: '1',
|
||||||
|
padding: config.space.S300,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
border: `1px solid ${border}`,
|
||||||
|
backgroundColor: panelBg,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumListCard = style({
|
||||||
|
gridColumn: '1',
|
||||||
|
gridRow: '2 / 4',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
padding: config.space.S300,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
border: `1px solid ${border}`,
|
||||||
|
backgroundColor: panelBg,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumDetailPanel = style({
|
||||||
|
gridColumn: '2',
|
||||||
|
gridRow: '1 / 4',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
border: `1px solid ${border}`,
|
||||||
|
backgroundColor: panelBg,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumFilters = style({
|
||||||
|
display: 'grid',
|
||||||
|
gap: config.space.S200,
|
||||||
|
gridTemplateColumns: 'minmax(0, 1.3fr) minmax(0, 1.3fr) minmax(0, 1fr) auto',
|
||||||
|
alignItems: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumFilterSelect = style({
|
||||||
|
fontSize: '0.88rem',
|
||||||
|
height: '2rem',
|
||||||
|
minWidth: 0,
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.2rem 0.5rem',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: `1px solid ${border}`,
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'inherit',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumSearchField = style({
|
||||||
|
gridColumn: '1 / -1',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumSearchRow = style({
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(0, 1fr) auto',
|
||||||
|
gap: config.space.S100,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderBottom: `1px solid ${border}`,
|
||||||
|
paddingBottom: config.space.S100,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumSearchInput = style({
|
||||||
|
background: 'transparent',
|
||||||
|
border: 0,
|
||||||
|
outline: 'none',
|
||||||
|
fontSize: '0.88rem',
|
||||||
|
height: '2rem',
|
||||||
|
width: '100%',
|
||||||
|
color: 'inherit',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumSortCycleBtn = style({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minWidth: '2.25rem',
|
||||||
|
height: '2rem',
|
||||||
|
padding: config.space.S100,
|
||||||
|
border: 0,
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'inherit',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
color: color.Primary.Main,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumNewPostBox = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S300,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginBottom: config.space.S300,
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumSessionUser = style({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S200,
|
||||||
|
marginRight: 'auto',
|
||||||
|
minWidth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumPostScroll = style({
|
||||||
|
flex: '1 1 0',
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumPostList = style({
|
||||||
|
display: 'grid',
|
||||||
|
gap: 0,
|
||||||
|
listStyle: 'none',
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumPostItem = style({
|
||||||
|
borderTop: `1px solid ${border}`,
|
||||||
|
selectors: {
|
||||||
|
'&:first-child': {
|
||||||
|
borderTop: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumPostLink = style({
|
||||||
|
display: 'grid',
|
||||||
|
rowGap: config.space.S100,
|
||||||
|
width: '100%',
|
||||||
|
padding: `${config.space.S300} ${config.space.S200}`,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
border: 0,
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'inherit',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
font: 'inherit',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: color.Primary.Container,
|
||||||
|
},
|
||||||
|
'&[data-active="true"]': {
|
||||||
|
backgroundColor: color.Primary.Container,
|
||||||
|
},
|
||||||
|
'&[data-pinned="true"]': {
|
||||||
|
backgroundColor: color.Secondary.Container,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumPostHead = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: config.space.S200,
|
||||||
|
minWidth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumPostHeadMain = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: config.space.S200,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumPostTitle = style({
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
fontSize: '1.02rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumPostFoot = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: config.space.S200,
|
||||||
|
minWidth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumPostAuthor = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: config.space.S100,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
fontSize: '0.88rem',
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumAuthorName = style({
|
||||||
|
fontWeight: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumAuthorServer = style({
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
opacity: 0.75,
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumPostMeta = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
gap: config.space.S200,
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
opacity: 0.85,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumCategoryLabel = style({
|
||||||
|
textTransform: 'lowercase',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumReplyMeta = style({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.2rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumReplyCount = style({
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumListFooter = style({
|
||||||
|
borderTop: `1px solid ${border}`,
|
||||||
|
padding: config.space.S300,
|
||||||
|
textAlign: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumLoadMoreBtn = style({
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumListLoading = style({
|
||||||
|
padding: config.space.S400,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumDetailScroll = style({
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumPageNav = style({
|
||||||
|
width: 'min(400px, 38vw)',
|
||||||
|
minWidth: '300px',
|
||||||
|
maxWidth: '420px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumFeedSidebar = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
gap: config.space.S200,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumDetailOnlyRoot = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
height: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ForumDetailPanelFull = style({
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
164
src/app/features/forum/ForumBoardView.tsx
Normal file
164
src/app/features/forum/ForumBoardView.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { Box, Icon, Icons, Text } from 'folds';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
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 { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||||
|
import { useForumBoard, type ForumBoardScope } from './useForumBoard';
|
||||||
|
import { ForumFilterBar } from './ForumFilterBar';
|
||||||
|
import { ForumPostList } from './ForumPostList';
|
||||||
|
import { ForumNewPostBox } from './ForumNewPostBox';
|
||||||
|
import type { ForumPublishedPost } from './types';
|
||||||
|
import * as css from './ForumBoardView.css';
|
||||||
|
|
||||||
|
type ForumBoardViewProps = {
|
||||||
|
forumSpace: Room;
|
||||||
|
scope: ForumBoardScope;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ForumBoardView({ forumSpace, scope }: ForumBoardViewProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const editor = useEditor();
|
||||||
|
const detailScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const {
|
||||||
|
sections,
|
||||||
|
threads,
|
||||||
|
query,
|
||||||
|
loadingSections,
|
||||||
|
loadingThreads,
|
||||||
|
error,
|
||||||
|
selectedPostId,
|
||||||
|
activeTopicRoomId,
|
||||||
|
setQueryParam,
|
||||||
|
setCategory,
|
||||||
|
selectPost,
|
||||||
|
loadMorePosts,
|
||||||
|
hasMorePosts,
|
||||||
|
loadingMore,
|
||||||
|
insertThreadFromNewPost,
|
||||||
|
recordThreadReply,
|
||||||
|
} = useForumBoard(scope, forumSpace);
|
||||||
|
|
||||||
|
const activeTopicRoom = activeTopicRoomId ? mx.getRoom(activeTopicRoomId) : null;
|
||||||
|
const topicPowerLevels = usePowerLevels(activeTopicRoom ?? forumSpace);
|
||||||
|
const setActiveThreadId = useSetAtom(
|
||||||
|
activeThreadIdAtomFamily(activeTopicRoomId ?? forumSpace.roomId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const topicSelected = Boolean(query.topic || scope.topicRoomId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPostId && activeTopicRoomId) {
|
||||||
|
setActiveThreadId(selectedPostId);
|
||||||
|
return () => setActiveThreadId(undefined);
|
||||||
|
}
|
||||||
|
setActiveThreadId(undefined);
|
||||||
|
return undefined;
|
||||||
|
}, [selectedPostId, activeTopicRoomId, setActiveThreadId]);
|
||||||
|
|
||||||
|
const getTopicRoom = useCallback(
|
||||||
|
(roomId: string | undefined) => (roomId ? mx.getRoom(roomId) : null),
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePostPublished = useCallback(
|
||||||
|
(published: ForumPublishedPost) => {
|
||||||
|
insertThreadFromNewPost(published);
|
||||||
|
selectPost(published.eventId, published.topicRoomId);
|
||||||
|
detailScrollRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
},
|
||||||
|
[insertThreadFromNewPost, selectPost]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showComposer = Boolean(activeTopicRoom && (!selectedPostId || topicSelected));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageContent>
|
||||||
|
<Box className={css.ForumBoardRoot}>
|
||||||
|
{error && (
|
||||||
|
<Box style={{ padding: '0.5rem 1rem' }}>
|
||||||
|
<Text style={{ color: 'var(--fc-critical, #f38ba8)' }}>{error}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box className={css.ForumBoardGrid}>
|
||||||
|
<Box className={css.ForumFiltersCard}>
|
||||||
|
<ForumFilterBar
|
||||||
|
sections={sections}
|
||||||
|
threads={threads}
|
||||||
|
query={query}
|
||||||
|
hideCategoryTopic={Boolean(scope.topicRoomId)}
|
||||||
|
onQueryChange={setQueryParam}
|
||||||
|
onCategoryChange={setCategory}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className={css.ForumListCard}>
|
||||||
|
<ForumNewPostBox
|
||||||
|
sections={sections}
|
||||||
|
postCount={threads.length}
|
||||||
|
loading={loadingSections || loadingThreads}
|
||||||
|
hasMore={hasMorePosts}
|
||||||
|
defaultCategory={query.category}
|
||||||
|
defaultTopicRoomId={activeTopicRoomId ?? query.topic}
|
||||||
|
onPublished={handlePostPublished}
|
||||||
|
/>
|
||||||
|
<ForumPostList
|
||||||
|
threads={threads}
|
||||||
|
selectedPostId={selectedPostId}
|
||||||
|
loading={loadingSections || loadingThreads}
|
||||||
|
getTopicRoom={getTopicRoom}
|
||||||
|
onSelectPost={selectPost}
|
||||||
|
hasMore={hasMorePosts}
|
||||||
|
loadingMore={loadingMore}
|
||||||
|
onLoadMore={loadMorePosts}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className={css.ForumDetailPanel}>
|
||||||
|
<Box ref={detailScrollRef} className={css.ForumDetailScroll}>
|
||||||
|
{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 or create a new one'
|
||||||
|
: 'Select a topic to browse posts'}
|
||||||
|
</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>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageContent>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
src/app/features/forum/ForumChatComposer.tsx
Normal file
191
src/app/features/forum/ForumChatComposer.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import React, { KeyboardEventHandler, useCallback, useRef, useState } from 'react';
|
||||||
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import { Editor } from 'slate';
|
||||||
|
import { ReactEditor } from 'slate-react';
|
||||||
|
import { Icon, IconButton, Icons, Line, PopOut } from 'folds';
|
||||||
|
import {
|
||||||
|
CustomEditor,
|
||||||
|
Toolbar,
|
||||||
|
EmoticonAutocomplete,
|
||||||
|
AutocompletePrefix,
|
||||||
|
AutocompleteQuery,
|
||||||
|
getAutocompleteQuery,
|
||||||
|
createEmoticonElement,
|
||||||
|
moveCursor,
|
||||||
|
} from '../../components/editor';
|
||||||
|
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
||||||
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { mobileOrTablet } from '../../utils/user-agent';
|
||||||
|
import { PluginButtonSlot } from '../settings/plugins/PluginButtonSlot';
|
||||||
|
|
||||||
|
type ForumChatComposerProps = {
|
||||||
|
editor: Editor;
|
||||||
|
roomId: string;
|
||||||
|
room: Room;
|
||||||
|
placeholder?: string;
|
||||||
|
onSend?: () => void;
|
||||||
|
showAttachButton?: boolean;
|
||||||
|
maxHeight?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Same chrome as {@link RoomInput} (attach, emoji, toolbar, send). */
|
||||||
|
export function ForumChatComposer({
|
||||||
|
editor,
|
||||||
|
roomId,
|
||||||
|
room,
|
||||||
|
placeholder = 'Send a message…',
|
||||||
|
onSend,
|
||||||
|
showAttachButton = false,
|
||||||
|
maxHeight,
|
||||||
|
}: ForumChatComposerProps) {
|
||||||
|
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||||
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
const imagePackRooms = useImagePackRooms(roomId, roomToParents);
|
||||||
|
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [autocompleteQuery, setAutocompleteQuery] =
|
||||||
|
useState<AutocompleteQuery<AutocompletePrefix>>();
|
||||||
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
if (
|
||||||
|
onSend &&
|
||||||
|
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt)))
|
||||||
|
) {
|
||||||
|
evt.preventDefault();
|
||||||
|
onSend();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enterForNewline, onSend]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
const prevWordRange = getAutocompleteQuery(editor, evt);
|
||||||
|
if (prevWordRange?.prefix === AutocompletePrefix.Emoticon) {
|
||||||
|
setAutocompleteQuery({ prefix: AutocompletePrefix.Emoticon, range: prevWordRange.range });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAutocompleteQuery(undefined);
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
||||||
|
editor.insertNode(createEmoticonElement(key, shortcode));
|
||||||
|
moveCursor(editor);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseAutocomplete = useCallback(() => {
|
||||||
|
setAutocompleteQuery(undefined);
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
|
||||||
|
<EmoticonAutocomplete
|
||||||
|
imagePackRooms={imagePackRooms}
|
||||||
|
editor={editor}
|
||||||
|
query={autocompleteQuery}
|
||||||
|
requestClose={handleCloseAutocomplete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<CustomEditor
|
||||||
|
editor={editor}
|
||||||
|
placeholder={placeholder}
|
||||||
|
maxHeight={maxHeight}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
before={showAttachButton ? <PluginButtonSlot location="composer-actions" /> : undefined}
|
||||||
|
after={
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setToolbar(!toolbar)}
|
||||||
|
aria-label="Formatting"
|
||||||
|
>
|
||||||
|
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||||
|
</IconButton>
|
||||||
|
<UseStateProvider initial={undefined}>
|
||||||
|
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
||||||
|
<PopOut
|
||||||
|
offset={16}
|
||||||
|
alignOffset={-44}
|
||||||
|
position="Top"
|
||||||
|
align="End"
|
||||||
|
anchor={
|
||||||
|
emojiBoardTab === undefined
|
||||||
|
? undefined
|
||||||
|
: emojiBtnRef.current?.getBoundingClientRect() ?? undefined
|
||||||
|
}
|
||||||
|
content={
|
||||||
|
<EmojiBoard
|
||||||
|
tab={emojiBoardTab}
|
||||||
|
onTabChange={setEmojiBoardTab}
|
||||||
|
imagePackRooms={imagePackRooms}
|
||||||
|
returnFocusOnDeactivate={false}
|
||||||
|
onEmojiSelect={handleEmoticonSelect}
|
||||||
|
onCustomEmojiSelect={handleEmoticonSelect}
|
||||||
|
requestClose={() => {
|
||||||
|
setEmojiBoardTab((t) => {
|
||||||
|
if (t) {
|
||||||
|
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
ref={emojiBtnRef}
|
||||||
|
aria-pressed={emojiBoardTab === EmojiBoardTab.Emoji}
|
||||||
|
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
type="button"
|
||||||
|
aria-label="Emoji"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Smile} filled={emojiBoardTab === EmojiBoardTab.Emoji} />
|
||||||
|
</IconButton>
|
||||||
|
</PopOut>
|
||||||
|
)}
|
||||||
|
</UseStateProvider>
|
||||||
|
<PluginButtonSlot location="text-composer-toolbar" />
|
||||||
|
{onSend && (
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
onClick={onSend}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
aria-label="Send"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Send} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
bottom={
|
||||||
|
toolbar ? (
|
||||||
|
<div>
|
||||||
|
<Line variant="SurfaceVariant" size="300" />
|
||||||
|
<Toolbar />
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
src/app/features/forum/ForumFeedSidebar.tsx
Normal file
185
src/app/features/forum/ForumFeedSidebar.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { Box, Text } 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 { 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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* matrixsso-style left column: category/topic nav + filters + post list.
|
||||||
|
* Renders inside the space PageNav instead of Lobby / ROOMS.
|
||||||
|
*/
|
||||||
|
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 {
|
||||||
|
sections,
|
||||||
|
threads,
|
||||||
|
query,
|
||||||
|
loadingSections,
|
||||||
|
loadingThreads,
|
||||||
|
error,
|
||||||
|
selectedPostId,
|
||||||
|
setQueryParam,
|
||||||
|
selectPost,
|
||||||
|
loadMorePosts,
|
||||||
|
hasMorePosts,
|
||||||
|
loadingMore,
|
||||||
|
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 getTopicRoom = useCallback(
|
||||||
|
(roomId: string | undefined) => (roomId ? mx.getRoom(roomId) : null),
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePostPublished = useCallback(
|
||||||
|
(published: ForumPublishedPost) => {
|
||||||
|
insertThreadFromNewPost(published);
|
||||||
|
selectPost(published.eventId, published.topicRoomId);
|
||||||
|
},
|
||||||
|
[insertThreadFromNewPost, selectPost]
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/app/features/forum/ForumFilterBar.tsx
Normal file
151
src/app/features/forum/ForumFilterBar.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Icon, IconButton, Text, Tooltip, TooltipProvider } from 'folds';
|
||||||
|
import type { ForumBoardQuery, ForumSection, ForumThreadSummary } from './types';
|
||||||
|
import { ForumSortIcons } from './forumLucideIcons';
|
||||||
|
import { ForumTopicSearchInput } from './ForumTopicSearchInput';
|
||||||
|
import * as t from './forumTheme.css';
|
||||||
|
|
||||||
|
const SORT_CYCLE: NonNullable<ForumBoardQuery['sort']>[] = ['hot', 'new', 'top'];
|
||||||
|
const SORT_ICON = {
|
||||||
|
hot: ForumSortIcons.hot,
|
||||||
|
new: ForumSortIcons.new,
|
||||||
|
top: ForumSortIcons.top,
|
||||||
|
} as const;
|
||||||
|
const SORT_TITLE: Record<NonNullable<ForumBoardQuery['sort']>, string> = {
|
||||||
|
hot: 'Hot — sort by activity',
|
||||||
|
new: 'New — newest first',
|
||||||
|
top: 'Top — most replies',
|
||||||
|
};
|
||||||
|
|
||||||
|
type ForumFilterBarProps = {
|
||||||
|
sections: ForumSection[];
|
||||||
|
threads: ForumThreadSummary[];
|
||||||
|
query: ForumBoardQuery;
|
||||||
|
hideCategoryTopic?: boolean;
|
||||||
|
onQueryChange: (key: string, value: string | null) => void;
|
||||||
|
onCategoryChange?: (category: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ForumFilterBar({
|
||||||
|
sections,
|
||||||
|
threads,
|
||||||
|
query,
|
||||||
|
hideCategoryTopic = false,
|
||||||
|
onQueryChange,
|
||||||
|
onCategoryChange,
|
||||||
|
}: ForumFilterBarProps) {
|
||||||
|
const categoryTopics = query.category
|
||||||
|
? sections.find((s) => s.title === query.category)?.topics ?? []
|
||||||
|
: sections.flatMap((s) => s.topics);
|
||||||
|
|
||||||
|
const [searchDraft, setSearchDraft] = useState(query.q || '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchDraft(query.q || '');
|
||||||
|
}, [query.q]);
|
||||||
|
|
||||||
|
const applySearch = useCallback(() => {
|
||||||
|
const trimmed = searchDraft.trim();
|
||||||
|
onQueryChange('q', trimmed ? trimmed : null);
|
||||||
|
}, [onQueryChange, searchDraft]);
|
||||||
|
|
||||||
|
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
applySearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleSort = useCallback(() => {
|
||||||
|
const current = query.sort || 'hot';
|
||||||
|
const idx = SORT_CYCLE.indexOf(current);
|
||||||
|
const next = SORT_CYCLE[(idx + 1) % SORT_CYCLE.length];
|
||||||
|
onQueryChange('sort', next);
|
||||||
|
}, [query.sort, onQueryChange]);
|
||||||
|
|
||||||
|
const sort = query.sort || 'hot';
|
||||||
|
|
||||||
|
const handleCategoryChange = (value: string) => {
|
||||||
|
const category = value || null;
|
||||||
|
if (onCategoryChange) {
|
||||||
|
onCategoryChange(category);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onQueryChange('category', category);
|
||||||
|
if (query.topic) {
|
||||||
|
onQueryChange('topic', null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className={hideCategoryTopic ? t.ForumFiltersCompact : t.ForumFilters}
|
||||||
|
onSubmit={handleSearchSubmit}
|
||||||
|
id="forumFilterForm"
|
||||||
|
>
|
||||||
|
<ForumTopicSearchInput value={searchDraft} onChange={setSearchDraft} threads={threads} />
|
||||||
|
|
||||||
|
{!hideCategoryTopic && (
|
||||||
|
<select
|
||||||
|
className={t.ForumFilterSelect}
|
||||||
|
aria-label="Category"
|
||||||
|
value={query.category || ''}
|
||||||
|
onChange={(e) => handleCategoryChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All categories</option>
|
||||||
|
{sections.map((section) => (
|
||||||
|
<option key={section.title} value={section.title}>
|
||||||
|
{section.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hideCategoryTopic && (
|
||||||
|
<select
|
||||||
|
className={t.ForumFilterSelect}
|
||||||
|
aria-label="Topic"
|
||||||
|
value={query.topic || ''}
|
||||||
|
onChange={(e) => onQueryChange('topic', e.target.value || null)}
|
||||||
|
>
|
||||||
|
<option value="">All topics</option>
|
||||||
|
{categoryTopics.length === 0 && query.category ? (
|
||||||
|
<option value="" disabled>
|
||||||
|
No topics yet — use Add Topic in the menu
|
||||||
|
</option>
|
||||||
|
) : null}
|
||||||
|
{categoryTopics.map((topic) => (
|
||||||
|
<option key={topic.roomId} value={topic.roomId}>
|
||||||
|
{topic.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
align="Center"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>{SORT_TITLE[sort]}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
className={t.ForumSortCycleBtn}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={cycleSort}
|
||||||
|
aria-label={SORT_TITLE[sort]}
|
||||||
|
>
|
||||||
|
<Icon src={SORT_ICON[sort]} size="200" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/app/features/forum/ForumMessageActions.tsx
Normal file
29
src/app/features/forum/ForumMessageActions.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Icon, Icons } from 'folds';
|
||||||
|
import * as theme from './forumTheme.css';
|
||||||
|
|
||||||
|
type ForumMessageActionsProps = {
|
||||||
|
onReply: () => void;
|
||||||
|
replyActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ForumMessageActions({ onReply, replyActive }: ForumMessageActionsProps) {
|
||||||
|
return (
|
||||||
|
<div className={theme.ForumMessageActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
replyActive
|
||||||
|
? `${theme.ForumMessageActionBtn} ${theme.ForumMessageActionBtnActive}`
|
||||||
|
: theme.ForumMessageActionBtn
|
||||||
|
}
|
||||||
|
onClick={onReply}
|
||||||
|
aria-label="Reply"
|
||||||
|
title="Reply"
|
||||||
|
aria-pressed={replyActive}
|
||||||
|
>
|
||||||
|
<Icon className={theme.ForumMessageActionIcon} src={Icons.ReplyArrow} size="200" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/app/features/forum/ForumMessageBody.tsx
Normal file
27
src/app/features/forum/ForumMessageBody.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Text } from 'folds';
|
||||||
|
import * as theme from './forumTheme.css';
|
||||||
|
|
||||||
|
type ForumMessageBodyProps = {
|
||||||
|
body: string;
|
||||||
|
bodyHtml?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ForumMessageBody({ body, bodyHtml }: ForumMessageBodyProps) {
|
||||||
|
const plain = body.trim();
|
||||||
|
if (bodyHtml) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={theme.ForumMessageBodyRich}
|
||||||
|
// eslint-disable-next-line react/no-danger
|
||||||
|
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!plain) return null;
|
||||||
|
return (
|
||||||
|
<Text as="p" className={theme.ForumMessageBody} size="T400">
|
||||||
|
{body}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/app/features/forum/ForumNewPostBox.tsx
Normal file
68
src/app/features/forum/ForumNewPostBox.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { Box, Button, Icon, Icons, Text } from 'folds';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import type { ForumPost, ForumPublishedPost } from './types';
|
||||||
|
import { ForumNewPostModal } from './ForumNewPostModal';
|
||||||
|
import * as t from './forumTheme.css';
|
||||||
|
|
||||||
|
function formatShowingPosts(count: number, loading: boolean, hasMore: boolean): string {
|
||||||
|
if (loading && count === 0) {
|
||||||
|
return 'Loading posts…';
|
||||||
|
}
|
||||||
|
const suffix = hasMore && count > 0 ? '+' : '';
|
||||||
|
const noun = count === 1 ? 'post' : 'posts';
|
||||||
|
return `Showing ${count}${suffix} ${noun}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForumNewPostBoxProps = {
|
||||||
|
sections: ForumSection[];
|
||||||
|
postCount: number;
|
||||||
|
loading?: boolean;
|
||||||
|
hasMore?: boolean;
|
||||||
|
defaultCategory?: string;
|
||||||
|
defaultTopicRoomId?: string;
|
||||||
|
onPublished?: (published: ForumPublishedPost) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ForumNewPostBox({
|
||||||
|
sections,
|
||||||
|
postCount,
|
||||||
|
loading = false,
|
||||||
|
hasMore = false,
|
||||||
|
defaultCategory,
|
||||||
|
defaultTopicRoomId,
|
||||||
|
onPublished,
|
||||||
|
}: ForumNewPostBoxProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
|
||||||
|
const handleOpen = useCallback(() => {
|
||||||
|
setModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box className={t.ForumNewPostBox}>
|
||||||
|
<Text as="p" className={t.ForumPostCountMeta} size="T200" priority="300">
|
||||||
|
{formatShowingPosts(postCount, loading, hasMore)}
|
||||||
|
</Text>
|
||||||
|
{userId ? (
|
||||||
|
<Button variant="Secondary" size="300" radii="300" onClick={handleOpen}>
|
||||||
|
<Icon src={Icons.Plus} size="100" />
|
||||||
|
<Text size="B400">New post</Text>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
{modalOpen && userId && (
|
||||||
|
<ForumNewPostModal
|
||||||
|
sections={sections}
|
||||||
|
initialCategory={defaultCategory}
|
||||||
|
initialTopicRoomId={defaultTopicRoomId}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onPublished={onPublished}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
268
src/app/features/forum/ForumNewPostModal.tsx
Normal file
268
src/app/features/forum/ForumNewPostModal.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
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 {
|
||||||
|
CustomEditor,
|
||||||
|
Toolbar,
|
||||||
|
isEmptyEditor,
|
||||||
|
resetEditor,
|
||||||
|
resetEditorHistory,
|
||||||
|
toMatrixCustomHTML,
|
||||||
|
toPlainText,
|
||||||
|
trimCustomHtml,
|
||||||
|
useEditor,
|
||||||
|
} from '../../components/editor';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { sendForumPost } from './forumFeed';
|
||||||
|
import { FORUM_EDITOR_OUTPUT_OPTS } from './forumRichText';
|
||||||
|
import type { ForumPublishedPost, ForumSection } from './types';
|
||||||
|
import { ForumTopicTargetFields } from './ForumTopicTargetFields';
|
||||||
|
import * as t from './forumTheme.css';
|
||||||
|
|
||||||
|
type ForumNewPostModalProps = {
|
||||||
|
sections: ForumSection[];
|
||||||
|
initialCategory?: string;
|
||||||
|
initialTopicRoomId?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onPublished?: (published: ForumPublishedPost) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ForumNewPostModal({
|
||||||
|
sections,
|
||||||
|
initialCategory = '',
|
||||||
|
initialTopicRoomId = '',
|
||||||
|
onClose,
|
||||||
|
onPublished,
|
||||||
|
}: ForumNewPostModalProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const editor = useEditor();
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [category, setCategory] = useState(initialCategory);
|
||||||
|
const [topicRoomId, setTopicRoomId] = useState(initialTopicRoomId);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const categoryTopics = useMemo(() => {
|
||||||
|
if (category) {
|
||||||
|
return sections.find((s) => s.title === category)?.topics ?? [];
|
||||||
|
}
|
||||||
|
return sections.flatMap((s) => s.topics);
|
||||||
|
}, [category, sections]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!topicRoomId) return;
|
||||||
|
if (!categoryTopics.some((topic) => topic.roomId === topicRoomId)) {
|
||||||
|
setTopicRoomId('');
|
||||||
|
}
|
||||||
|
}, [categoryTopics, topicRoomId]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (sending) return;
|
||||||
|
resetEditor(editor);
|
||||||
|
resetEditorHistory(editor);
|
||||||
|
onClose();
|
||||||
|
}, [editor, onClose, sending]);
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
||||||
|
async (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (sending) return;
|
||||||
|
|
||||||
|
const trimmedTitle = title.trim();
|
||||||
|
const plainText = toPlainText(editor.children, false).trim();
|
||||||
|
const formattedHtml = trimCustomHtml(
|
||||||
|
toMatrixCustomHTML(editor.children, FORUM_EDITOR_OUTPUT_OPTS)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!topicRoomId) {
|
||||||
|
setError('Select a topic for this post.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!trimmedTitle) {
|
||||||
|
setError('Post title is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isEmptyEditor(editor)) {
|
||||||
|
setError('Post body is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const eventId = await sendForumPost(mx, topicRoomId, {
|
||||||
|
title: trimmedTitle,
|
||||||
|
plainText,
|
||||||
|
formattedHtml,
|
||||||
|
});
|
||||||
|
resetEditor(editor);
|
||||||
|
resetEditorHistory(editor);
|
||||||
|
onPublished?.({
|
||||||
|
eventId,
|
||||||
|
topicRoomId,
|
||||||
|
title: trimmedTitle,
|
||||||
|
plainText,
|
||||||
|
formattedHtml,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to publish post');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editor, mx, onClose, onPublished, sending, title, topicRoomId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditorKeyDown: KeyboardEventHandler = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
if (isKeyHotkey('mod+enter', evt) && !sending) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.currentTarget.closest('form')?.requestSubmit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sending]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCategoryChange = useCallback((next: string) => {
|
||||||
|
setCategory(next);
|
||||||
|
setTopicRoomId('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: handleClose,
|
||||||
|
clickOutsideDeactivates: !sending,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal
|
||||||
|
size="500"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
maxHeight: 'min(90vh, 52rem)',
|
||||||
|
maxWidth: 'min(720px, calc(100vw - 2rem))',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Header
|
||||||
|
size="500"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S300,
|
||||||
|
paddingLeft: config.space.S400,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">New post</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" radii="300" onClick={handleClose} disabled={sending} aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Scroll size="300" hideTrack style={{ flex: '1 1 auto', minHeight: 0 }}>
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
ref={formRef}
|
||||||
|
className={t.ForumPostModalForm}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
direction="Column"
|
||||||
|
style={{ padding: config.space.S400 }}
|
||||||
|
>
|
||||||
|
<ForumTopicTargetFields
|
||||||
|
sections={sections}
|
||||||
|
category={category}
|
||||||
|
topicRoomId={topicRoomId}
|
||||||
|
onCategoryChange={handleCategoryChange}
|
||||||
|
onTopicChange={setTopicRoomId}
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="forum-post-title"
|
||||||
|
className={t.ForumFilterSelect}
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
maxLength={140}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
placeholder="Title"
|
||||||
|
aria-label="Title"
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box className={t.ForumPostModalBodyField}>
|
||||||
|
<Box className={t.ForumPostModalEditorWrap}>
|
||||||
|
<CustomEditor
|
||||||
|
editor={editor}
|
||||||
|
fillHeight
|
||||||
|
placeholder="Write your post…"
|
||||||
|
maxHeight={toRem(280)}
|
||||||
|
onKeyDown={handleEditorKeyDown}
|
||||||
|
bottom={
|
||||||
|
<div>
|
||||||
|
<Line variant="SurfaceVariant" size="300" />
|
||||||
|
<Toolbar />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box className={t.ForumPostModalActions}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={sending}
|
||||||
|
>
|
||||||
|
<Text size="B400">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="Primary" size="400" radii="300" disabled={sending}>
|
||||||
|
<Text size="B400">{sending ? 'Publishing…' : 'Publish post'}</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Modal>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/app/features/forum/ForumPostAuthor.tsx
Normal file
26
src/app/features/forum/ForumPostAuthor.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { ForumAuthorIdentity } from './ForumAuthorIdentity';
|
||||||
|
import * as theme from './forumTheme.css';
|
||||||
|
|
||||||
|
export function ForumPostAuthor({
|
||||||
|
room,
|
||||||
|
sender,
|
||||||
|
displayName,
|
||||||
|
}: {
|
||||||
|
room: Room | null;
|
||||||
|
sender: string;
|
||||||
|
displayName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={theme.ForumPostAuthor}>
|
||||||
|
<ForumAuthorIdentity
|
||||||
|
room={room}
|
||||||
|
sender={sender}
|
||||||
|
displayName={displayName}
|
||||||
|
wrapTag="span"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/app/features/forum/ForumPostList.tsx
Normal file
124
src/app/features/forum/ForumPostList.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import type { ForumThreadSummary } from './types';
|
||||||
|
import { formatForumTimeAgo } from './forumTime';
|
||||||
|
import { ForumPostAuthor } from './ForumPostAuthor';
|
||||||
|
import * as t from './forumTheme.css';
|
||||||
|
|
||||||
|
function categoryTopicLabel(thread: ForumThreadSummary): string {
|
||||||
|
const section = thread.sectionTitle || '';
|
||||||
|
const topic = thread.topicName || '';
|
||||||
|
if (section && topic) return `${section}/${topic}`;
|
||||||
|
if (topic) return topic;
|
||||||
|
if (section) return `${section}/all topics`;
|
||||||
|
return 'all categories / all topics';
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForumPostListProps = {
|
||||||
|
threads: ForumThreadSummary[];
|
||||||
|
selectedPostId: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
getTopicRoom: (roomId: string | undefined) => Room | null;
|
||||||
|
onSelectPost: (eventId: string, topicRoomId?: string) => void;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadingMore?: boolean;
|
||||||
|
onLoadMore?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ForumPostList({
|
||||||
|
threads,
|
||||||
|
selectedPostId,
|
||||||
|
loading,
|
||||||
|
getTopicRoom,
|
||||||
|
onSelectPost,
|
||||||
|
hasMore = false,
|
||||||
|
loadingMore = false,
|
||||||
|
onLoadMore,
|
||||||
|
}: ForumPostListProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={t.ForumPostScroll}>
|
||||||
|
{loading && threads.length === 0 && (
|
||||||
|
<Box alignItems="Center" gap="200" style={{ padding: '1rem' }}>
|
||||||
|
<Spinner size="200" />
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
Loading posts…
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!loading && threads.length === 0 && (
|
||||||
|
<Text as="p" className={t.ForumListLoading} size="T300" priority="300">
|
||||||
|
No threads match these filters.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<ul className={t.ForumPostList}>
|
||||||
|
{threads.map((thread) => {
|
||||||
|
const isActive = thread.eventId === selectedPostId;
|
||||||
|
const topicRoom = getTopicRoom(thread.topicRoomId);
|
||||||
|
return (
|
||||||
|
<li key={thread.eventId} className={t.ForumPostItem}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={t.ForumPostLink}
|
||||||
|
data-active={isActive ? 'true' : undefined}
|
||||||
|
data-pinned={thread.isPinned ? 'true' : undefined}
|
||||||
|
onClick={() => onSelectPost(thread.eventId, thread.topicRoomId)}
|
||||||
|
>
|
||||||
|
<div className={t.ForumPostHead}>
|
||||||
|
<div className={t.ForumPostHeadMain}>
|
||||||
|
{thread.isPinned && <Icon src={Icons.Pin} size="100" filled />}
|
||||||
|
<span className={t.ForumPostTitle} data-role="title">
|
||||||
|
{thread.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{thread.totalReplies > 0 && (
|
||||||
|
<Box as="span" className={t.ForumReplyMeta} alignItems="Center" gap="100" aria-hidden>
|
||||||
|
{thread.totalReplies > 1 && (
|
||||||
|
<Text as="span" className={t.ForumReplyCount} size="L400">
|
||||||
|
{thread.totalReplies}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Icon
|
||||||
|
src={thread.totalReplies === 1 ? Icons.Mail : Icons.MailPlus}
|
||||||
|
size="100"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={t.ForumPostFoot}>
|
||||||
|
<ForumPostAuthor
|
||||||
|
room={topicRoom}
|
||||||
|
sender={thread.sender}
|
||||||
|
displayName={thread.senderDisplayName}
|
||||||
|
/>
|
||||||
|
<div className={t.ForumPostMeta}>
|
||||||
|
<span className={t.ForumCategoryLabel}>{categoryTopicLabel(thread)}</span>
|
||||||
|
<Text as="span" size="L400" priority="300">
|
||||||
|
{formatForumTimeAgo(thread.lastActivityTs)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{hasMore && onLoadMore && (
|
||||||
|
<div className={t.ForumListFooter}>
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
fill="Soft"
|
||||||
|
onClick={onLoadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
>
|
||||||
|
{loadingMore ? <Spinner size="100" /> : <Text size="B400">Load older posts</Text>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/app/features/forum/ForumRichReplyComposer.tsx
Normal file
119
src/app/features/forum/ForumRichReplyComposer.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import React, { FormEventHandler, KeyboardEventHandler, useCallback, useState } from 'react';
|
||||||
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import { Button, Line, Text, color, toRem } from 'folds';
|
||||||
|
import {
|
||||||
|
CustomEditor,
|
||||||
|
Toolbar,
|
||||||
|
isEmptyEditor,
|
||||||
|
resetEditor,
|
||||||
|
resetEditorHistory,
|
||||||
|
toMatrixCustomHTML,
|
||||||
|
toPlainText,
|
||||||
|
trimCustomHtml,
|
||||||
|
useEditor,
|
||||||
|
} from '../../components/editor';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { sendForumReply, createForumReplyFromPayload } from './forumFeed';
|
||||||
|
import { FORUM_EDITOR_OUTPUT_OPTS } from './forumRichText';
|
||||||
|
import type { ForumPost } from './types';
|
||||||
|
import * as t from './forumTheme.css';
|
||||||
|
|
||||||
|
type ForumRichReplyComposerProps = {
|
||||||
|
roomId: string;
|
||||||
|
threadRootId: string;
|
||||||
|
replyToEventId: string;
|
||||||
|
onSent?: (reply: ForumPost) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ForumRichReplyComposer({
|
||||||
|
roomId,
|
||||||
|
threadRootId,
|
||||||
|
replyToEventId,
|
||||||
|
onSent,
|
||||||
|
onCancel,
|
||||||
|
}: ForumRichReplyComposerProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const editor = useEditor();
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
||||||
|
async (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (sending || isEmptyEditor(editor)) return;
|
||||||
|
|
||||||
|
const plainText = toPlainText(editor.children, false).trim();
|
||||||
|
const formattedHtml = trimCustomHtml(
|
||||||
|
toMatrixCustomHTML(editor.children, FORUM_EDITOR_OUTPUT_OPTS)
|
||||||
|
);
|
||||||
|
if (!plainText) return;
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const eventId = await sendForumReply(mx, roomId, threadRootId, replyToEventId, {
|
||||||
|
plainText,
|
||||||
|
formattedHtml,
|
||||||
|
});
|
||||||
|
resetEditor(editor);
|
||||||
|
resetEditorHistory(editor);
|
||||||
|
onSent?.(
|
||||||
|
createForumReplyFromPayload(mx, roomId, eventId, replyToEventId, {
|
||||||
|
plainText,
|
||||||
|
formattedHtml,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to send reply');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editor, mx, onSent, replyToEventId, roomId, sending, threadRootId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
if (isKeyHotkey('mod+enter', evt) && !sending) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.currentTarget.closest('form')?.requestSubmit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sending]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className={t.ForumFeedReplyForm} onSubmit={handleSubmit}>
|
||||||
|
<div className={t.ForumInlineReplyEditorWrap}>
|
||||||
|
<CustomEditor
|
||||||
|
editor={editor}
|
||||||
|
placeholder="Write a reply…"
|
||||||
|
maxHeight={toRem(240)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
bottom={
|
||||||
|
<div>
|
||||||
|
<Line variant="SurfaceVariant" size="300" />
|
||||||
|
<Toolbar />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<div className={t.ForumReplyFormActions}>
|
||||||
|
{onCancel && (
|
||||||
|
<Button type="button" variant="SurfaceVariant" size="300" onClick={onCancel} disabled={sending}>
|
||||||
|
<Text size="B400">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="submit" className={t.ForumReplySubmitBtn} variant="Primary" size="300" disabled={sending}>
|
||||||
|
<Text size="B400">{sending ? 'Sending…' : 'Send reply'}</Text>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/app/features/forum/ForumSpaceLayout.tsx
Normal file
30
src/app/features/forum/ForumSpaceLayout.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React, { ReactNode, useMemo } from 'react';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||||
|
import { useSpace } from '../../hooks/useSpace';
|
||||||
|
import { isForumContainer, shouldShowForumLobby } from '../../utils/room';
|
||||||
|
import { ForumBoardProvider } from './ForumBoardContext';
|
||||||
|
|
||||||
|
/** Shares forum board state between space sidebar and lobby/room outlet. */
|
||||||
|
export function ForumSpaceLayout({ children }: { children: ReactNode }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const space = useSpace();
|
||||||
|
const selectedRoomId = useSelectedRoom();
|
||||||
|
|
||||||
|
const scope = useMemo(() => {
|
||||||
|
const room = selectedRoomId ? mx.getRoom(selectedRoomId) : null;
|
||||||
|
const topicRoomId =
|
||||||
|
room && room.roomId !== space.roomId && !isForumContainer(room) ? room.roomId : undefined;
|
||||||
|
return { forumSpaceId: space.roomId, topicRoomId };
|
||||||
|
}, [mx, selectedRoomId, space.roomId]);
|
||||||
|
|
||||||
|
if (!shouldShowForumLobby(space)) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ForumBoardProvider forumSpace={space} scope={scope}>
|
||||||
|
{children}
|
||||||
|
</ForumBoardProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
src/app/features/forum/ForumSpaceShell.tsx
Normal file
186
src/app/features/forum/ForumSpaceShell.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Box, Icon, Icons, Text, color } from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
|
import { PowerLevelsContextProvider } from '../../hooks/usePowerLevels';
|
||||||
|
import { Page, PageContent } from '../../components/page';
|
||||||
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
|
import { LobbyHeader } from '../lobby/LobbyHeader';
|
||||||
|
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||||
|
import { useForumBoardContext } from './ForumBoardContext';
|
||||||
|
import { ForumFilterBar } from './ForumFilterBar';
|
||||||
|
import { ForumPostList } from './ForumPostList';
|
||||||
|
import { ForumNewPostBox } from './ForumNewPostBox';
|
||||||
|
import { ForumThreadDetail } from './ForumThreadDetail';
|
||||||
|
import type { ForumPost, ForumPublishedPost } from './types';
|
||||||
|
import * as theme from './forumTheme.css';
|
||||||
|
|
||||||
|
/** matrixsso-style feed in the Cinny main pane (Paarrot theme + folds icons). */
|
||||||
|
export function ForumSpaceShell() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [optimisticPost, setOptimisticPost] = useState<ForumPost | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
forumSpace,
|
||||||
|
sections,
|
||||||
|
threads,
|
||||||
|
query,
|
||||||
|
loadingSections,
|
||||||
|
loadingThreads,
|
||||||
|
error,
|
||||||
|
selectedPostId,
|
||||||
|
activeTopicRoomId,
|
||||||
|
setQueryParam,
|
||||||
|
setCategory,
|
||||||
|
selectPost,
|
||||||
|
loadMorePosts,
|
||||||
|
hasMorePosts,
|
||||||
|
loadingMore,
|
||||||
|
insertThreadFromNewPost,
|
||||||
|
recordThreadReply,
|
||||||
|
} = useForumBoardContext();
|
||||||
|
|
||||||
|
const spacePowerLevels = usePowerLevels(forumSpace);
|
||||||
|
const activeTopicRoom = activeTopicRoomId ? mx.getRoom(activeTopicRoomId) : null;
|
||||||
|
const topicSelected = Boolean(query.topic);
|
||||||
|
|
||||||
|
const setActiveThreadId = useSetAtom(
|
||||||
|
activeThreadIdAtomFamily(activeTopicRoomId ?? forumSpace.roomId)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPostId && activeTopicRoomId) {
|
||||||
|
setActiveThreadId(selectedPostId);
|
||||||
|
return () => setActiveThreadId(undefined);
|
||||||
|
}
|
||||||
|
setActiveThreadId(undefined);
|
||||||
|
return undefined;
|
||||||
|
}, [selectedPostId, activeTopicRoomId, setActiveThreadId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (optimisticPost && optimisticPost.eventId !== selectedPostId) {
|
||||||
|
setOptimisticPost(null);
|
||||||
|
}
|
||||||
|
}, [optimisticPost, selectedPostId]);
|
||||||
|
|
||||||
|
const selectedThread = useMemo(
|
||||||
|
() => threads.find((t) => t.eventId === selectedPostId),
|
||||||
|
[threads, selectedPostId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getTopicRoom = useCallback(
|
||||||
|
(roomId: string | undefined) => (roomId ? mx.getRoom(roomId) : null),
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePostPublished = useCallback(
|
||||||
|
(published: ForumPublishedPost) => {
|
||||||
|
const post = insertThreadFromNewPost(published);
|
||||||
|
if (post) {
|
||||||
|
setOptimisticPost(post);
|
||||||
|
}
|
||||||
|
selectPost(published.eventId, published.topicRoomId);
|
||||||
|
},
|
||||||
|
[insertThreadFromNewPost, selectPost]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReplySent = useCallback(
|
||||||
|
(_reply: ForumPost) => {
|
||||||
|
if (selectedPostId) {
|
||||||
|
recordThreadReply(selectedPostId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[recordThreadReply, selectedPostId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showThread = Boolean(selectedPostId && activeTopicRoom);
|
||||||
|
|
||||||
|
const threadInitialPost =
|
||||||
|
optimisticPost?.eventId === selectedPostId ? optimisticPost : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PowerLevelsContextProvider value={spacePowerLevels}>
|
||||||
|
<Page className={classNames(ContainerColor({ variant: 'Background' }), theme.ForumAppRoot)}>
|
||||||
|
<PageContent className={theme.ForumPageContent}>
|
||||||
|
<main id="topicLiveApp" className={theme.ForumTopicLiveApp}>
|
||||||
|
<section className={theme.ForumBlenderLayout}>
|
||||||
|
<div className={theme.ForumLeftColumn}>
|
||||||
|
<div className={theme.ForumLeftHeader}>
|
||||||
|
<LobbyHeader showSpaceToolbar powerLevels={spacePowerLevels} />
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<aside className={theme.ForumDetailPane}>
|
||||||
|
{showThread && activeTopicRoom ? (
|
||||||
|
<div id="topicPostDetail" className={theme.ForumTopicPostDetail}>
|
||||||
|
<ForumThreadDetail
|
||||||
|
roomId={activeTopicRoom.roomId}
|
||||||
|
rootEventId={selectedPostId!}
|
||||||
|
titleHint={selectedThread?.title}
|
||||||
|
initialPost={threadInitialPost}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</PageContent>
|
||||||
|
</Page>
|
||||||
|
</PowerLevelsContextProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
src/app/features/forum/ForumThreadDetail.tsx
Normal file
223
src/app/features/forum/ForumThreadDetail.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
// @refresh reset
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Box, Spinner, Text } from 'folds';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { formatForumTimeAgo } from './forumTime';
|
||||||
|
import { loadPostThread } from './forumFeed';
|
||||||
|
import { appendReplyToThread } from './forumTopicHelpers';
|
||||||
|
import type { ForumPost } from './types';
|
||||||
|
import { ForumAuthorIdentity } from './ForumAuthorIdentity';
|
||||||
|
import { ForumMessageBody } from './ForumMessageBody';
|
||||||
|
import { ForumMessageActions } from './ForumMessageActions';
|
||||||
|
import { ForumRichReplyComposer } from './ForumRichReplyComposer';
|
||||||
|
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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={theme.ForumThreadReply} data-event-id={reply.eventId}>
|
||||||
|
<div className={theme.ForumThreadReplyInner}>
|
||||||
|
<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)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{reply.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}
|
||||||
|
room={room}
|
||||||
|
threadRootId={threadRootId}
|
||||||
|
roomId={roomId}
|
||||||
|
depth={depth + 1}
|
||||||
|
onReplySent={onReplySent}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForumThreadDetailProps = {
|
||||||
|
roomId: string;
|
||||||
|
rootEventId: string;
|
||||||
|
titleHint?: string;
|
||||||
|
initialPost?: ForumPost | null;
|
||||||
|
onReplySent?: (reply: ForumPost) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ForumThreadDetail({
|
||||||
|
roomId,
|
||||||
|
rootEventId,
|
||||||
|
titleHint,
|
||||||
|
initialPost,
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (initialPost?.eventId === rootEventId) {
|
||||||
|
setPost(initialPost);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
loadPostThread(mx, roomId, rootEventId)
|
||||||
|
.then((loaded) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setPost(loaded);
|
||||||
|
if (!loaded) setError('Post not found.');
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Could not load this thread.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [mx, roomId, rootEventId, initialPost]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box alignItems="Center" gap="200" style={{ padding: '1rem' }}>
|
||||||
|
<Spinner size="200" />
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
Loading…
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !post) {
|
||||||
|
return (
|
||||||
|
<Text as="p" className={theme.ForumListLoading} size="T300" priority="300">
|
||||||
|
{error || 'Post not found.'}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
403
src/app/features/forum/ForumTopicSearchInput.tsx
Normal file
403
src/app/features/forum/ForumTopicSearchInput.tsx
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Icon, IconButton, Icons } from 'folds';
|
||||||
|
import {
|
||||||
|
TOPIC_SEARCH_QUALIFIERS,
|
||||||
|
collectAuthorHintsFromThreads,
|
||||||
|
collectTitleHintsFromThreads,
|
||||||
|
insertTopicSearchQualifierAt,
|
||||||
|
insertTopicSearchValueAt,
|
||||||
|
matchAuthorHints,
|
||||||
|
matchTitleHints,
|
||||||
|
matchTopicSearchQualifiers,
|
||||||
|
parseTopicSearchQuery,
|
||||||
|
readActiveQualifierValueAt,
|
||||||
|
readQualifierPartialAt,
|
||||||
|
type TopicSearchAuthorHint,
|
||||||
|
type TopicSearchQualifier,
|
||||||
|
} from './topicSearch';
|
||||||
|
import type { ForumThreadSummary } from './types';
|
||||||
|
import * as t from './forumTheme.css';
|
||||||
|
|
||||||
|
type ForumTopicSearchInputProps = {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
threads?: ForumThreadSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SuggestionMode = 'qualifier' | 'author' | 'title';
|
||||||
|
|
||||||
|
export function ForumTopicSearchInput({ value, onChange, threads = [] }: ForumTopicSearchInputProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const mirrorRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const pillsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [caret, setCaret] = useState(0);
|
||||||
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||||
|
const [tabPartial, setTabPartial] = useState('');
|
||||||
|
|
||||||
|
const authorHints = useMemo(() => collectAuthorHintsFromThreads(threads), [threads]);
|
||||||
|
const titleHints = useMemo(() => collectTitleHintsFromThreads(threads), [threads]);
|
||||||
|
|
||||||
|
const qualifierPartial = readQualifierPartialAt(value, caret);
|
||||||
|
const valueContext = readActiveQualifierValueAt(value, caret);
|
||||||
|
const qualifierMatches = qualifierPartial
|
||||||
|
? matchTopicSearchQualifiers(qualifierPartial)
|
||||||
|
: TOPIC_SEARCH_QUALIFIERS;
|
||||||
|
|
||||||
|
const authorMatches = useMemo(
|
||||||
|
() =>
|
||||||
|
valueContext?.key === 'from' ? matchAuthorHints(valueContext.partial, authorHints) : [],
|
||||||
|
[authorHints, valueContext]
|
||||||
|
);
|
||||||
|
|
||||||
|
const titleMatches = useMemo(
|
||||||
|
() =>
|
||||||
|
valueContext?.key === 'intitle' ? matchTitleHints(valueContext.partial, titleHints) : [],
|
||||||
|
[titleHints, valueContext]
|
||||||
|
);
|
||||||
|
|
||||||
|
const suggestionMode: SuggestionMode | null = useMemo(() => {
|
||||||
|
if (!focused) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (valueContext?.key === 'from' && authorHints.length > 0) {
|
||||||
|
return 'author';
|
||||||
|
}
|
||||||
|
if (valueContext?.key === 'intitle' && titleHints.length > 0) {
|
||||||
|
return 'title';
|
||||||
|
}
|
||||||
|
if (!qualifierPartial || qualifierMatches.length > 0) {
|
||||||
|
return 'qualifier';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [
|
||||||
|
focused,
|
||||||
|
valueContext,
|
||||||
|
authorHints.length,
|
||||||
|
titleHints.length,
|
||||||
|
qualifierPartial,
|
||||||
|
qualifierMatches.length,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const suggestionCount =
|
||||||
|
suggestionMode === 'author'
|
||||||
|
? authorMatches.length
|
||||||
|
: suggestionMode === 'title'
|
||||||
|
? titleMatches.length
|
||||||
|
: suggestionMode === 'qualifier'
|
||||||
|
? qualifierMatches.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const showPills = suggestionMode !== null && suggestionCount > 0;
|
||||||
|
const parsed = parseTopicSearchQuery(value);
|
||||||
|
|
||||||
|
const positionFloatingPills = useCallback(() => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
const root = pillsRef.current;
|
||||||
|
const mirror = mirrorRef.current;
|
||||||
|
if (!input || !root || root.hidden) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const caretPos = input.selectionStart ?? input.value.length;
|
||||||
|
const textBefore = input.value.slice(0, caretPos).replace(/\s/g, '\u00a0') || '\u200b';
|
||||||
|
|
||||||
|
if (mirror) {
|
||||||
|
const inputStyle = window.getComputedStyle(input);
|
||||||
|
mirror.textContent = textBefore;
|
||||||
|
mirror.style.font = inputStyle.font;
|
||||||
|
mirror.style.letterSpacing = inputStyle.letterSpacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mirrorWidth = mirror?.offsetWidth ?? 0;
|
||||||
|
const maxLeft = Math.max(0, input.clientWidth - root.offsetWidth);
|
||||||
|
const left = Math.min(Math.max(0, mirrorWidth - 12), maxLeft);
|
||||||
|
root.style.left = `${left}px`;
|
||||||
|
|
||||||
|
const caretLeft = Math.min(Math.max(12, mirrorWidth), input.clientWidth - 12);
|
||||||
|
root.style.setProperty('--forum-search-caret', `${caretLeft - left}px`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
positionFloatingPills();
|
||||||
|
}, [value, caret, showPills, positionFloatingPills]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHighlightIndex(0);
|
||||||
|
setTabPartial('');
|
||||||
|
}, [suggestionMode, valueContext?.partial, qualifierPartial]);
|
||||||
|
|
||||||
|
const applyInputUpdate = useCallback(
|
||||||
|
(nextValue: string, nextCursor: number) => {
|
||||||
|
onChange(nextValue);
|
||||||
|
setTabPartial('');
|
||||||
|
setHighlightIndex(0);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input) return;
|
||||||
|
input.focus();
|
||||||
|
input.setSelectionRange(nextCursor, nextCursor);
|
||||||
|
setCaret(nextCursor);
|
||||||
|
positionFloatingPills();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onChange, positionFloatingPills]
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertQualifier = useCallback(
|
||||||
|
(qualifier: TopicSearchQualifier) => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input) return;
|
||||||
|
const caretPos = input.selectionStart ?? input.value.length;
|
||||||
|
const { value: nextValue, cursor } = insertTopicSearchQualifierAt(value, caretPos, qualifier);
|
||||||
|
applyInputUpdate(nextValue, cursor);
|
||||||
|
},
|
||||||
|
[applyInputUpdate, value]
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertValue = useCallback(
|
||||||
|
(replacement: string) => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input) return;
|
||||||
|
const caretPos = input.selectionStart ?? input.value.length;
|
||||||
|
const { value: nextValue, cursor } = insertTopicSearchValueAt(value, caretPos, replacement);
|
||||||
|
applyInputUpdate(nextValue, cursor);
|
||||||
|
},
|
||||||
|
[applyInputUpdate, value]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectAtIndex = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (suggestionMode === 'author') {
|
||||||
|
const hint = authorMatches[index];
|
||||||
|
if (hint) {
|
||||||
|
insertValue(hint.displayName);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (suggestionMode === 'title') {
|
||||||
|
const title = titleMatches[index];
|
||||||
|
if (title) {
|
||||||
|
insertValue(title);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (suggestionMode === 'qualifier') {
|
||||||
|
const qualifier = qualifierMatches[index];
|
||||||
|
if (qualifier) {
|
||||||
|
insertQualifier(qualifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authorMatches, insertQualifier, insertValue, qualifierMatches, suggestionMode, titleMatches]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectHighlighted = useCallback(() => {
|
||||||
|
selectAtIndex(highlightIndex);
|
||||||
|
}, [highlightIndex, selectAtIndex]);
|
||||||
|
|
||||||
|
const moveHighlight = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
if (!suggestionCount) return;
|
||||||
|
setHighlightIndex((idx) => (idx + delta + suggestionCount) % suggestionCount);
|
||||||
|
},
|
||||||
|
[suggestionCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
|
||||||
|
const pillsVisible = showPills && suggestionCount > 0;
|
||||||
|
|
||||||
|
if (pillsVisible && (event.key === 'ArrowRight' || event.key === 'ArrowDown')) {
|
||||||
|
event.preventDefault();
|
||||||
|
moveHighlight(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pillsVisible && (event.key === 'ArrowLeft' || event.key === 'ArrowUp')) {
|
||||||
|
event.preventDefault();
|
||||||
|
moveHighlight(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
pillsVisible &&
|
||||||
|
event.key === 'Enter' &&
|
||||||
|
(suggestionMode === 'author' ||
|
||||||
|
suggestionMode === 'title' ||
|
||||||
|
(suggestionMode === 'qualifier' && qualifierPartial))
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
selectHighlighted();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape' && pillsVisible) {
|
||||||
|
event.preventDefault();
|
||||||
|
setTabPartial('');
|
||||||
|
setHighlightIndex(0);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== 'Tab' || event.altKey || event.ctrlKey || event.metaKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pillsVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const tabKey =
|
||||||
|
suggestionMode === 'author'
|
||||||
|
? `author:${valueContext?.partial ?? ''}`
|
||||||
|
: suggestionMode === 'title'
|
||||||
|
? `title:${valueContext?.partial ?? ''}`
|
||||||
|
: `qualifier:${qualifierPartial}`;
|
||||||
|
|
||||||
|
const nextIndex =
|
||||||
|
tabKey === tabPartial ? (highlightIndex + 1) % suggestionCount : 0;
|
||||||
|
setTabPartial(tabKey);
|
||||||
|
setHighlightIndex(nextIndex);
|
||||||
|
selectAtIndex(nextIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncCaret = () => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input) return;
|
||||||
|
setCaret(input.selectionStart ?? input.value.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pillsLabel =
|
||||||
|
suggestionMode === 'author'
|
||||||
|
? 'Author suggestions'
|
||||||
|
: suggestionMode === 'title'
|
||||||
|
? 'Title suggestions'
|
||||||
|
: 'Search qualifiers';
|
||||||
|
|
||||||
|
const matchKeys = new Set(qualifierMatches.map((item) => item.key));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={t.ForumSearchField}>
|
||||||
|
<div className={classNames(t.ForumSearchRow, t.ForumSearchRowAnchor)}>
|
||||||
|
<span ref={mirrorRef} className={t.ForumSearchCaretMirror} aria-hidden />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className={t.ForumSearchInput}
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
onChange(event.target.value);
|
||||||
|
syncCaret();
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
syncCaret();
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
window.setTimeout(() => setFocused(false), 120);
|
||||||
|
}}
|
||||||
|
onClick={syncCaret}
|
||||||
|
onKeyUp={syncCaret}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Search posts…"
|
||||||
|
title="Examples: intitle:release inpost:bug inthread:thanks from:alice"
|
||||||
|
aria-label="Search posts"
|
||||||
|
aria-controls="forumSearchPills"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={pillsRef}
|
||||||
|
id="forumSearchPills"
|
||||||
|
className={t.ForumSearchPills}
|
||||||
|
role="listbox"
|
||||||
|
aria-label={pillsLabel}
|
||||||
|
hidden={!showPills}
|
||||||
|
>
|
||||||
|
<div className={t.ForumSearchPillsInner}>
|
||||||
|
{suggestionMode === 'author' &&
|
||||||
|
authorMatches.map((hint, index) => (
|
||||||
|
<button
|
||||||
|
key={hint.userId}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={index === highlightIndex}
|
||||||
|
className={classNames(t.ForumSearchPill, t.ForumSearchPillValue, {
|
||||||
|
[t.ForumSearchPillHighlighted]: index === highlightIndex,
|
||||||
|
})}
|
||||||
|
title={`${hint.displayName} (${hint.userId})`}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => insertValue(hint.displayName)}
|
||||||
|
>
|
||||||
|
{hint.displayName}
|
||||||
|
<span style={{ opacity: 0.65 }}> @{hint.localpart}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{suggestionMode === 'title' &&
|
||||||
|
titleMatches.map((title, index) => (
|
||||||
|
<button
|
||||||
|
key={title}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={index === highlightIndex}
|
||||||
|
className={classNames(t.ForumSearchPill, t.ForumSearchPillValue, {
|
||||||
|
[t.ForumSearchPillHighlighted]: index === highlightIndex,
|
||||||
|
})}
|
||||||
|
title={title}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => insertValue(title)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{suggestionMode === 'qualifier' &&
|
||||||
|
TOPIC_SEARCH_QUALIFIERS.map((qualifier) => {
|
||||||
|
const visible = !qualifierPartial || matchKeys.has(qualifier.key);
|
||||||
|
const matchIndex = qualifierMatches.findIndex((item) => item.key === qualifier.key);
|
||||||
|
const highlighted = visible && matchIndex === highlightIndex;
|
||||||
|
const inUse = Boolean(parsed.qualifiers[qualifier.key]?.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={qualifier.key}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={highlighted}
|
||||||
|
className={classNames(t.ForumSearchPill, {
|
||||||
|
[t.ForumSearchPillHighlighted]: highlighted,
|
||||||
|
[t.ForumSearchPillInUse]: inUse,
|
||||||
|
})}
|
||||||
|
data-qualifier={qualifier.key}
|
||||||
|
title={qualifier.label}
|
||||||
|
aria-label={`${qualifier.label} (${qualifier.token})`}
|
||||||
|
hidden={!visible}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => insertQualifier(qualifier)}
|
||||||
|
>
|
||||||
|
{qualifier.token}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
className={t.ForumSearchSubmit}
|
||||||
|
type="submit"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
aria-label="Search"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Search} size="200" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/app/features/forum/ForumTopicTargetFields.tsx
Normal file
67
src/app/features/forum/ForumTopicTargetFields.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import type { ForumSection } from './types';
|
||||||
|
import * as t from './forumTheme.css';
|
||||||
|
|
||||||
|
type ForumTopicTargetFieldsProps = {
|
||||||
|
sections: ForumSection[];
|
||||||
|
category: string;
|
||||||
|
topicRoomId: string;
|
||||||
|
onCategoryChange: (category: string) => void;
|
||||||
|
onTopicChange: (topicRoomId: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ForumTopicTargetFields({
|
||||||
|
sections,
|
||||||
|
category,
|
||||||
|
topicRoomId,
|
||||||
|
onCategoryChange,
|
||||||
|
onTopicChange,
|
||||||
|
disabled = false,
|
||||||
|
}: ForumTopicTargetFieldsProps) {
|
||||||
|
const categoryTopics = useMemo(() => {
|
||||||
|
if (category) {
|
||||||
|
return sections.find((s) => s.title === category)?.topics ?? [];
|
||||||
|
}
|
||||||
|
return sections.flatMap((s) => s.topics);
|
||||||
|
}, [category, sections]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={t.ForumTopicTargetFields}>
|
||||||
|
<select
|
||||||
|
className={t.ForumFilterSelect}
|
||||||
|
aria-label="Category"
|
||||||
|
value={category}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onCategoryChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All categories</option>
|
||||||
|
{sections.map((section) => (
|
||||||
|
<option key={section.title} value={section.title}>
|
||||||
|
{section.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className={t.ForumFilterSelect}
|
||||||
|
aria-label="Topic"
|
||||||
|
value={topicRoomId}
|
||||||
|
disabled={disabled || categoryTopics.length === 0}
|
||||||
|
onChange={(e) => onTopicChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select a topic</option>
|
||||||
|
{categoryTopics.length === 0 && category ? (
|
||||||
|
<option value="" disabled>
|
||||||
|
No topics yet — use Add Topic
|
||||||
|
</option>
|
||||||
|
) : null}
|
||||||
|
{categoryTopics.map((topic) => (
|
||||||
|
<option key={topic.roomId} value={topic.roomId}>
|
||||||
|
{topic.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
546
src/app/features/forum/forumFeed.ts
Normal file
546
src/app/features/forum/forumFeed.ts
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
import {
|
||||||
|
Direction,
|
||||||
|
EventType,
|
||||||
|
type IEventRelation,
|
||||||
|
type IRoomEvent,
|
||||||
|
type MatrixClient,
|
||||||
|
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';
|
||||||
|
|
||||||
|
type RawRelation = {
|
||||||
|
rel_type?: string;
|
||||||
|
event_id?: string;
|
||||||
|
'm.in_reply_to'?: { event_id?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
function threadRelationFromContent(content: Record<string, unknown>): RawRelation | null {
|
||||||
|
const relates = content['m.relates_to'];
|
||||||
|
if (!relates || typeof relates !== 'object') return null;
|
||||||
|
return relates as RawRelation;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const count = Number(thread?.count);
|
||||||
|
if (!Number.isFinite(count) || count < 0) return undefined;
|
||||||
|
return Math.floor(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
function postTitleFromContent(content: Record<string, unknown>): { title: string; body: string } {
|
||||||
|
const rawBody = typeof content.body === 'string' ? content.body : '';
|
||||||
|
const customTitle =
|
||||||
|
typeof content['com.matrixsso.title'] === 'string'
|
||||||
|
? String(content['com.matrixsso.title']).trim()
|
||||||
|
: '';
|
||||||
|
const firstLine = rawBody.split(/\r?\n/, 1)[0]?.trim() ?? '';
|
||||||
|
const title = (customTitle || firstLine || '(untitled post)').slice(0, 160);
|
||||||
|
|
||||||
|
let body = rawBody;
|
||||||
|
if (customTitle && rawBody.startsWith(customTitle)) {
|
||||||
|
body = rawBody.slice(customTitle.length).replace(/^\s+/, '');
|
||||||
|
} else {
|
||||||
|
const [leadingBlock, ...restBlocks] = rawBody.split(/\r?\n\r?\n/);
|
||||||
|
if (restBlocks.length > 0 && leadingBlock.trim() === firstLine) {
|
||||||
|
body = restBlocks.join('\n\n').trim();
|
||||||
|
} else if (firstLine && rawBody.startsWith(firstLine)) {
|
||||||
|
body = rawBody.slice(firstLine.length).replace(/^\s+/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title, body: body || rawBody };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortRepliesRecursively(posts: ForumPost[]) {
|
||||||
|
posts.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
for (const post of posts) {
|
||||||
|
if (post.replies.length > 0) sortRepliesRecursively(post.replies);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPosts(mx: MatrixClient, roomId: string, timelineEvents: IRoomEvent[]): ForumPost[] {
|
||||||
|
const redactedIds = new Set(
|
||||||
|
timelineEvents.filter((e) => e.unsigned?.redacted_because).map((e) => e.event_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalized = timelineEvents
|
||||||
|
.filter(
|
||||||
|
(event) =>
|
||||||
|
event.type === EventType.RoomMessage &&
|
||||||
|
event.event_id &&
|
||||||
|
event.content &&
|
||||||
|
!redactedIds.has(event.event_id)
|
||||||
|
)
|
||||||
|
.map((event) => {
|
||||||
|
const content = event.content as Record<string, unknown>;
|
||||||
|
const relation = threadRelationFromContent(content);
|
||||||
|
const isThreadReply =
|
||||||
|
relation?.rel_type === RelationType.Thread || relation?.rel_type === 'm.thread';
|
||||||
|
|
||||||
|
if (typeof content.body !== 'string') return null;
|
||||||
|
|
||||||
|
const { title, body } = postTitleFromContent(content);
|
||||||
|
const customTitle =
|
||||||
|
typeof content['com.matrixsso.title'] === 'string'
|
||||||
|
? String(content['com.matrixsso.title']).trim()
|
||||||
|
: '';
|
||||||
|
const bodyHtml = bodyHtmlFromMessageContent(content, {
|
||||||
|
stripTitleHeading: Boolean(customTitle),
|
||||||
|
});
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
const displayName = room
|
||||||
|
? getMemberDisplayName(room, event.sender || '') ?? event.sender
|
||||||
|
: event.sender;
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventId: event.event_id!,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
bodyHtml,
|
||||||
|
sender: event.sender || 'unknown',
|
||||||
|
senderDisplayName: displayName,
|
||||||
|
timestamp: event.origin_server_ts || 0,
|
||||||
|
relation,
|
||||||
|
bundledReplyCount: !isThreadReply ? parseBundledThreadReplyCount(event) : undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((e): e is NonNullable<typeof e> => e !== null)
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
|
const postsById = new Map<string, ForumPost>();
|
||||||
|
for (const event of normalized) {
|
||||||
|
postsById.set(event.eventId, {
|
||||||
|
eventId: event.eventId,
|
||||||
|
title: event.title,
|
||||||
|
body: event.body,
|
||||||
|
bodyHtml: event.bodyHtml,
|
||||||
|
sender: event.sender,
|
||||||
|
senderDisplayName: event.senderDisplayName,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
bundledReplyCount: event.bundledReplyCount,
|
||||||
|
replies: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots: ForumPost[] = [];
|
||||||
|
const placedReplyIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const event of normalized) {
|
||||||
|
const current = postsById.get(event.eventId);
|
||||||
|
if (!current) continue;
|
||||||
|
|
||||||
|
const relation = event.relation;
|
||||||
|
let threadRootId: string | null = null;
|
||||||
|
let parentEventId: string | null = null;
|
||||||
|
|
||||||
|
if (relation?.rel_type === RelationType.Thread || relation?.rel_type === 'm.thread') {
|
||||||
|
threadRootId = relation.event_id ? String(relation.event_id) : null;
|
||||||
|
}
|
||||||
|
if (relation?.['m.in_reply_to']?.event_id) {
|
||||||
|
parentEventId = String(relation['m.in_reply_to'].event_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!threadRootId) {
|
||||||
|
roots.push(current);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placedReplyIds.has(event.eventId)) continue;
|
||||||
|
|
||||||
|
if (parentEventId && parentEventId !== threadRootId) {
|
||||||
|
const parent = postsById.get(parentEventId);
|
||||||
|
if (parent) {
|
||||||
|
current.replyToEventId = parentEventId;
|
||||||
|
parent.replies.push(current);
|
||||||
|
placedReplyIds.add(event.eventId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
current.replyToEventId = parentEventId;
|
||||||
|
current.replyToDeleted = true;
|
||||||
|
const threadRoot = postsById.get(threadRootId);
|
||||||
|
if (threadRoot) {
|
||||||
|
threadRoot.replies.push(current);
|
||||||
|
placedReplyIds.add(event.eventId);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadRoot = postsById.get(threadRootId);
|
||||||
|
if (threadRoot) {
|
||||||
|
threadRoot.replies.push(current);
|
||||||
|
placedReplyIds.add(event.eventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sortRepliesRecursively(roots);
|
||||||
|
return roots.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listForumTopics(mx: MatrixClient, spaceRoomId: string): Promise<ForumTopic[]> {
|
||||||
|
const rooms: IHierarchyRoom[] = [];
|
||||||
|
let nextBatch: string | undefined;
|
||||||
|
for (let page = 0; page < 5; page += 1) {
|
||||||
|
const hierarchy = await mx.getRoomHierarchy(spaceRoomId, 100, 3, false, nextBatch);
|
||||||
|
rooms.push(...hierarchy.rooms);
|
||||||
|
nextBatch = hierarchy.next_batch;
|
||||||
|
if (!nextBatch) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentByRoomId = new Map<string, string>();
|
||||||
|
for (const room of rooms) {
|
||||||
|
if (!room?.room_id || !Array.isArray(room.children_state)) continue;
|
||||||
|
for (const childState of room.children_state) {
|
||||||
|
if (childState?.type !== 'm.space.child' || typeof childState.state_key !== 'string') continue;
|
||||||
|
parentByRoomId.set(childState.state_key, room.room_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromHierarchy = rooms
|
||||||
|
.filter((room) => room?.room_id && room.room_id !== spaceRoomId)
|
||||||
|
.map((room) => ({
|
||||||
|
roomId: room.room_id,
|
||||||
|
name: String(room.name || room.canonical_alias || room.room_id),
|
||||||
|
topic: typeof room.topic === 'string' ? room.topic : undefined,
|
||||||
|
canonicalAlias: typeof room.canonical_alias === 'string' ? room.canonical_alias : undefined,
|
||||||
|
memberCount:
|
||||||
|
typeof room.num_joined_members === 'number' ? room.num_joined_members : undefined,
|
||||||
|
isSpace: room.room_type === 'm.space' || room.room_type === 'm.forum',
|
||||||
|
parentRoomId: parentByRoomId.get(room.room_id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (fromHierarchy.length > 0) {
|
||||||
|
return fromHierarchy.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: joined space children (when hierarchy API returns nothing useful)
|
||||||
|
const topics: ForumTopic[] = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
|
||||||
|
const collectFromSpace = (parentId: string, depth: number) => {
|
||||||
|
if (depth > 3 || visited.has(parentId)) return;
|
||||||
|
visited.add(parentId);
|
||||||
|
const parentRoom = mx.getRoom(parentId);
|
||||||
|
if (!parentRoom) return;
|
||||||
|
|
||||||
|
for (const childId of getSpaceChildren(parentRoom)) {
|
||||||
|
if (childId === spaceRoomId || visited.has(childId)) continue;
|
||||||
|
const child = mx.getRoom(childId);
|
||||||
|
const name = child?.name || childId;
|
||||||
|
const childIsSpace = isSpace(child ?? null);
|
||||||
|
topics.push({
|
||||||
|
roomId: childId,
|
||||||
|
name,
|
||||||
|
isSpace: childIsSpace,
|
||||||
|
parentRoomId: parentId,
|
||||||
|
});
|
||||||
|
if (childIsSpace) collectFromSpace(childId, depth + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
collectFromSpace(spaceRoomId, 0);
|
||||||
|
return topics.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTopicFeedPosts(
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
options: { from?: string; minRoots?: number } = {}
|
||||||
|
): Promise<{ posts: ForumPost[]; nextBatch: string | null }> {
|
||||||
|
const pageSize = 40;
|
||||||
|
const minRoots = options.minRoots ?? 8;
|
||||||
|
let merged: ForumPost[] = [];
|
||||||
|
let nextBatch = options.from;
|
||||||
|
const maxPages = options.from ? 5 : 10;
|
||||||
|
|
||||||
|
for (let page = 0; page < maxPages; page += 1) {
|
||||||
|
const response = await mx.createMessagesRequest(
|
||||||
|
roomId,
|
||||||
|
nextBatch ?? null,
|
||||||
|
pageSize,
|
||||||
|
Direction.Backward
|
||||||
|
);
|
||||||
|
const chunk = [...(response.chunk || [])].reverse();
|
||||||
|
const pagePosts = buildPosts(mx, roomId, chunk);
|
||||||
|
merged = page === 0 && !options.from ? pagePosts : mergeRootPosts(merged, pagePosts);
|
||||||
|
nextBatch = response.end ?? undefined;
|
||||||
|
if (!nextBatch || merged.length >= minRoots) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { posts: merged, nextBatch: nextBatch ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRootPosts(existing: ForumPost[], incoming: ForumPost[]): ForumPost[] {
|
||||||
|
const byId = new Map(existing.map((post) => [post.eventId, post]));
|
||||||
|
for (const post of incoming) {
|
||||||
|
const previous = byId.get(post.eventId);
|
||||||
|
if (!previous) {
|
||||||
|
byId.set(post.eventId, post);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
previous.replies = [...previous.replies, ...post.replies];
|
||||||
|
}
|
||||||
|
return [...byId.values()].sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRootEvent(
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
rootEventId: string
|
||||||
|
): Promise<IRoomEvent | null> {
|
||||||
|
const rootId = rootEventId.trim();
|
||||||
|
if (!rootId) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (await mx.fetchRoomEvent(roomId, rootId)) as IRoomEvent;
|
||||||
|
} catch {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
const local = room?.findEventById(rootId);
|
||||||
|
if (local) {
|
||||||
|
return local.event as IRoomEvent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPostThread(
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
rootEventId: string
|
||||||
|
): Promise<ForumPost | null> {
|
||||||
|
const rootId = rootEventId.trim();
|
||||||
|
const rootEvent = await fetchRootEvent(mx, roomId, rootId);
|
||||||
|
if (!rootEvent?.event_id) return null;
|
||||||
|
|
||||||
|
const events: IRoomEvent[] = [rootEvent];
|
||||||
|
|
||||||
|
const collectRelations = async (
|
||||||
|
relationType: RelationType | null,
|
||||||
|
eventType: EventType | null
|
||||||
|
) => {
|
||||||
|
let from: string | undefined;
|
||||||
|
for (let page = 0; page < 20; page += 1) {
|
||||||
|
const relPayload = await mx.fetchRelations(roomId, rootId, relationType, eventType, {
|
||||||
|
from,
|
||||||
|
limit: 100,
|
||||||
|
dir: Direction.Backward,
|
||||||
|
recurse: true,
|
||||||
|
});
|
||||||
|
if (relPayload.chunk?.length) {
|
||||||
|
events.push(...(relPayload.chunk as IRoomEvent[]));
|
||||||
|
}
|
||||||
|
from = relPayload.next_batch ?? undefined;
|
||||||
|
if (!from) break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 posts = buildPosts(mx, roomId, unique);
|
||||||
|
return posts.find((post) => post.eventId === rootId) ?? posts[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAggregatedTopicFeed(
|
||||||
|
mx: MatrixClient,
|
||||||
|
scopes: Array<{ roomId: string; sectionTitle?: string; topicName?: string }>
|
||||||
|
): Promise<ForumPost[]> {
|
||||||
|
const allPosts: ForumPost[] = [];
|
||||||
|
for (const scope of scopes) {
|
||||||
|
const { posts } = await listTopicFeedPosts(mx, scope.roomId, { minRoots: 5 });
|
||||||
|
annotatePostsWithRoomScope(posts, scope.roomId, scope);
|
||||||
|
allPosts.push(...posts);
|
||||||
|
}
|
||||||
|
return allPosts.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ForumNewPostPayload = {
|
||||||
|
title: string;
|
||||||
|
plainText: string;
|
||||||
|
formattedHtml: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Top-level forum thread root (matrixsso-compatible). */
|
||||||
|
export async function sendForumPost(
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
post: ForumNewPostPayload
|
||||||
|
): Promise<string> {
|
||||||
|
const title = post.title.trim();
|
||||||
|
const plainText = post.plainText.trim();
|
||||||
|
if (!title) {
|
||||||
|
throw new Error('Post title is required.');
|
||||||
|
}
|
||||||
|
if (title.length > 140) {
|
||||||
|
throw new Error('Post title is too long.');
|
||||||
|
}
|
||||||
|
if (!plainText) {
|
||||||
|
throw new Error('Post body is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let formattedHtml = post.formattedHtml.trim().replace(/(<br\s*\/?>\s*)+$/gi, '');
|
||||||
|
if (!formattedHtml) {
|
||||||
|
formattedHtml = `<p>${escapeHtml(plainText)}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await mx.sendMessage(roomId, {
|
||||||
|
msgtype: 'm.text',
|
||||||
|
body: `${title}\n\n${plainText}`,
|
||||||
|
'com.matrixsso.title': title,
|
||||||
|
format: 'org.matrix.custom.html',
|
||||||
|
formatted_body: `<h1>${escapeHtml(title)}</h1>${formattedHtml}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.event_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ForumReplyPayload = {
|
||||||
|
plainText: string;
|
||||||
|
formattedHtml?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Thread reply (matrixsso: m.thread + m.in_reply_to on parent message). */
|
||||||
|
export function sendForumReply(
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
threadRootId: string,
|
||||||
|
replyToEventId: string,
|
||||||
|
message: string | ForumReplyPayload
|
||||||
|
): Promise<string> {
|
||||||
|
const rootId = threadRootId.trim();
|
||||||
|
const parentId = replyToEventId.trim() || rootId;
|
||||||
|
if (!rootId) {
|
||||||
|
throw new Error('Thread root event id is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainText = (typeof message === 'string' ? message : message.plainText).trim();
|
||||||
|
if (!plainText) {
|
||||||
|
throw new Error('Reply message is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let formattedHtml =
|
||||||
|
typeof message === 'string' ? '' : String(message.formattedHtml || '').trim();
|
||||||
|
formattedHtml = formattedHtml.replace(/(<br\s*\/?>\s*)+$/gi, '');
|
||||||
|
|
||||||
|
const content: Record<string, unknown> = {
|
||||||
|
msgtype: 'm.text',
|
||||||
|
body: plainText,
|
||||||
|
'm.relates_to': {
|
||||||
|
rel_type: RelationType.Thread,
|
||||||
|
event_id: rootId,
|
||||||
|
is_falling_back: false,
|
||||||
|
'm.in_reply_to': { event_id: parentId },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (formattedHtml) {
|
||||||
|
content.format = 'org.matrix.custom.html';
|
||||||
|
content.formatted_body = formattedHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mx.sendMessage(roomId, content).then((res) => res.event_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createForumPostFromPayload(
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
eventId: string,
|
||||||
|
payload: ForumNewPostPayload,
|
||||||
|
meta: { topicRoomId?: string; sectionTitle?: string; topicName?: string } = {}
|
||||||
|
): ForumPost {
|
||||||
|
const userId = mx.getUserId() || 'unknown';
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
const displayName = room
|
||||||
|
? getMemberDisplayName(room, userId) ?? userId
|
||||||
|
: userId;
|
||||||
|
const title = payload.title.trim();
|
||||||
|
const plainText = payload.plainText.trim();
|
||||||
|
let formattedHtml = payload.formattedHtml.trim().replace(/(<br\s*\/?>\s*)+$/gi, '');
|
||||||
|
if (!formattedHtml) {
|
||||||
|
formattedHtml = `<p>${escapeHtml(plainText)}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventId,
|
||||||
|
title,
|
||||||
|
body: plainText,
|
||||||
|
bodyHtml: bodyHtmlFromMessageContent(
|
||||||
|
{
|
||||||
|
format: 'org.matrix.custom.html',
|
||||||
|
formatted_body: `<h1>${escapeHtml(title)}</h1>${formattedHtml}`,
|
||||||
|
},
|
||||||
|
{ stripTitleHeading: true }
|
||||||
|
),
|
||||||
|
sender: userId,
|
||||||
|
senderDisplayName: displayName,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
topicRoomId: meta.topicRoomId ?? roomId,
|
||||||
|
sectionTitle: meta.sectionTitle,
|
||||||
|
topicName: meta.topicName,
|
||||||
|
bundledReplyCount: 0,
|
||||||
|
replies: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createForumReplyFromPayload(
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
eventId: string,
|
||||||
|
replyToEventId: string,
|
||||||
|
payload: ForumReplyPayload
|
||||||
|
): ForumPost {
|
||||||
|
const userId = mx.getUserId() || 'unknown';
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
const displayName = room
|
||||||
|
? getMemberDisplayName(room, userId) ?? userId
|
||||||
|
: userId;
|
||||||
|
const plainText = payload.plainText.trim();
|
||||||
|
let formattedHtml = String(payload.formattedHtml || '').trim().replace(/(<br\s*\/?>\s*)+$/gi, '');
|
||||||
|
|
||||||
|
const content: Record<string, unknown> = { body: plainText };
|
||||||
|
if (formattedHtml) {
|
||||||
|
content.format = 'org.matrix.custom.html';
|
||||||
|
content.formatted_body = formattedHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventId,
|
||||||
|
title: '',
|
||||||
|
body: plainText,
|
||||||
|
bodyHtml: bodyHtmlFromMessageContent(content),
|
||||||
|
sender: userId,
|
||||||
|
senderDisplayName: displayName,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
replyToEventId,
|
||||||
|
replies: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
59
src/app/features/forum/forumLucideIcons.tsx
Normal file
59
src/app/features/forum/forumLucideIcons.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { IconSrc } from 'folds';
|
||||||
|
|
||||||
|
/** Lucide paths (ISC) — same icons as matrixsso sort toggle (flame / sparkles / crown). */
|
||||||
|
|
||||||
|
type SvgNode = [tag: string, attrs: Record<string, string>];
|
||||||
|
|
||||||
|
const FLAME: SvgNode[] = [
|
||||||
|
['path', { d: 'M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
const SPARKLES: SvgNode[] = [
|
||||||
|
[
|
||||||
|
'path',
|
||||||
|
{
|
||||||
|
d: 'M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
['path', { d: 'M20 2v4' }],
|
||||||
|
['path', { d: 'M22 4h-4' }],
|
||||||
|
['circle', { cx: '4', cy: '20', r: '2' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
const CROWN: SvgNode[] = [
|
||||||
|
[
|
||||||
|
'path',
|
||||||
|
{
|
||||||
|
d: 'M11.562 3.266a.5.5 0 0 1 .876 0L15.39 8.87a1 1 0 0 0 1.516.294L21.183 5.5a.5.5 0 0 1 .798.519l-2.834 10.246a1 1 0 0 1-.956.734H5.81a1 1 0 0 1-.957-.734L2.02 6.02a.5.5 0 0 1 .798-.519l4.276 3.664a1 1 0 0 0 1.516-.294z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
['path', { d: 'M5 21h14' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
function lucideIcon(nodes: SvgNode): IconSrc {
|
||||||
|
return () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
{nodes.map(([tag, attrs]) =>
|
||||||
|
React.createElement(tag, { key: `${tag}-${attrs.d ?? attrs.cx ?? ''}`, ...attrs })
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** hot → flame, new → sparkles, top → crown (matrixsso SORT_LUCIDE_ICONS) */
|
||||||
|
export const ForumSortIcons = {
|
||||||
|
hot: lucideIcon(FLAME),
|
||||||
|
new: lucideIcon(SPARKLES),
|
||||||
|
top: lucideIcon(CROWN),
|
||||||
|
} as const;
|
||||||
28
src/app/features/forum/forumRichText.ts
Normal file
28
src/app/features/forum/forumRichText.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { OutputOptions } from '../../components/editor/output';
|
||||||
|
import { sanitizeCustomHtml } from '../../utils/sanitize';
|
||||||
|
|
||||||
|
export const FORUM_EDITOR_OUTPUT_OPTS: OutputOptions = {
|
||||||
|
allowTextFormatting: true,
|
||||||
|
allowInlineMarkdown: true,
|
||||||
|
allowBlockMarkdown: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function stripLeadingTitleHeading(html: string): string {
|
||||||
|
return html.replace(/^<h1[^>]*>[\s\S]*?<\/h1>\s*/i, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bodyHtmlFromMessageContent(
|
||||||
|
content: Record<string, unknown>,
|
||||||
|
options: { stripTitleHeading?: boolean } = {}
|
||||||
|
): string | undefined {
|
||||||
|
if (content.format !== 'org.matrix.custom.html') return undefined;
|
||||||
|
const raw = content.formatted_body;
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) return undefined;
|
||||||
|
|
||||||
|
let html = raw.trim();
|
||||||
|
if (options.stripTitleHeading) {
|
||||||
|
html = stripLeadingTitleHeading(html);
|
||||||
|
}
|
||||||
|
const sanitized = sanitizeCustomHtml(html);
|
||||||
|
return sanitized || undefined;
|
||||||
|
}
|
||||||
1121
src/app/features/forum/forumTheme.css.ts
Normal file
1121
src/app/features/forum/forumTheme.css.ts
Normal file
File diff suppressed because it is too large
Load Diff
40
src/app/features/forum/forumTime.ts
Normal file
40
src/app/features/forum/forumTime.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/** matrixsso-style relative time (e.g. "4m", "3h", "now"). */
|
||||||
|
export function formatForumTimeAgo(timestamp: number): string {
|
||||||
|
const ms = timestamp;
|
||||||
|
if (!Number.isFinite(ms) || ms <= 0) {
|
||||||
|
return 'now';
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = Math.floor((Date.now() - ms) / 1000);
|
||||||
|
if (seconds < 45) {
|
||||||
|
return 'now';
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) {
|
||||||
|
return `${hours}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 7) {
|
||||||
|
return `${days}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeks = Math.floor(days / 7);
|
||||||
|
if (weeks < 5) {
|
||||||
|
return `${weeks}w`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
if (months < 12) {
|
||||||
|
return `${months}mo`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const years = Math.floor(days / 365);
|
||||||
|
return `${years}y`;
|
||||||
|
}
|
||||||
125
src/app/features/forum/forumTopicHelpers.ts
Normal file
125
src/app/features/forum/forumTopicHelpers.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type { ForumPost, ForumSection, ForumThreadSummary, ForumTopic } from './types';
|
||||||
|
import {
|
||||||
|
applyTopicStatusSort,
|
||||||
|
filterPostsBySearch,
|
||||||
|
parseTopicSearchQuery,
|
||||||
|
} from './topicSearch';
|
||||||
|
|
||||||
|
export function countAllReplies(replies: ForumPost[] | undefined): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const reply of replies || []) {
|
||||||
|
total += 1;
|
||||||
|
total += countAllReplies(reply.replies);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maxThreadTimestamp(post: ForumPost): number {
|
||||||
|
let maxValue = post.timestamp;
|
||||||
|
const stack = [...(post.replies || [])];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop();
|
||||||
|
if (!current) continue;
|
||||||
|
if (current.timestamp > maxValue) maxValue = current.timestamp;
|
||||||
|
if (current.replies?.length) stack.push(...current.replies);
|
||||||
|
}
|
||||||
|
return maxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildThreadSummaries(posts: ForumPost[]): ForumThreadSummary[] {
|
||||||
|
return posts.map((post) => ({
|
||||||
|
eventId: post.eventId,
|
||||||
|
title: post.title || post.body.slice(0, 160) || '(no title)',
|
||||||
|
body: post.body,
|
||||||
|
sender: post.sender,
|
||||||
|
senderDisplayName: post.senderDisplayName,
|
||||||
|
timestamp: post.timestamp,
|
||||||
|
topicRoomId: post.topicRoomId,
|
||||||
|
sectionTitle: post.sectionTitle,
|
||||||
|
topicName: post.topicName,
|
||||||
|
totalReplies: post.bundledReplyCount ?? countAllReplies(post.replies),
|
||||||
|
lastActivityTs: maxThreadTimestamp(post),
|
||||||
|
isPinned: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTopicThreads(
|
||||||
|
posts: ForumPost[],
|
||||||
|
query: { q?: string; status?: string; sort?: string } = {}
|
||||||
|
): ForumThreadSummary[] {
|
||||||
|
const parsed = parseTopicSearchQuery(query.q || '');
|
||||||
|
const filtered = filterPostsBySearch(posts, parsed);
|
||||||
|
const summaries = buildThreadSummaries(filtered);
|
||||||
|
return applyTopicStatusSort(summaries, query.status || 'all', query.sort || 'hot');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildForumSections(topics: ForumTopic[]): ForumSection[] {
|
||||||
|
const spaces = topics.filter((topic) => topic.isSpace);
|
||||||
|
const postableTopics = topics.filter((topic) => !topic.isSpace);
|
||||||
|
const sections: ForumSection[] = [];
|
||||||
|
|
||||||
|
for (const space of spaces) {
|
||||||
|
const childTopics = postableTopics.filter((topic) => topic.parentRoomId === space.roomId);
|
||||||
|
|
||||||
|
sections.push({
|
||||||
|
spaceId: space.roomId,
|
||||||
|
title: space.name,
|
||||||
|
description: space.topic || null,
|
||||||
|
topics: childTopics.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const coveredTopicIds = new Set(
|
||||||
|
sections.flatMap((section) => section.topics.map((topic) => topic.roomId))
|
||||||
|
);
|
||||||
|
const uncategorizedTopics = postableTopics.filter((topic) => !coveredTopicIds.has(topic.roomId));
|
||||||
|
if (uncategorizedTopics.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
title: 'General Topics',
|
||||||
|
description: null,
|
||||||
|
topics: uncategorizedTopics.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a reply under the parent event (immutable). */
|
||||||
|
export function appendReplyToThread(
|
||||||
|
root: ForumPost,
|
||||||
|
parentEventId: string,
|
||||||
|
reply: ForumPost
|
||||||
|
): ForumPost {
|
||||||
|
const walk = (node: ForumPost): ForumPost => {
|
||||||
|
if (node.eventId === parentEventId) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
replies: [...node.replies, reply].sort((a, b) => a.timestamp - b.timestamp),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
const nextReplies = node.replies.map((child) => {
|
||||||
|
const updated = walk(child);
|
||||||
|
if (updated !== child) changed = true;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
if (!changed) return node;
|
||||||
|
return { ...node, replies: nextReplies };
|
||||||
|
};
|
||||||
|
return walk(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function annotatePostsWithRoomScope(
|
||||||
|
posts: ForumPost[],
|
||||||
|
roomId: string,
|
||||||
|
scope: { sectionTitle?: string; topicName?: string } | null = null
|
||||||
|
): void {
|
||||||
|
const sectionTitle = scope?.sectionTitle ? String(scope.sectionTitle) : '';
|
||||||
|
const topicName = scope?.topicName ? String(scope.topicName) : '';
|
||||||
|
|
||||||
|
for (const post of posts) {
|
||||||
|
post.topicRoomId = roomId;
|
||||||
|
if (sectionTitle) post.sectionTitle = sectionTitle;
|
||||||
|
if (topicName) post.topicName = topicName;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/app/features/forum/index.ts
Normal file
7
src/app/features/forum/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { ForumBoardView } from './ForumBoardView';
|
||||||
|
export { ForumSortIcons } from './forumLucideIcons';
|
||||||
|
export { ForumSpaceShell } from './ForumSpaceShell';
|
||||||
|
export { ForumThreadDetail } from './ForumThreadDetail';
|
||||||
|
export { ForumAwareSpaceLayout } from './ForumAwareSpaceLayout';
|
||||||
|
export { ForumSpaceLayout } from './ForumSpaceLayout';
|
||||||
|
export type { ForumBoardScope } from './useForumBoard';
|
||||||
430
src/app/features/forum/topicSearch.ts
Normal file
430
src/app/features/forum/topicSearch.ts
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
import type { ForumPost } from './types';
|
||||||
|
|
||||||
|
export type ParsedTopicSearch = {
|
||||||
|
isActive: boolean;
|
||||||
|
freeText: string;
|
||||||
|
terms: string[];
|
||||||
|
qualifiers: {
|
||||||
|
intitle: string[];
|
||||||
|
inpost: string[];
|
||||||
|
inthread: string[];
|
||||||
|
from: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopicSearchQualifier = {
|
||||||
|
key: keyof ParsedTopicSearch['qualifiers'];
|
||||||
|
token: string;
|
||||||
|
label: string;
|
||||||
|
aliases: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUALIFIER_RE = /\b(intitle|inpost|inbody|inthread|inreply|from):\s*("([^"]+)"|(\S+))/gi;
|
||||||
|
|
||||||
|
export const TOPIC_SEARCH_QUALIFIERS: TopicSearchQualifier[] = [
|
||||||
|
{ key: 'intitle', token: 'intitle:', label: 'Title', aliases: [] },
|
||||||
|
{ key: 'inpost', token: 'inpost:', label: 'Post body', aliases: ['inbody'] },
|
||||||
|
{ key: 'inthread', token: 'inthread:', label: 'In thread', aliases: ['inreply'] },
|
||||||
|
{ key: 'from', token: 'from:', label: 'Author', aliases: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function readQualifierPartialAt(value: string, cursor: number): string {
|
||||||
|
const before = String(value || '').slice(0, Math.max(0, cursor));
|
||||||
|
const match = before.match(/(?:^|\s)([^\s:]*)$/);
|
||||||
|
return match ? String(match[1] || '').toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchTopicSearchQualifiers(partial: string): TopicSearchQualifier[] {
|
||||||
|
const prefix = String(partial || '').toLowerCase();
|
||||||
|
if (!prefix) {
|
||||||
|
return TOPIC_SEARCH_QUALIFIERS.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
return TOPIC_SEARCH_QUALIFIERS.filter((qualifier) => {
|
||||||
|
if (qualifier.key.startsWith(prefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return qualifier.aliases.some((alias) => alias.startsWith(prefix));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TopicSearchAuthorHint = {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
localpart: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QualifierValueContext = {
|
||||||
|
key: keyof ParsedTopicSearch['qualifiers'];
|
||||||
|
partial: string;
|
||||||
|
valueStart: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VALUE_QUALIFIER_KEYS = new Set<keyof ParsedTopicSearch['qualifiers']>([
|
||||||
|
'intitle',
|
||||||
|
'inpost',
|
||||||
|
'inthread',
|
||||||
|
'from',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizeQualifierKey(raw: string): keyof ParsedTopicSearch['qualifiers'] | null {
|
||||||
|
const key = raw.toLowerCase();
|
||||||
|
if (key === 'inbody') return 'inpost';
|
||||||
|
if (key === 'inreply') return 'inthread';
|
||||||
|
if (key === 'intitle' || key === 'inpost' || key === 'inthread' || key === 'from') {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSearchValue(value: string): string {
|
||||||
|
const trimmed = String(value || '').trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
if (/[\s:"]/.test(trimmed)) {
|
||||||
|
return `"${trimmed.replace(/"/g, '')}"`;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Partial value typed after a qualifier token (e.g. `from:ali` or `intitle:"rel`). */
|
||||||
|
export function readActiveQualifierValueAt(value: string, cursor: number): QualifierValueContext | null {
|
||||||
|
const before = String(value || '').slice(0, Math.max(0, cursor));
|
||||||
|
const match = before.match(
|
||||||
|
/(?:^|\s)(intitle|inpost|inbody|inthread|inreply|from):\s*(?:"([^"]*)"?|(\S*))$/i
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalizeQualifierKey(String(match[1] || ''));
|
||||||
|
if (!key || !VALUE_QUALIFIER_KEYS.has(key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partial = match[2] !== undefined ? String(match[2] || '') : String(match[3] || '');
|
||||||
|
const matchStart = before.length - match[0].length;
|
||||||
|
const qualifierLen = String(match[1] || '').length;
|
||||||
|
const afterColon = before.slice(matchStart + qualifierLen + 1);
|
||||||
|
const leadingSpace = afterColon.match(/^\s*/)?.[0]?.length ?? 0;
|
||||||
|
let valueStart = matchStart + qualifierLen + 1 + leadingSpace;
|
||||||
|
if (afterColon.trimStart().startsWith('"')) {
|
||||||
|
valueStart += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { key, partial, valueStart };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertTopicSearchValueAt(
|
||||||
|
value: string,
|
||||||
|
cursor: number,
|
||||||
|
replacement: string
|
||||||
|
): { value: string; cursor: number } {
|
||||||
|
const ctx = readActiveQualifierValueAt(value, cursor);
|
||||||
|
if (!ctx) {
|
||||||
|
return { value, cursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = formatSearchValue(replacement);
|
||||||
|
const prefix = value.slice(0, ctx.valueStart);
|
||||||
|
const suffix = value.slice(cursor);
|
||||||
|
const spacer = suffix.length && suffix[0] === ' ' ? '' : ' ';
|
||||||
|
const nextValue = `${prefix}${formatted}${spacer}${suffix}`;
|
||||||
|
const nextCursor = ctx.valueStart + formatted.length + spacer.length;
|
||||||
|
|
||||||
|
return { value: nextValue, cursor: nextCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectAuthorHintsFromThreads(
|
||||||
|
threads: Array<{ sender: string; senderDisplayName?: string }>
|
||||||
|
): TopicSearchAuthorHint[] {
|
||||||
|
const map = new Map<string, TopicSearchAuthorHint>();
|
||||||
|
|
||||||
|
for (const thread of threads) {
|
||||||
|
const userId = thread.sender;
|
||||||
|
if (!userId || map.has(userId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const localpart = userId.startsWith('@') ? userId.slice(1).split(':')[0] : userId;
|
||||||
|
const displayName = (thread.senderDisplayName || localpart).trim() || localpart;
|
||||||
|
map.set(userId, { userId, displayName, localpart });
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...map.values()].sort(
|
||||||
|
(a, b) => a.displayName.localeCompare(b.displayName) || a.localpart.localeCompare(b.localpart)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectTitleHintsFromThreads(threads: Array<{ title: string }>): string[] {
|
||||||
|
const titles = new Set<string>();
|
||||||
|
for (const thread of threads) {
|
||||||
|
const title = String(thread.title || '').trim();
|
||||||
|
if (title) {
|
||||||
|
titles.add(title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...titles].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchAuthorHints(
|
||||||
|
partial: string,
|
||||||
|
hints: TopicSearchAuthorHint[]
|
||||||
|
): TopicSearchAuthorHint[] {
|
||||||
|
const prefix = String(partial || '').trim().toLowerCase();
|
||||||
|
return hints
|
||||||
|
.filter((hint) => {
|
||||||
|
if (!prefix) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
hint.localpart.toLowerCase().includes(prefix) ||
|
||||||
|
hint.displayName.toLowerCase().includes(prefix) ||
|
||||||
|
hint.userId.toLowerCase().includes(prefix)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchTitleHints(partial: string, titles: string[]): string[] {
|
||||||
|
const prefix = String(partial || '').trim().toLowerCase();
|
||||||
|
return titles
|
||||||
|
.filter((title) => !prefix || title.toLowerCase().includes(prefix))
|
||||||
|
.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertTopicSearchQualifierAt(
|
||||||
|
value: string,
|
||||||
|
cursor: number,
|
||||||
|
qualifier: TopicSearchQualifier
|
||||||
|
): { value: string; cursor: number } {
|
||||||
|
const input = String(value || '');
|
||||||
|
const caret = Math.max(0, Math.min(cursor, input.length));
|
||||||
|
const partial = readQualifierPartialAt(input, caret);
|
||||||
|
const tokenStart = caret - partial.length;
|
||||||
|
const prefix = input.slice(0, tokenStart);
|
||||||
|
const suffix = input.slice(caret);
|
||||||
|
const spacer = suffix.length && suffix[0] === ' ' ? '' : ' ';
|
||||||
|
const nextValue = `${prefix}${qualifier.token}${spacer}${suffix}`;
|
||||||
|
const nextCursor = tokenStart + qualifier.token.length + spacer.length;
|
||||||
|
|
||||||
|
return { value: nextValue, cursor: nextCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(value: string): string {
|
||||||
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTerms(qualifiers: ParsedTopicSearch['qualifiers'], freeText: string): string[] {
|
||||||
|
const terms = new Set<string>();
|
||||||
|
|
||||||
|
for (const list of Object.values(qualifiers)) {
|
||||||
|
for (const value of list) {
|
||||||
|
const trimmed = String(value || '').trim();
|
||||||
|
if (trimmed) {
|
||||||
|
terms.add(trimmed.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freeText) {
|
||||||
|
const quoted = [...freeText.matchAll(/"([^"]+)"/g)].map((match) => match[1]);
|
||||||
|
let scratch = freeText;
|
||||||
|
for (const phrase of quoted) {
|
||||||
|
if (phrase.trim()) {
|
||||||
|
terms.add(phrase.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
scratch = scratch.replace(`"${phrase}"`, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const word of scratch.split(/\s+/)) {
|
||||||
|
const trimmed = word.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
terms.add(trimmed.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...terms];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTopicSearchQuery(raw: string): ParsedTopicSearch {
|
||||||
|
const input = String(raw || '').trim();
|
||||||
|
const qualifiers: ParsedTopicSearch['qualifiers'] = {
|
||||||
|
intitle: [],
|
||||||
|
inpost: [],
|
||||||
|
inthread: [],
|
||||||
|
from: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!input) {
|
||||||
|
return { isActive: false, qualifiers, freeText: '', terms: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
let remainder = input;
|
||||||
|
for (const match of input.matchAll(QUALIFIER_RE)) {
|
||||||
|
const key = String(match[1] || '').toLowerCase();
|
||||||
|
const value = String(match[3] || match[4] || '').trim();
|
||||||
|
if (value) {
|
||||||
|
if (key === 'inbody') {
|
||||||
|
qualifiers.inpost.push(value);
|
||||||
|
} else if (key === 'inreply') {
|
||||||
|
qualifiers.inthread.push(value);
|
||||||
|
} else if (key === 'intitle') {
|
||||||
|
qualifiers.intitle.push(value);
|
||||||
|
} else if (key === 'inpost') {
|
||||||
|
qualifiers.inpost.push(value);
|
||||||
|
} else if (key === 'inthread') {
|
||||||
|
qualifiers.inthread.push(value);
|
||||||
|
} else if (key === 'from') {
|
||||||
|
qualifiers.from.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remainder = remainder.replace(match[0], ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const freeText = remainder.replace(/\s+/g, ' ').trim();
|
||||||
|
const terms = collectTerms(qualifiers, freeText);
|
||||||
|
const isActive = Boolean(
|
||||||
|
freeText ||
|
||||||
|
qualifiers.intitle.length ||
|
||||||
|
qualifiers.inpost.length ||
|
||||||
|
qualifiers.inthread.length ||
|
||||||
|
qualifiers.from.length
|
||||||
|
);
|
||||||
|
|
||||||
|
return { isActive, qualifiers, freeText, terms };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeText(node: ForumPost): string {
|
||||||
|
return [node.title || '', node.body || '', node.sender || '', node.senderDisplayName || '']
|
||||||
|
.join('\n')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkReplyTree(node: ForumPost, visit: (node: ForumPost) => void): void {
|
||||||
|
for (const reply of node.replies || []) {
|
||||||
|
visit(reply);
|
||||||
|
walkReplyTree(reply, visit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function termsMatchAll(terms: string[], haystack: string): boolean {
|
||||||
|
const text = String(haystack || '').toLowerCase();
|
||||||
|
return terms.every((term) => text.includes(String(term || '').toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function termsMatchAny(terms: string[], haystack: string): boolean {
|
||||||
|
const text = String(haystack || '').toLowerCase();
|
||||||
|
return terms.some((term) => text.includes(String(term || '').toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function threadMatchesSearch(post: ForumPost, parsed: ParsedTopicSearch): boolean {
|
||||||
|
if (!parsed?.isActive || !post) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { qualifiers, freeText, terms } = parsed;
|
||||||
|
const titleHay = String(post.title || '').toLowerCase();
|
||||||
|
const bodyHay = String(post.body || '').toLowerCase();
|
||||||
|
const senderHay = `${post.sender || ''}\n${post.senderDisplayName || ''}`.toLowerCase();
|
||||||
|
const fullHay = `${titleHay}\n${bodyHay}\n${senderHay}`;
|
||||||
|
|
||||||
|
if (qualifiers.intitle.length && !termsMatchAll(qualifiers.intitle, titleHay)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qualifiers.inpost.length && !termsMatchAll(qualifiers.inpost, bodyHay)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qualifiers.from.length && !termsMatchAll(qualifiers.from, senderHay)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qualifiers.inthread.length) {
|
||||||
|
let replyHit = false;
|
||||||
|
walkReplyTree(post, (reply) => {
|
||||||
|
if (termsMatchAll(qualifiers.inthread, nodeText(reply))) {
|
||||||
|
replyHit = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!replyHit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freeText) {
|
||||||
|
let anywhere = termsMatchAny(terms, fullHay);
|
||||||
|
if (!anywhere) {
|
||||||
|
walkReplyTree(post, (reply) => {
|
||||||
|
if (termsMatchAny(terms, nodeText(reply))) {
|
||||||
|
anywhere = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!anywhere) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterPostsBySearch(posts: ForumPost[], parsed: ParsedTopicSearch): ForumPost[] {
|
||||||
|
if (!parsed?.isActive) {
|
||||||
|
return posts || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (posts || []).filter((post) => threadMatchesSearch(post, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTopicStatusSort<T extends { lastActivityTs: number; timestamp: number; totalReplies: number; eventId: string }>(
|
||||||
|
threads: T[],
|
||||||
|
statusFilter: string,
|
||||||
|
sortBy: string
|
||||||
|
): T[] {
|
||||||
|
let filtered = threads || [];
|
||||||
|
|
||||||
|
if (statusFilter === 'open') {
|
||||||
|
filtered = filtered.filter((thread) => thread.lastActivityTs >= Date.now() - 30 * 86400000);
|
||||||
|
} else if (statusFilter === 'quiet') {
|
||||||
|
filtered = filtered.filter((thread) => thread.lastActivityTs < Date.now() - 30 * 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...filtered];
|
||||||
|
if (sortBy === 'top') {
|
||||||
|
sorted.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.totalReplies - a.totalReplies ||
|
||||||
|
b.lastActivityTs - a.lastActivityTs ||
|
||||||
|
b.eventId.localeCompare(a.eventId)
|
||||||
|
);
|
||||||
|
} else if (sortBy === 'new') {
|
||||||
|
sorted.sort((a, b) => b.timestamp - a.timestamp || b.eventId.localeCompare(a.eventId));
|
||||||
|
} else {
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
const hotA = a.totalReplies * 4 + Math.floor((a.lastActivityTs - a.timestamp) / 3600000);
|
||||||
|
const hotB = b.totalReplies * 4 + Math.floor((b.lastActivityTs - b.timestamp) / 3600000);
|
||||||
|
return hotB - hotA || b.lastActivityTs - a.lastActivityTs || b.eventId.localeCompare(a.eventId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightSearchTerms(text: string, terms: string[]): string {
|
||||||
|
if (!terms.length) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usable = [...new Set(terms.map((term) => String(term || '').trim()).filter(Boolean))].sort(
|
||||||
|
(a, b) => b.length - a.length
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = text;
|
||||||
|
for (const term of usable) {
|
||||||
|
const re = new RegExp(`(${escapeRegex(term)})`, 'gi');
|
||||||
|
result = result.replace(re, '<mark class="search-hit">$1</mark>');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
68
src/app/features/forum/types.ts
Normal file
68
src/app/features/forum/types.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
export type ForumTopic = {
|
||||||
|
roomId: string;
|
||||||
|
name: string;
|
||||||
|
topic?: string;
|
||||||
|
canonicalAlias?: string;
|
||||||
|
memberCount?: number;
|
||||||
|
isSpace: boolean;
|
||||||
|
parentRoomId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ForumSection = {
|
||||||
|
/** Child space room id when this section is a subspace; omitted for “General Topics”. */
|
||||||
|
spaceId?: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
topics: ForumTopic[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ForumPost = {
|
||||||
|
eventId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
bodyHtml?: string;
|
||||||
|
sender: string;
|
||||||
|
senderDisplayName?: string;
|
||||||
|
timestamp: number;
|
||||||
|
topicRoomId?: string;
|
||||||
|
sectionTitle?: string;
|
||||||
|
topicName?: string;
|
||||||
|
bundledReplyCount?: number;
|
||||||
|
replies: ForumPost[];
|
||||||
|
replyToEventId?: string;
|
||||||
|
replyToDeleted?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ForumThreadSummary = {
|
||||||
|
eventId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
sender: string;
|
||||||
|
senderDisplayName?: string;
|
||||||
|
timestamp: number;
|
||||||
|
topicRoomId?: string;
|
||||||
|
sectionTitle?: string;
|
||||||
|
topicName?: string;
|
||||||
|
totalReplies: number;
|
||||||
|
lastActivityTs: number;
|
||||||
|
isPinned?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ForumPublishedPost = {
|
||||||
|
eventId: string;
|
||||||
|
topicRoomId: string;
|
||||||
|
title: string;
|
||||||
|
plainText: string;
|
||||||
|
formattedHtml: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ForumBoardQuery = {
|
||||||
|
category?: string;
|
||||||
|
topic?: string;
|
||||||
|
post?: string;
|
||||||
|
/** Room for the selected post (thread detail only; does not change filter dropdowns). */
|
||||||
|
postRoom?: string;
|
||||||
|
q?: string;
|
||||||
|
status?: 'all' | 'open' | 'quiet';
|
||||||
|
sort?: 'hot' | 'new' | 'top';
|
||||||
|
};
|
||||||
375
src/app/features/forum/useForumBoard.ts
Normal file
375
src/app/features/forum/useForumBoard.ts
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { createRoomModalAtom } from '../../state/createRoomModal';
|
||||||
|
import { createSpaceModalAtom } from '../../state/createSpaceModal';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
import {
|
||||||
|
buildForumSections,
|
||||||
|
buildTopicThreads,
|
||||||
|
buildThreadSummaries,
|
||||||
|
} from './forumTopicHelpers';
|
||||||
|
import { applyTopicStatusSort } from './topicSearch';
|
||||||
|
import {
|
||||||
|
createForumPostFromPayload,
|
||||||
|
listForumTopics,
|
||||||
|
listTopicFeedPosts,
|
||||||
|
loadAggregatedTopicFeed,
|
||||||
|
} from './forumFeed';
|
||||||
|
import type { ForumBoardQuery, ForumPublishedPost, ForumSection, ForumThreadSummary } from './types';
|
||||||
|
|
||||||
|
export type ForumBoardScope = {
|
||||||
|
forumSpaceId: string;
|
||||||
|
/** When set, only show posts from this topic room. */
|
||||||
|
topicRoomId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function scopesFromSelection(
|
||||||
|
sections: ForumSection[],
|
||||||
|
category?: string,
|
||||||
|
topic?: string
|
||||||
|
): Array<{ roomId: string; sectionTitle?: string; topicName?: string }> {
|
||||||
|
if (topic) {
|
||||||
|
for (const section of sections) {
|
||||||
|
const match = section.topics.find((t) => t.roomId === topic);
|
||||||
|
if (match) {
|
||||||
|
return [{ roomId: match.roomId, sectionTitle: section.title, topicName: match.name }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [{ roomId: topic }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
const section = sections.find((s) => s.title === category);
|
||||||
|
if (!section) return [];
|
||||||
|
return section.topics.map((t) => ({
|
||||||
|
roomId: t.roomId,
|
||||||
|
sectionTitle: section.title,
|
||||||
|
topicName: t.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections.flatMap((section) =>
|
||||||
|
section.topics.map((t) => ({
|
||||||
|
roomId: t.roomId,
|
||||||
|
sectionTitle: section.title,
|
||||||
|
topicName: t.name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const filterQuery: ForumBoardQuery = useMemo(
|
||||||
|
() => ({
|
||||||
|
category: searchParams.get('category') || undefined,
|
||||||
|
topic: scope.topicRoomId || 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]
|
||||||
|
);
|
||||||
|
|
||||||
|
const query: ForumBoardQuery = useMemo(
|
||||||
|
() => ({
|
||||||
|
...filterQuery,
|
||||||
|
post: searchParams.get('post') || undefined,
|
||||||
|
postRoom: searchParams.get('postRoom') || undefined,
|
||||||
|
}),
|
||||||
|
[filterQuery, searchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [sections, setSections] = useState<ForumSection[]>([]);
|
||||||
|
const [threads, setThreads] = useState<ForumThreadSummary[]>([]);
|
||||||
|
const [loadingSections, setLoadingSections] = useState(true);
|
||||||
|
const [loadingThreads, setLoadingThreads] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
{ replace: true }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadSections = useCallback(async () => {
|
||||||
|
setLoadingSections(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const topics = await listForumTopics(mx, scope.forumSpaceId);
|
||||||
|
setSections(buildForumSections(topics));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load forum categories');
|
||||||
|
} finally {
|
||||||
|
setLoadingSections(false);
|
||||||
|
}
|
||||||
|
}, [mx, scope.forumSpaceId]);
|
||||||
|
|
||||||
|
const refreshThreads = useCallback(async () => {
|
||||||
|
setLoadingThreads(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
|
||||||
|
if (roomScopes.length === 0) {
|
||||||
|
setThreads([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let posts;
|
||||||
|
if (roomScopes.length === 1) {
|
||||||
|
const result = await listTopicFeedPosts(mx, roomScopes[0].roomId);
|
||||||
|
posts = result.posts;
|
||||||
|
setPostsNextBatch(result.nextBatch);
|
||||||
|
const scopeMeta = roomScopes[0];
|
||||||
|
posts.forEach((post) => {
|
||||||
|
post.topicRoomId = scopeMeta.roomId;
|
||||||
|
if (scopeMeta.sectionTitle) post.sectionTitle = scopeMeta.sectionTitle;
|
||||||
|
if (scopeMeta.topicName) post.topicName = scopeMeta.topicName;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
posts = await loadAggregatedTopicFeed(mx, roomScopes);
|
||||||
|
setPostsNextBatch(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setThreads(buildTopicThreads(posts, filterQuery));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load forum posts');
|
||||||
|
} finally {
|
||||||
|
setLoadingThreads(false);
|
||||||
|
}
|
||||||
|
}, [mx, sections, filterQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSections();
|
||||||
|
}, [loadSections]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSpaceChild = (event: MatrixEvent) => {
|
||||||
|
if (event.getType() !== StateEvent.SpaceChild) return;
|
||||||
|
const roomId = event.getRoomId();
|
||||||
|
if (!roomId) return;
|
||||||
|
if (
|
||||||
|
roomId === forumSpace.roomId ||
|
||||||
|
sections.some((section) => section.spaceId === roomId)
|
||||||
|
) {
|
||||||
|
loadSections();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mx.on(RoomStateEvent.Events, handleSpaceChild);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(RoomStateEvent.Events, handleSpaceChild);
|
||||||
|
};
|
||||||
|
}, [mx, forumSpace.roomId, sections, loadSections]);
|
||||||
|
|
||||||
|
const createSpaceModal = useAtomValue(createSpaceModalAtom);
|
||||||
|
const createRoomModal = useAtomValue(createRoomModalAtom);
|
||||||
|
const prevCreateSpaceModal = useRef(createSpaceModal);
|
||||||
|
const prevCreateRoomModal = useRef(createRoomModal);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevCreateSpaceModal.current && !createSpaceModal) {
|
||||||
|
loadSections();
|
||||||
|
}
|
||||||
|
if (prevCreateRoomModal.current && !createRoomModal) {
|
||||||
|
loadSections();
|
||||||
|
}
|
||||||
|
prevCreateSpaceModal.current = createSpaceModal;
|
||||||
|
prevCreateRoomModal.current = createRoomModal;
|
||||||
|
}, [createSpaceModal, createRoomModal, loadSections]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loadingSections) {
|
||||||
|
refreshThreads();
|
||||||
|
}
|
||||||
|
}, [loadingSections, refreshThreads]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
refreshThreads();
|
||||||
|
}, 30_000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [refreshThreads]);
|
||||||
|
|
||||||
|
const selectedPostId = query.post ?? null;
|
||||||
|
const selectedThread = threads.find((t) => t.eventId === selectedPostId);
|
||||||
|
const activeTopicRoomId =
|
||||||
|
query.postRoom || selectedThread?.topicRoomId || query.topic || scope.topicRoomId || null;
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertThreadFromNewPost = useCallback(
|
||||||
|
(published: ForumPublishedPost): ForumPost | null => {
|
||||||
|
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
|
||||||
|
const inScope =
|
||||||
|
filterQuery.topic === published.topicRoomId ||
|
||||||
|
(!filterQuery.topic &&
|
||||||
|
(filterQuery.category
|
||||||
|
? roomScopes.some((s) => s.roomId === published.topicRoomId)
|
||||||
|
: true));
|
||||||
|
|
||||||
|
const scopeMeta = roomScopes.find((s) => s.roomId === published.topicRoomId);
|
||||||
|
const post = createForumPostFromPayload(
|
||||||
|
mx,
|
||||||
|
published.topicRoomId,
|
||||||
|
published.eventId,
|
||||||
|
{
|
||||||
|
title: published.title,
|
||||||
|
plainText: published.plainText,
|
||||||
|
formattedHtml: published.formattedHtml,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topicRoomId: published.topicRoomId,
|
||||||
|
sectionTitle: scopeMeta?.sectionTitle,
|
||||||
|
topicName: scopeMeta?.topicName,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (inScope) {
|
||||||
|
const [summary] = buildThreadSummaries([post]);
|
||||||
|
if (summary) {
|
||||||
|
setThreads((prev) => {
|
||||||
|
const without = prev.filter((t) => t.eventId !== summary.eventId);
|
||||||
|
const merged = [summary, ...without];
|
||||||
|
return applyTopicStatusSort(merged, filterQuery.status || 'all', filterQuery.sort || 'hot');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return post;
|
||||||
|
},
|
||||||
|
[mx, sections, filterQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordThreadReply = useCallback((rootEventId: string) => {
|
||||||
|
setThreads((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.eventId === rootEventId
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
totalReplies: t.totalReplies + 1,
|
||||||
|
lastActivityTs: Date.now(),
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMorePosts = useCallback(async () => {
|
||||||
|
if (!postsNextBatch || loadingMore) return;
|
||||||
|
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
|
||||||
|
if (roomScopes.length !== 1) return;
|
||||||
|
|
||||||
|
setLoadingMore(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const scopeMeta = roomScopes[0];
|
||||||
|
const result = await listTopicFeedPosts(mx, scopeMeta.roomId, {
|
||||||
|
from: postsNextBatch,
|
||||||
|
minRoots: 1,
|
||||||
|
});
|
||||||
|
setPostsNextBatch(result.nextBatch);
|
||||||
|
result.posts.forEach((post) => {
|
||||||
|
post.topicRoomId = scopeMeta.roomId;
|
||||||
|
if (scopeMeta.sectionTitle) post.sectionTitle = scopeMeta.sectionTitle;
|
||||||
|
if (scopeMeta.topicName) post.topicName = scopeMeta.topicName;
|
||||||
|
});
|
||||||
|
setThreads((prev) => {
|
||||||
|
const existingIds = new Set(prev.map((t) => t.eventId));
|
||||||
|
const added = buildTopicThreads(result.posts, filterQuery).filter(
|
||||||
|
(t) => !existingIds.has(t.eventId)
|
||||||
|
);
|
||||||
|
const merged = [...prev, ...added];
|
||||||
|
const sort = filterQuery.sort || 'hot';
|
||||||
|
merged.sort((a, b) => {
|
||||||
|
if (sort === 'new') return b.timestamp - a.timestamp;
|
||||||
|
if (sort === 'top') return b.totalReplies - a.totalReplies;
|
||||||
|
const hotA = a.totalReplies * 4 + Math.floor((a.lastActivityTs - a.timestamp) / 3600000);
|
||||||
|
const hotB = b.totalReplies * 4 + Math.floor((b.lastActivityTs - b.timestamp) / 3600000);
|
||||||
|
return hotB - hotA;
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load older posts');
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [postsNextBatch, loadingMore, sections, filterQuery, mx]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
forumSpace,
|
||||||
|
sections,
|
||||||
|
threads,
|
||||||
|
query,
|
||||||
|
loadingSections,
|
||||||
|
loadingThreads,
|
||||||
|
error,
|
||||||
|
selectedPostId,
|
||||||
|
selectedThread,
|
||||||
|
activeTopicRoomId,
|
||||||
|
setQueryParam,
|
||||||
|
setCategory,
|
||||||
|
selectPost,
|
||||||
|
refreshThreads,
|
||||||
|
insertThreadFromNewPost,
|
||||||
|
recordThreadReply,
|
||||||
|
loadMorePosts,
|
||||||
|
hasMorePosts: Boolean(postsNextBatch),
|
||||||
|
loadingMore,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { BackRouteHandler } from '../../components/BackRouteHandler';
|
import { BackRouteHandler } from '../../components/BackRouteHandler';
|
||||||
|
import { isSpace } from '../../utils/room';
|
||||||
|
|
||||||
type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] };
|
type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] };
|
||||||
export function JoinBeforeNavigate({
|
export function JoinBeforeNavigate({
|
||||||
@@ -23,7 +24,7 @@ export function JoinBeforeNavigate({
|
|||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
|
|
||||||
const handleView = (roomId: string) => {
|
const handleView = (roomId: string) => {
|
||||||
if (mx.getRoom(roomId)?.isSpaceRoom()) {
|
if (isSpace(mx.getRoom(roomId) ?? null)) {
|
||||||
navigateSpace(roomId);
|
navigateSpace(roomId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ import { getSpaceRoomPath } from '../../pages/pathUtils';
|
|||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { CanDropCallback, useDnDMonitor } from './DnD';
|
import { CanDropCallback, useDnDMonitor } from './DnD';
|
||||||
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
|
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
|
||||||
import { getStateEvent } from '../../utils/room';
|
import { getStateEvent, shouldShowForumLobby } from '../../utils/room';
|
||||||
|
import { ForumSpaceShell } from '../forum/ForumSpaceShell';
|
||||||
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
|
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
|
||||||
import {
|
import {
|
||||||
makeCinnySpacesContent,
|
makeCinnySpacesContent,
|
||||||
@@ -149,7 +150,19 @@ const useCanDropLobbyItem = (
|
|||||||
return canDrop;
|
return canDrop;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function ForumSpaceLobby() {
|
||||||
|
return <ForumSpaceShell />;
|
||||||
|
}
|
||||||
|
|
||||||
export function Lobby() {
|
export function Lobby() {
|
||||||
|
const space = useSpace();
|
||||||
|
if (shouldShowForumLobby(space)) {
|
||||||
|
return <ForumSpaceLobby />;
|
||||||
|
}
|
||||||
|
return <SpaceCardLobby />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpaceCardLobby() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { MouseEventHandler, forwardRef, useState } from 'react';
|
import React, { MouseEventHandler, forwardRef, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -38,6 +38,12 @@ import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
|
|||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||||
|
import { AddExistingModal } from '../add-existing';
|
||||||
|
import { SpeechBubble } from '../../components/speech-bubble/SpeechBubble';
|
||||||
|
import { SpaceOptionsMenu, type OpenAddExistingOptions } from '../space/SpaceOptionsMenu';
|
||||||
|
import type { HierarchyItemSpace } from '../../hooks/useSpaceHierarchy';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { useForumBoardContextOptionally } from '../forum/ForumBoardContext';
|
||||||
|
|
||||||
type LobbyMenuProps = {
|
type LobbyMenuProps = {
|
||||||
powerLevels: IPowerLevels;
|
powerLevels: IPowerLevels;
|
||||||
@@ -137,14 +143,19 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
|
|||||||
|
|
||||||
type LobbyHeaderProps = {
|
type LobbyHeaderProps = {
|
||||||
showProfile?: boolean;
|
showProfile?: boolean;
|
||||||
|
/** Space name on the left + add room/space + ⋮ menu (forum toolbar). */
|
||||||
|
showSpaceToolbar?: boolean;
|
||||||
powerLevels: IPowerLevels;
|
powerLevels: IPowerLevels;
|
||||||
};
|
};
|
||||||
export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
export function LobbyHeader({ showProfile, showSpaceToolbar, powerLevels }: LobbyHeaderProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const space = useSpace();
|
const space = useSpace();
|
||||||
|
const creators = useRoomCreators(space);
|
||||||
|
const spacePermissions = useRoomPermissions(creators, powerLevels);
|
||||||
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
|
const [addExisting, setAddExisting] = useState<OpenAddExistingOptions | null>(null);
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
|
|
||||||
const name = useRoomName(space);
|
const name = useRoomName(space);
|
||||||
@@ -153,10 +164,192 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
|||||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const forumBoard = useForumBoardContextOptionally();
|
||||||
|
const menuWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const menuTriggerElRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const addParent = useMemo(() => {
|
||||||
|
const rootId = space.roomId;
|
||||||
|
if (!forumBoard) {
|
||||||
|
return { roomId: rootId, showAddSpace: true };
|
||||||
|
}
|
||||||
|
if (forumBoard.query.category) {
|
||||||
|
const section = forumBoard.sections.find((s) => s.title === forumBoard.query.category);
|
||||||
|
if (section?.spaceId) {
|
||||||
|
return { roomId: section.spaceId, showAddSpace: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { roomId: rootId, showAddSpace: true };
|
||||||
|
}, [forumBoard, space.roomId]);
|
||||||
|
|
||||||
|
const addParentItem: HierarchyItemSpace = useMemo(
|
||||||
|
() => ({
|
||||||
|
roomId: addParent.roomId,
|
||||||
|
content: { via: [] },
|
||||||
|
ts: 0,
|
||||||
|
space: true,
|
||||||
|
parentId: addParent.roomId === space.roomId ? undefined : space.roomId,
|
||||||
|
}),
|
||||||
|
[addParent.roomId, space.roomId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const canEditChildren = Boolean(
|
||||||
|
spacePermissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
menuTriggerElRef.current = evt.currentTarget;
|
||||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSpaceToolbar || !menuAnchor || addExisting) return;
|
||||||
|
|
||||||
|
let outsideTimer: number | undefined;
|
||||||
|
const clearOutsideTimer = () => {
|
||||||
|
if (outsideTimer) {
|
||||||
|
window.clearTimeout(outsideTimer);
|
||||||
|
outsideTimer = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
clearOutsideTimer();
|
||||||
|
setMenuAnchor(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isInside = (node: Node | null) => {
|
||||||
|
if (!node) return false;
|
||||||
|
return Boolean(
|
||||||
|
menuWrapperRef.current?.contains(node) || menuTriggerElRef.current?.contains(node)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = (evt: MouseEvent | TouchEvent) => {
|
||||||
|
const node = evt.target as Node | null;
|
||||||
|
if (!isInside(node)) {
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (evt: PointerEvent | MouseEvent) => {
|
||||||
|
const node = evt.target as Node | null;
|
||||||
|
const inside = isInside(node);
|
||||||
|
if (inside) {
|
||||||
|
clearOutsideTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!outsideTimer) {
|
||||||
|
outsideTimer = window.setTimeout(closeMenu, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handlePointerDown, true);
|
||||||
|
document.addEventListener('touchstart', handlePointerDown, true);
|
||||||
|
document.addEventListener('pointermove', handlePointerMove, true);
|
||||||
|
document.addEventListener('mousemove', handlePointerMove, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearOutsideTimer();
|
||||||
|
document.removeEventListener('mousedown', handlePointerDown, true);
|
||||||
|
document.removeEventListener('touchstart', handlePointerDown, true);
|
||||||
|
document.removeEventListener('pointermove', handlePointerMove, true);
|
||||||
|
document.removeEventListener('mousemove', handlePointerMove, true);
|
||||||
|
};
|
||||||
|
}, [addExisting, menuAnchor, showSpaceToolbar]);
|
||||||
|
|
||||||
|
if (showSpaceToolbar) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader outlined>
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="300">
|
||||||
|
<Avatar size="300" shrink="No">
|
||||||
|
<RoomAvatar
|
||||||
|
roomId={space.roomId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={name}
|
||||||
|
renderFallback={() => <Text size="H4">{nameInitials(name)}</Text>}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
<Text size="H3" truncate style={{ minWidth: 0 }}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Box grow="Yes" />
|
||||||
|
<TooltipProvider
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>More Options</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
onClick={handleOpenMenu}
|
||||||
|
ref={triggerRef}
|
||||||
|
aria-pressed={!!menuAnchor}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon size="400" src={Icons.VerticalDots} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
<PopOut
|
||||||
|
anchor={menuAnchor}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
returnFocusOnDeactivate: false,
|
||||||
|
onDeactivate: () => setMenuAnchor(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div ref={menuWrapperRef}>
|
||||||
|
<SpeechBubble variant="menu" tail="top" tailX="calc(100% - 22px)">
|
||||||
|
<SpaceOptionsMenu
|
||||||
|
room={space}
|
||||||
|
requestClose={() => setMenuAnchor(undefined)}
|
||||||
|
onOpenAddExisting={(options) => {
|
||||||
|
setAddExisting(options);
|
||||||
|
setMenuAnchor(undefined);
|
||||||
|
}}
|
||||||
|
forumAdd={
|
||||||
|
forumBoard && canEditChildren
|
||||||
|
? {
|
||||||
|
item: addParentItem,
|
||||||
|
showAddSpace: addParent.showAddSpace,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SpeechBubble>
|
||||||
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
{addExisting && (
|
||||||
|
<AddExistingModal
|
||||||
|
parentId={addExisting.parentId}
|
||||||
|
space={addExisting.space}
|
||||||
|
requestClose={() => setAddExisting(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageHeader className={showProfile ? undefined : css.Header} balance>
|
<PageHeader className={showProfile ? undefined : css.Header} balance>
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
|||||||
171
src/app/features/lobby/SpaceAddControls.tsx
Normal file
171
src/app/features/lobby/SpaceAddControls.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import React, { MouseEventHandler, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { Box, Chip, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config } from 'folds';
|
||||||
|
import type { HierarchyItem } from '../../hooks/useSpaceHierarchy';
|
||||||
|
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
|
||||||
|
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
|
||||||
|
import { AddExistingModal } from '../add-existing';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
function AddRoomButton({ item, roomLabel }: { item: HierarchyItem; roomLabel?: string }) {
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
const openCreateRoomModal = useOpenCreateRoomModal();
|
||||||
|
const [addExisting, setAddExisting] = useState(false);
|
||||||
|
|
||||||
|
const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateRoom = () => {
|
||||||
|
openCreateRoomModal(item.roomId);
|
||||||
|
setCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddExisting = () => {
|
||||||
|
setAddExisting(true);
|
||||||
|
setCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="Start"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Primary"
|
||||||
|
fill="None"
|
||||||
|
onClick={handleCreateRoom}
|
||||||
|
>
|
||||||
|
<Text size="T300">New Room</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
|
||||||
|
<Text size="T300">Existing Room</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
variant="Primary"
|
||||||
|
radii="Pill"
|
||||||
|
before={<Icon src={Icons.Plus} size="50" />}
|
||||||
|
onClick={handleAddRoom}
|
||||||
|
aria-pressed={!!cords}
|
||||||
|
>
|
||||||
|
<Text size="B300">{roomLabel ?? 'Add Room'}</Text>
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
{addExisting && (
|
||||||
|
<AddExistingModal parentId={item.roomId} requestClose={() => setAddExisting(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddSpaceButton({ item, spaceLabel }: { item: HierarchyItem; spaceLabel?: string }) {
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
const openCreateSpaceModal = useOpenCreateSpaceModal();
|
||||||
|
const [addExisting, setAddExisting] = useState(false);
|
||||||
|
|
||||||
|
const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSpace = () => {
|
||||||
|
openCreateSpaceModal(item.roomId as string);
|
||||||
|
setCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddExisting = () => {
|
||||||
|
setAddExisting(true);
|
||||||
|
setCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="Start"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Primary"
|
||||||
|
fill="None"
|
||||||
|
onClick={handleCreateSpace}
|
||||||
|
>
|
||||||
|
<Text size="T300">New Space</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
|
||||||
|
<Text size="T300">Existing Space</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
before={<Icon src={Icons.Plus} size="50" />}
|
||||||
|
onClick={handleAddSpace}
|
||||||
|
aria-pressed={!!cords}
|
||||||
|
>
|
||||||
|
<Text size="B300">{spaceLabel ?? 'Add Space'}</Text>
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
{addExisting && (
|
||||||
|
<AddExistingModal space parentId={item.roomId} requestClose={() => setAddExisting(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpaceAddControlsProps = {
|
||||||
|
item: HierarchyItem;
|
||||||
|
/** Show “Add Space” (root / top-level space only). */
|
||||||
|
showAddSpace?: boolean;
|
||||||
|
roomLabel?: string;
|
||||||
|
spaceLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Add room / add sub-space chips from the space lobby. */
|
||||||
|
export function SpaceAddControls({
|
||||||
|
item,
|
||||||
|
showAddSpace = false,
|
||||||
|
roomLabel,
|
||||||
|
spaceLabel,
|
||||||
|
}: SpaceAddControlsProps) {
|
||||||
|
return (
|
||||||
|
<Box direction="Row" alignItems="Center" gap="200">
|
||||||
|
<AddRoomButton item={item} roomLabel={roomLabel} />
|
||||||
|
{showAddSpace && <AddSpaceButton item={item} spaceLabel={spaceLabel} />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
|||||||
const subspaces = useMemo(() => {
|
const subspaces = useMemo(() => {
|
||||||
const s: Map<string, IHierarchyRoom> = new Map();
|
const s: Map<string, IHierarchyRoom> = new Map();
|
||||||
rooms.forEach((r) => {
|
rooms.forEach((r) => {
|
||||||
if (r.room_type === RoomType.Space) {
|
if (r.room_type === RoomType.Space || r.room_type === RoomType.Forum) {
|
||||||
s.set(r.room_id, r);
|
s.set(r.room_id, r);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,9 +33,7 @@ import { useDraggableItem } from './DnD';
|
|||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
|
import { SpaceAddControls } from './SpaceAddControls';
|
||||||
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
|
|
||||||
import { AddExistingModal } from '../add-existing';
|
|
||||||
|
|
||||||
function SpaceProfileLoading() {
|
function SpaceProfileLoading() {
|
||||||
return (
|
return (
|
||||||
@@ -240,141 +238,6 @@ function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileP
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddRoomButton({ item }: { item: HierarchyItem }) {
|
|
||||||
const [cords, setCords] = useState<RectCords>();
|
|
||||||
const openCreateRoomModal = useOpenCreateRoomModal();
|
|
||||||
const [addExisting, setAddExisting] = useState(false);
|
|
||||||
|
|
||||||
const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
|
||||||
setCords(evt.currentTarget.getBoundingClientRect());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateRoom = () => {
|
|
||||||
openCreateRoomModal(item.roomId);
|
|
||||||
setCords(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddExisting = () => {
|
|
||||||
setAddExisting(true);
|
|
||||||
setCords(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PopOut
|
|
||||||
anchor={cords}
|
|
||||||
position="Bottom"
|
|
||||||
align="End"
|
|
||||||
content={
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: () => setCords(undefined),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
|
||||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Menu style={{ padding: config.space.S100 }}>
|
|
||||||
<MenuItem
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
variant="Primary"
|
|
||||||
fill="None"
|
|
||||||
onClick={handleCreateRoom}
|
|
||||||
>
|
|
||||||
<Text size="T300">New Room</Text>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
|
|
||||||
<Text size="T300">Existing Room</Text>
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
</FocusTrap>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Chip
|
|
||||||
variant="Primary"
|
|
||||||
radii="Pill"
|
|
||||||
before={<Icon src={Icons.Plus} size="50" />}
|
|
||||||
onClick={handleAddRoom}
|
|
||||||
aria-pressed={!!cords}
|
|
||||||
>
|
|
||||||
<Text size="B300">Add Room</Text>
|
|
||||||
</Chip>
|
|
||||||
{addExisting && (
|
|
||||||
<AddExistingModal parentId={item.roomId} requestClose={() => setAddExisting(false)} />
|
|
||||||
)}
|
|
||||||
</PopOut>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddSpaceButton({ item }: { item: HierarchyItem }) {
|
|
||||||
const [cords, setCords] = useState<RectCords>();
|
|
||||||
const openCreateSpaceModal = useOpenCreateSpaceModal();
|
|
||||||
const [addExisting, setAddExisting] = useState(false);
|
|
||||||
|
|
||||||
const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
|
||||||
setCords(evt.currentTarget.getBoundingClientRect());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateSpace = () => {
|
|
||||||
openCreateSpaceModal(item.roomId as any);
|
|
||||||
setCords(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddExisting = () => {
|
|
||||||
setAddExisting(true);
|
|
||||||
setCords(undefined);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<PopOut
|
|
||||||
anchor={cords}
|
|
||||||
position="Bottom"
|
|
||||||
align="End"
|
|
||||||
content={
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: () => setCords(undefined),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
|
||||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Menu style={{ padding: config.space.S100 }}>
|
|
||||||
<MenuItem
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
variant="Primary"
|
|
||||||
fill="None"
|
|
||||||
onClick={handleCreateSpace}
|
|
||||||
>
|
|
||||||
<Text size="T300">New Space</Text>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
|
|
||||||
<Text size="T300">Existing Space</Text>
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
</FocusTrap>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Chip
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
radii="Pill"
|
|
||||||
before={<Icon src={Icons.Plus} size="50" />}
|
|
||||||
onClick={handleAddSpace}
|
|
||||||
aria-pressed={!!cords}
|
|
||||||
>
|
|
||||||
<Text size="B300">Add Space</Text>
|
|
||||||
</Chip>
|
|
||||||
{addExisting && (
|
|
||||||
<AddExistingModal space parentId={item.roomId} requestClose={() => setAddExisting(false)} />
|
|
||||||
)}
|
|
||||||
</PopOut>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type SpaceItemCardProps = {
|
type SpaceItemCardProps = {
|
||||||
summary: IHierarchyRoom | undefined;
|
summary: IHierarchyRoom | undefined;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -484,8 +347,7 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
|
|||||||
</Box>
|
</Box>
|
||||||
{space && canEditChild && (
|
{space && canEditChild && (
|
||||||
<Box shrink="No" alignItems="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" gap="200">
|
||||||
<AddRoomButton item={item} />
|
<SpaceAddControls item={item} showAddSpace={item.parentId === undefined} />
|
||||||
{item.parentId === undefined && <AddSpaceButton item={item} />}
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
8
src/app/features/room/ForumRoomView.css.ts
Normal file
8
src/app/features/room/ForumRoomView.css.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color } from 'folds';
|
||||||
|
|
||||||
|
export const ForumBanner = style({
|
||||||
|
borderLeft: `3px solid ${color.Primary.Main}`,
|
||||||
|
background: color.Primary.Container,
|
||||||
|
color: color.Primary.OnContainer,
|
||||||
|
});
|
||||||
50
src/app/features/room/ForumRoomView.tsx
Normal file
50
src/app/features/room/ForumRoomView.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
|
import { findForumRootSpace, shouldShowForumLobby } from '../../utils/room';
|
||||||
|
import { ForumSpaceShell } from '../forum/ForumSpaceShell';
|
||||||
|
import { RoomView } from './RoomView';
|
||||||
|
|
||||||
|
type ForumRoomViewProps = {
|
||||||
|
room: Room;
|
||||||
|
eventId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ForumRoomView({ room, eventId }: ForumRoomViewProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const space = useSpaceOptionally();
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
const [, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const forumSpaceId =
|
||||||
|
space && shouldShowForumLobby(space)
|
||||||
|
? space.roomId
|
||||||
|
: findForumRootSpace(mx, room.roomId, roomToParents);
|
||||||
|
|
||||||
|
const forumSpace = forumSpaceId ? mx.getRoom(forumSpaceId) : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!eventId) return;
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
next.set('post', eventId);
|
||||||
|
if (!next.get('topic') && room.roomId !== forumSpaceId) {
|
||||||
|
next.set('topic', room.roomId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
{ replace: true }
|
||||||
|
);
|
||||||
|
}, [eventId, setSearchParams, room.roomId, forumSpaceId]);
|
||||||
|
|
||||||
|
if (forumSpace && shouldShowForumLobby(forumSpace)) {
|
||||||
|
return <ForumSpaceShell />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <RoomView room={room} eventId={eventId} />;
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||||
import { activeRoomIdAtom } from '../../state/activeRoom';
|
import { activeRoomIdAtom } from '../../state/activeRoom';
|
||||||
|
import { isForum } from '../../utils/room';
|
||||||
|
import { ForumRoomView } from './ForumRoomView';
|
||||||
|
|
||||||
export function Room() {
|
export function Room() {
|
||||||
const { eventId } = useParams();
|
const { eventId } = useParams();
|
||||||
@@ -28,6 +30,7 @@ export function Room() {
|
|||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
const members = useRoomMembers(mx, room.roomId);
|
const members = useRoomMembers(mx, room.roomId);
|
||||||
const markAsRead = useMarkAsRead(mx);
|
const markAsRead = useMarkAsRead(mx);
|
||||||
|
const forumRoom = isForum(room);
|
||||||
|
|
||||||
// Update titlebar with current room ID
|
// Update titlebar with current room ID
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,7 +53,7 @@ export function Room() {
|
|||||||
return (
|
return (
|
||||||
<PowerLevelsContextProvider value={powerLevels}>
|
<PowerLevelsContextProvider value={powerLevels}>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<RoomView room={room} eventId={eventId} />
|
{forumRoom ? <ForumRoomView room={room} eventId={eventId} /> : <RoomView room={room} eventId={eventId} />}
|
||||||
{screenSize === ScreenSize.Desktop && isDrawer && (
|
{screenSize === ScreenSize.Desktop && isDrawer && (
|
||||||
<>
|
<>
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
|
|||||||
@@ -132,9 +132,10 @@ interface RoomInputProps {
|
|||||||
room: Room;
|
room: Room;
|
||||||
/** When provided, all messages are sent as replies in this thread. */
|
/** When provided, all messages are sent as replies in this thread. */
|
||||||
threadRootId?: string;
|
threadRootId?: string;
|
||||||
|
onMessageSent?: () => void;
|
||||||
}
|
}
|
||||||
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => {
|
({ editor, fileDropContainerRef, roomId, room, threadRootId, onMessageSent }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
@@ -480,7 +481,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
setCommandHint(undefined);
|
setCommandHint(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
}, [mx, roomId, threadRootId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons]);
|
onMessageSent?.();
|
||||||
|
}, [mx, roomId, threadRootId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons, onMessageSent]);
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
|||||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||||
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
||||||
import { copyToClipboard } from '../../utils/dom';
|
import { copyToClipboard } from '../../utils/dom';
|
||||||
|
import { SpeechBubble } from '../../components/speech-bubble/SpeechBubble';
|
||||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
||||||
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
@@ -316,35 +317,19 @@ function CallIndicator({ roomId }: CallIndicatorProps) {
|
|||||||
position="Bottom"
|
position="Bottom"
|
||||||
offset={8}
|
offset={8}
|
||||||
tooltip={
|
tooltip={
|
||||||
<Box
|
<SpeechBubble tail="top" tailX="50%">
|
||||||
direction="Column"
|
<Box direction="Column" gap="50">
|
||||||
gap="50"
|
<Text
|
||||||
style={{
|
size="T300"
|
||||||
padding: `${toRem(8)} ${toRem(12)}`,
|
style={{ fontWeight: 600, color: color.SurfaceVariant.OnContainer }}
|
||||||
backgroundColor: color.SurfaceVariant.Container,
|
>
|
||||||
color: color.SurfaceVariant.OnContainer,
|
{displayName}
|
||||||
borderRadius: toRem(8),
|
</Text>
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
<Text size="T200" style={{ opacity: 0.7, color: color.SurfaceVariant.OnContainer }}>
|
||||||
position: 'relative',
|
{member.userId}
|
||||||
}}
|
</Text>
|
||||||
>
|
</Box>
|
||||||
{/* Speech bubble arrow */}
|
</SpeechBubble>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: toRem(-6),
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
borderLeft: `${toRem(6)} solid transparent`,
|
|
||||||
borderRight: `${toRem(6)} solid transparent`,
|
|
||||||
borderBottom: `${toRem(6)} solid ${color.SurfaceVariant.Container}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text size="T300" style={{ fontWeight: 600, color: color.SurfaceVariant.OnContainer }}>{displayName}</Text>
|
|
||||||
<Text size="T200" style={{ opacity: 0.7, color: color.SurfaceVariant.OnContainer }}>{member.userId}</Text>
|
|
||||||
</Box>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
@@ -490,7 +475,11 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoomViewHeader() {
|
type RoomViewHeaderProps = {
|
||||||
|
forumLayout?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
@@ -564,9 +553,16 @@ export function RoomViewHeader() {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
<Text size={topic ? 'H5' : 'H3'} truncate>
|
<Box alignItems="Center" gap="100">
|
||||||
{name}
|
<Text size={topic ? 'H5' : 'H3'} truncate>
|
||||||
</Text>
|
{name}
|
||||||
|
</Text>
|
||||||
|
{forumLayout && (
|
||||||
|
<Badge variant="Secondary" fill="Soft" outlined>
|
||||||
|
<Text size="T200">Forum</Text>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
{topic && (
|
{topic && (
|
||||||
<UseStateProvider initial={false}>
|
<UseStateProvider initial={false}>
|
||||||
{(viewTopic, setViewTopic) => (
|
{(viewTopic, setViewTopic) => (
|
||||||
|
|||||||
306
src/app/features/space/SpaceOptionsMenu.tsx
Normal file
306
src/app/features/space/SpaceOptionsMenu.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import React, { forwardRef, useState } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { Box, Icon, Icons, Line, Menu, MenuItem, Text, config, toRem } from 'folds';
|
||||||
|
import type { HierarchyItem } from '../../hooks/useSpaceHierarchy';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
|
||||||
|
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
|
||||||
|
import { AddExistingModal } from '../add-existing';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
|
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
|
||||||
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
|
import { useMarkRoomsAsRead } from '../../hooks/useMarkAsRead';
|
||||||
|
import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../state/hooks/roomList';
|
||||||
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
|
import { useRoomsUnread } from '../../state/hooks/unread';
|
||||||
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||||
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
|
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
|
||||||
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||||
|
import { copyToClipboard } from '../../utils/dom';
|
||||||
|
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
|
||||||
|
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||||
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
|
|
||||||
|
type ForumAddOptions = {
|
||||||
|
item: HierarchyItem;
|
||||||
|
showAddSpace: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenAddExistingOptions = {
|
||||||
|
parentId: string;
|
||||||
|
space: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SpaceOptionsMenuProps = {
|
||||||
|
room: Room;
|
||||||
|
requestClose: () => void;
|
||||||
|
forumAdd?: ForumAddOptions;
|
||||||
|
/** Render Add Existing outside the popout (forum kebab). */
|
||||||
|
onOpenAddExisting?: (options: OpenAddExistingOptions) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpaceOptionsMenu = forwardRef<HTMLDivElement, SpaceOptionsMenuProps>(
|
||||||
|
({ room, requestClose, forumAdd, onOpenAddExisting }, ref) => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
const powerLevels = usePowerLevels(room);
|
||||||
|
const creators = useRoomCreators(room);
|
||||||
|
|
||||||
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
|
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
||||||
|
const openSpaceSettings = useOpenSpaceSettings();
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
const markRoomsAsRead = useMarkRoomsAsRead(mx);
|
||||||
|
|
||||||
|
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||||
|
const [addExistingRoom, setAddExistingRoom] = useState(false);
|
||||||
|
const [addExistingSpace, setAddExistingSpace] = useState(false);
|
||||||
|
const openCreateRoomModal = useOpenCreateRoomModal();
|
||||||
|
const openCreateSpaceModal = useOpenCreateSpaceModal();
|
||||||
|
|
||||||
|
const allChild = useSpaceChildren(
|
||||||
|
allRoomsAtom,
|
||||||
|
room.roomId,
|
||||||
|
useRecursiveChildScopeFactory(mx, roomToParents)
|
||||||
|
);
|
||||||
|
const unread = useRoomsUnread(allChild, roomToUnreadAtom);
|
||||||
|
|
||||||
|
const handleMarkAsRead = () => {
|
||||||
|
markRoomsAsRead(allChild, hideActivity);
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||||
|
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
||||||
|
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInvite = () => {
|
||||||
|
setInvitePrompt(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoomSettings = () => {
|
||||||
|
openSpaceSettings(room.roomId);
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenTimeline = () => {
|
||||||
|
navigateRoom(room.roomId);
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddTopic = () => {
|
||||||
|
if (!forumAdd) return;
|
||||||
|
openCreateRoomModal(forumAdd.item.roomId);
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCategory = () => {
|
||||||
|
if (!forumAdd) return;
|
||||||
|
openCreateSpaceModal(forumAdd.item.roomId);
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddExistingTopic = () => {
|
||||||
|
if (!forumAdd) return;
|
||||||
|
if (onOpenAddExisting) {
|
||||||
|
onOpenAddExisting({ parentId: forumAdd.item.roomId, space: false });
|
||||||
|
requestClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAddExistingRoom(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddExistingCategory = () => {
|
||||||
|
if (!forumAdd) return;
|
||||||
|
if (onOpenAddExisting) {
|
||||||
|
onOpenAddExisting({ parentId: forumAdd.item.roomId, space: true });
|
||||||
|
requestClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAddExistingSpace(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||||
|
{forumAdd && (
|
||||||
|
<>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleAddTopic}
|
||||||
|
variant="Primary"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.Plus} />}
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Add Topic
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleAddExistingTopic}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
fill="None"
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Add Existing Topic
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
{forumAdd.showAddSpace && (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleAddCategory}
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.Plus} />}
|
||||||
|
radii="300"
|
||||||
|
fill="None"
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Add Section
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleAddExistingCategory}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
fill="None"
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Add Existing Section
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Line variant="Surface" size="300" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{invitePrompt && (
|
||||||
|
<InviteUserPrompt
|
||||||
|
room={room}
|
||||||
|
requestClose={() => {
|
||||||
|
setInvitePrompt(false);
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleMarkAsRead}
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||||
|
radii="300"
|
||||||
|
disabled={!unread}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Mark as Read
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</Box>
|
||||||
|
<Line variant="Surface" size="300" />
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleInvite}
|
||||||
|
variant="Primary"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.UserPlus} />}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={invitePrompt}
|
||||||
|
disabled={!canInvite}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Invite
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.Link} />}
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Copy Link
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleRoomSettings}
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.Setting} />}
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Space Settings
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
{developerTools && (
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleOpenTimeline}
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.Terminal} />}
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Event Timeline
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Line variant="Surface" size="300" />
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
<UseStateProvider initial={false}>
|
||||||
|
{(promptLeave, setPromptLeave) => (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => setPromptLeave(true)}
|
||||||
|
variant="Critical"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={promptLeave}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Leave Space
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
{promptLeave && (
|
||||||
|
<LeaveSpacePrompt
|
||||||
|
roomId={room.roomId}
|
||||||
|
onDone={requestClose}
|
||||||
|
onCancel={() => setPromptLeave(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</UseStateProvider>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
{forumAdd && addExistingRoom && (
|
||||||
|
<AddExistingModal parentId={forumAdd.item.roomId} requestClose={() => setAddExistingRoom(false)} />
|
||||||
|
)}
|
||||||
|
{forumAdd && addExistingSpace && (
|
||||||
|
<AddExistingModal
|
||||||
|
space
|
||||||
|
parentId={forumAdd.item.roomId}
|
||||||
|
requestClose={() => setAddExistingSpace(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -11,14 +11,15 @@ export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHan
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const useFileDropZone = (
|
export const useFileDropZone = (
|
||||||
zoneRef: RefObject<HTMLElement>,
|
zoneRef: RefObject<HTMLElement> | undefined,
|
||||||
onDrop: (file: File[]) => void
|
onDrop: (file: File[]) => void
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const dragStateRef = useRef<'start' | 'leave' | 'over'>();
|
const dragStateRef = useRef<'start' | 'leave' | 'over'>();
|
||||||
const [active, setActive] = useState(false);
|
const [active, setActive] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const target = zoneRef.current;
|
const target = zoneRef?.current;
|
||||||
|
if (!target) return undefined;
|
||||||
const handleDrop = (evt: DragEvent) => {
|
const handleDrop = (evt: DragEvent) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
dragStateRef.current = undefined;
|
dragStateRef.current = undefined;
|
||||||
@@ -35,7 +36,9 @@ export const useFileDropZone = (
|
|||||||
}, [zoneRef, onDrop]);
|
}, [zoneRef, onDrop]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const target = zoneRef.current;
|
const target = zoneRef?.current;
|
||||||
|
if (!target) return undefined;
|
||||||
|
|
||||||
const handleDragEnter = (evt: DragEvent) => {
|
const handleDragEnter = (evt: DragEvent) => {
|
||||||
if (evt.dataTransfer?.types.includes('Files')) {
|
if (evt.dataTransfer?.types.includes('Files')) {
|
||||||
dragStateRef.current = 'start';
|
dragStateRef.current = 'start';
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
getSpaceRoomPath,
|
getSpaceRoomPath,
|
||||||
} from '../pages/pathUtils';
|
} from '../pages/pathUtils';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { getOrphanParents, guessPerfectParent } from '../utils/room';
|
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
|
||||||
import { roomToParentsAtom } from '../state/room/roomToParents';
|
import { roomToParentsAtom } from '../state/room/roomToParents';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
import { useSelectedSpace } from './router/useSelectedSpace';
|
import { useSelectedSpace } from './router/useSelectedSpace';
|
||||||
@@ -34,9 +34,15 @@ export const useRoomNavigate = () => {
|
|||||||
|
|
||||||
const navigateRoom = useCallback(
|
const navigateRoom = useCallback(
|
||||||
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
|
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
|
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
|
||||||
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
|
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
|
||||||
|
|
||||||
|
if (room && isSpace(room) && !openSpaceTimeline) {
|
||||||
|
navigate(getSpacePath(roomIdOrAlias), opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
|
const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
|
||||||
if (orphanParents.length > 0) {
|
if (orphanParents.length > 0) {
|
||||||
let parentSpace: string;
|
let parentSpace: string;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { QueryFunction, useInfiniteQuery } from '@tanstack/react-query';
|
|||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { roomToParentsAtom } from '../state/room/roomToParents';
|
import { roomToParentsAtom } from '../state/room/roomToParents';
|
||||||
import { MSpaceChildContent, StateEvent } from '../../types/matrix/room';
|
import { MSpaceChildContent, StateEvent } from '../../types/matrix/room';
|
||||||
import { getAllParents, getStateEvents, isValidChild } from '../utils/room';
|
import { getAllParents, getStateEvents, isSpace, isValidChild } from '../utils/room';
|
||||||
import { isRoomId } from '../utils/matrix';
|
import { isRoomId } from '../utils/matrix';
|
||||||
import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort';
|
import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort';
|
||||||
import { useStateEventCallback } from './useStateEventCallback';
|
import { useStateEventCallback } from './useStateEventCallback';
|
||||||
@@ -64,7 +64,7 @@ const getHierarchySpaces = (
|
|||||||
// because we can not find if a childId is space without joining
|
// because we can not find if a childId is space without joining
|
||||||
// or requesting room summary, we will look it into spaceRooms local
|
// or requesting room summary, we will look it into spaceRooms local
|
||||||
// cache which we maintain as we load summary in UI.
|
// cache which we maintain as we load summary in UI.
|
||||||
if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) {
|
if (isSpace(getRoom(childId) ?? null) || spaceRooms.has(childId)) {
|
||||||
const childItem: HierarchyItemSpace = {
|
const childItem: HierarchyItemSpace = {
|
||||||
roomId: childId,
|
roomId: childId,
|
||||||
content: childEvent.getContent<MSpaceChildContent>(),
|
content: childEvent.getContent<MSpaceChildContent>(),
|
||||||
@@ -114,7 +114,7 @@ const getSpaceHierarchy = (
|
|||||||
if (!isValidChild(childEvent)) return;
|
if (!isValidChild(childEvent)) return;
|
||||||
const childId = childEvent.getStateKey();
|
const childId = childEvent.getStateKey();
|
||||||
if (!childId || !isRoomId(childId)) return;
|
if (!childId || !isRoomId(childId)) return;
|
||||||
if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) return;
|
if (isSpace(getRoom(childId) ?? null) || spaceRooms.has(childId)) return;
|
||||||
|
|
||||||
const childItem: HierarchyItemRoom = {
|
const childItem: HierarchyItemRoom = {
|
||||||
roomId: childId,
|
roomId: childId,
|
||||||
@@ -189,7 +189,7 @@ const getSpaceJoinedHierarchy = (
|
|||||||
const childId = childEvent.getStateKey();
|
const childId = childEvent.getStateKey();
|
||||||
if (!childId || !isRoomId(childId)) return false;
|
if (!childId || !isRoomId(childId)) return false;
|
||||||
const room = getRoom(childId);
|
const room = getRoom(childId);
|
||||||
if (!room || room.isSpaceRoom()) return false;
|
if (!room || isSpace(room)) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { MatrixClient } from 'matrix-js-sdk';
|
|||||||
import { getHomeRoomPath, getDirectRoomPath, getSpaceRoomPath } from './pages/pathUtils';
|
import { getHomeRoomPath, getDirectRoomPath, getSpaceRoomPath } from './pages/pathUtils';
|
||||||
import { getCanonicalAliasOrRoomId, isRoomId } from './utils/matrix';
|
import { getCanonicalAliasOrRoomId, isRoomId } from './utils/matrix';
|
||||||
import { getCallService } from './features/call/useCall';
|
import { getCallService } from './features/call/useCall';
|
||||||
|
import { RoomType, StateEvent } from '../types/matrix/room';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global reference to the router navigate function
|
* Global reference to the router navigate function
|
||||||
@@ -23,6 +24,21 @@ let navigateFunction: ((path: string) => void) | null = null;
|
|||||||
*/
|
*/
|
||||||
let isInitialized = false;
|
let isInitialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} room
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isSpaceLikeRoom(room: any): boolean {
|
||||||
|
if (!room) return false;
|
||||||
|
if (typeof room.isSpaceRoom === 'function' && room.isSpaceRoom()) return true;
|
||||||
|
const roomKindEvent = room.currentState?.getStateEvents?.(StateEvent.PaarrotRoomKind, '');
|
||||||
|
const roomKind = roomKindEvent?.getContent?.()?.kind;
|
||||||
|
if (roomKind === 'forum_space') return true;
|
||||||
|
const createEvent = room.currentState?.getStateEvents?.(StateEvent.RoomCreate, '');
|
||||||
|
const roomType = createEvent?.getContent?.()?.type;
|
||||||
|
return roomType === RoomType.Forum;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the navigate function for programmatic navigation
|
* Set the navigate function for programmatic navigation
|
||||||
* Call this from your router setup
|
* Call this from your router setup
|
||||||
@@ -291,7 +307,7 @@ async function changeChannel(matrixClient: any, roomId: string) {
|
|||||||
if (isDirect) {
|
if (isDirect) {
|
||||||
// Navigate to direct message
|
// Navigate to direct message
|
||||||
path = getDirectRoomPath(roomIdOrAlias);
|
path = getDirectRoomPath(roomIdOrAlias);
|
||||||
} else if (room.isSpaceRoom()) {
|
} else if (isSpaceLikeRoom(room)) {
|
||||||
// Navigate to space
|
// Navigate to space
|
||||||
path = getSpaceRoomPath(roomIdOrAlias, roomIdOrAlias);
|
path = getSpaceRoomPath(roomIdOrAlias, roomIdOrAlias);
|
||||||
} else {
|
} else {
|
||||||
@@ -316,7 +332,7 @@ async function getChannels(matrixClient: any) {
|
|||||||
const rooms = matrixClient?.getRooms() || [];
|
const rooms = matrixClient?.getRooms() || [];
|
||||||
|
|
||||||
return rooms
|
return rooms
|
||||||
.filter((room: any) => !room.isSpaceRoom())
|
.filter((room: any) => !isSpaceLikeRoom(room))
|
||||||
.map((room: any) => {
|
.map((room: any) => {
|
||||||
// Extract server domain from roomId (!localpart:domain.tld)
|
// Extract server domain from roomId (!localpart:domain.tld)
|
||||||
const serverMatch = room.roomId.match(/:(.+)$/);
|
const serverMatch = room.roomId.match(/:(.+)$/);
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
|||||||
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
|
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
|
||||||
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
||||||
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
|
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
|
||||||
|
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
|
||||||
|
import { ForumSpaceLayout } from '../features/forum/ForumSpaceLayout';
|
||||||
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
|
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
|
||||||
import { Notifications, Inbox, Invites } from './client/inbox';
|
import { Notifications, Inbox, Invites } from './client/inbox';
|
||||||
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
|
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
|
||||||
@@ -236,15 +238,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||||||
path={SPACE_PATH}
|
path={SPACE_PATH}
|
||||||
element={
|
element={
|
||||||
<RouteSpaceProvider>
|
<RouteSpaceProvider>
|
||||||
<PageRoot
|
<ForumSpaceLayout>
|
||||||
nav={
|
<ForumAwareSpaceLayout />
|
||||||
<MobileFriendlyPageNav path={SPACE_PATH}>
|
</ForumSpaceLayout>
|
||||||
<Space />
|
|
||||||
</MobileFriendlyPageNav>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AnimatedOutlet />
|
|
||||||
</PageRoot>
|
|
||||||
</RouteSpaceProvider>
|
</RouteSpaceProvider>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { mDirectAtom } from '../../../state/mDirectList';
|
|||||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
import { useOrphanRooms } from '../../../state/hooks/roomList';
|
import { useOrphanRooms } from '../../../state/hooks/roomList';
|
||||||
import { StateEvent } from '../../../../types/matrix/room';
|
import { RoomType, StateEvent } from '../../../../types/matrix/room';
|
||||||
|
|
||||||
export const useHomeRooms = () => {
|
export const useHomeRooms = () => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -15,6 +15,25 @@ export const useHomeRooms = () => {
|
|||||||
|
|
||||||
// Filter out rooms that are sub-rooms of ANY room (not just orphan rooms)
|
// Filter out rooms that are sub-rooms of ANY room (not just orphan rooms)
|
||||||
const rooms = useMemo(() => {
|
const rooms = useMemo(() => {
|
||||||
|
const isSpaceLikeRoom = (roomId: string): boolean => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return false;
|
||||||
|
|
||||||
|
if (typeof room.isSpaceRoom === 'function' && room.isSpaceRoom()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomKindEvent = room.currentState.getStateEvents(StateEvent.PaarrotRoomKind, '');
|
||||||
|
const roomKind = roomKindEvent?.getContent<{ kind?: string }>()?.kind;
|
||||||
|
if (roomKind === 'forum_space') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createEvent = room.currentState.getStateEvents(StateEvent.RoomCreate, '');
|
||||||
|
const roomType = createEvent?.getContent<{ type?: string }>()?.type;
|
||||||
|
return roomType === RoomType.Space || roomType === RoomType.Forum;
|
||||||
|
};
|
||||||
|
|
||||||
// Build a set of all sub-room IDs from ALL joined rooms
|
// Build a set of all sub-room IDs from ALL joined rooms
|
||||||
const allSubRoomIds = new Set<string>();
|
const allSubRoomIds = new Set<string>();
|
||||||
mx.getRooms().forEach((room) => {
|
mx.getRooms().forEach((room) => {
|
||||||
@@ -24,7 +43,7 @@ export const useHomeRooms = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Return only rooms that are not sub-rooms of another room
|
// Return only rooms that are not sub-rooms of another room
|
||||||
return orphanRooms.filter((roomId) => !allSubRoomIds.has(roomId));
|
return orphanRooms.filter((roomId) => !allSubRoomIds.has(roomId) && !isSpaceLikeRoom(roomId));
|
||||||
}, [mx, orphanRooms]);
|
}, [mx, orphanRooms]);
|
||||||
|
|
||||||
return rooms;
|
return rooms;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
|||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
||||||
import { useSpace } from '../../../hooks/useSpace';
|
import { useSpace } from '../../../hooks/useSpace';
|
||||||
import { getAllParents, getSpaceChildren } from '../../../utils/room';
|
import { getAllParents, getSpaceChildren, isSpace } from '../../../utils/room';
|
||||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
||||||
@@ -38,7 +38,7 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (developerTools && room.isSpaceRoom() && room.roomId === space.roomId) {
|
if (developerTools && isSpace(room) && room.roomId === space.roomId) {
|
||||||
// allow to view space timeline
|
// allow to view space timeline
|
||||||
return (
|
return (
|
||||||
<RoomProvider key={room.roomId} value={room}>
|
<RoomProvider key={room.roomId} value={room}>
|
||||||
|
|||||||
@@ -85,165 +85,8 @@ import { ContainerColor } from '../../../styles/ContainerColor.css';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { BreakWord } from '../../../styles/Text.css';
|
import { BreakWord } from '../../../styles/Text.css';
|
||||||
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
|
||||||
|
import { isSpace, shouldShowForumLobby } from '../../../utils/room';
|
||||||
type SpaceMenuProps = {
|
import { SpaceOptionsMenu } from '../../../features/space/SpaceOptionsMenu';
|
||||||
room: Room;
|
|
||||||
requestClose: () => void;
|
|
||||||
};
|
|
||||||
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
|
||||||
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
|
||||||
const powerLevels = usePowerLevels(room);
|
|
||||||
const creators = useRoomCreators(room);
|
|
||||||
|
|
||||||
const permissions = useRoomPermissions(creators, powerLevels);
|
|
||||||
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
|
||||||
const openSpaceSettings = useOpenSpaceSettings();
|
|
||||||
const { navigateRoom } = useRoomNavigate();
|
|
||||||
const markRoomsAsRead = useMarkRoomsAsRead(mx);
|
|
||||||
|
|
||||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
|
||||||
|
|
||||||
const allChild = useSpaceChildren(
|
|
||||||
allRoomsAtom,
|
|
||||||
room.roomId,
|
|
||||||
useRecursiveChildScopeFactory(mx, roomToParents)
|
|
||||||
);
|
|
||||||
const unread = useRoomsUnread(allChild, roomToUnreadAtom);
|
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
|
||||||
markRoomsAsRead(allChild, hideActivity);
|
|
||||||
requestClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
|
||||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
|
||||||
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
|
||||||
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
|
|
||||||
requestClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInvite = () => {
|
|
||||||
setInvitePrompt(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRoomSettings = () => {
|
|
||||||
openSpaceSettings(room.roomId);
|
|
||||||
requestClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenTimeline = () => {
|
|
||||||
navigateRoom(room.roomId);
|
|
||||||
requestClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
||||||
{invitePrompt && room && (
|
|
||||||
<InviteUserPrompt
|
|
||||||
room={room}
|
|
||||||
requestClose={() => {
|
|
||||||
setInvitePrompt(false);
|
|
||||||
requestClose();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<MenuItem
|
|
||||||
onClick={handleMarkAsRead}
|
|
||||||
size="300"
|
|
||||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
|
||||||
radii="300"
|
|
||||||
disabled={!unread}
|
|
||||||
>
|
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
||||||
Mark as Read
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
</Box>
|
|
||||||
<Line variant="Surface" size="300" />
|
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
||||||
<MenuItem
|
|
||||||
onClick={handleInvite}
|
|
||||||
variant="Primary"
|
|
||||||
fill="None"
|
|
||||||
size="300"
|
|
||||||
after={<Icon size="100" src={Icons.UserPlus} />}
|
|
||||||
radii="300"
|
|
||||||
aria-pressed={invitePrompt}
|
|
||||||
disabled={!canInvite}
|
|
||||||
>
|
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
||||||
Invite
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
onClick={handleCopyLink}
|
|
||||||
size="300"
|
|
||||||
after={<Icon size="100" src={Icons.Link} />}
|
|
||||||
radii="300"
|
|
||||||
>
|
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
||||||
Copy Link
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
onClick={handleRoomSettings}
|
|
||||||
size="300"
|
|
||||||
after={<Icon size="100" src={Icons.Setting} />}
|
|
||||||
radii="300"
|
|
||||||
>
|
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
||||||
Space Settings
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
{developerTools && (
|
|
||||||
<MenuItem
|
|
||||||
onClick={handleOpenTimeline}
|
|
||||||
size="300"
|
|
||||||
after={<Icon size="100" src={Icons.Terminal} />}
|
|
||||||
radii="300"
|
|
||||||
>
|
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
||||||
Event Timeline
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Line variant="Surface" size="300" />
|
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
||||||
<UseStateProvider initial={false}>
|
|
||||||
{(promptLeave, setPromptLeave) => (
|
|
||||||
<>
|
|
||||||
<MenuItem
|
|
||||||
onClick={() => setPromptLeave(true)}
|
|
||||||
variant="Critical"
|
|
||||||
fill="None"
|
|
||||||
size="300"
|
|
||||||
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
|
|
||||||
radii="300"
|
|
||||||
aria-pressed={promptLeave}
|
|
||||||
>
|
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
||||||
Leave Space
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
{promptLeave && (
|
|
||||||
<LeaveSpacePrompt
|
|
||||||
roomId={room.roomId}
|
|
||||||
onDone={requestClose}
|
|
||||||
onCancel={() => setPromptLeave(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</UseStateProvider>
|
|
||||||
</Box>
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function SpaceHeader() {
|
function SpaceHeader() {
|
||||||
const space = useSpace();
|
const space = useSpace();
|
||||||
@@ -298,7 +141,7 @@ function SpaceHeader() {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
|
<SpaceOptionsMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -455,7 +298,7 @@ export function Space() {
|
|||||||
|
|
||||||
const addSubRooms = (parentRoomId: string, baseDepth: number) => {
|
const addSubRooms = (parentRoomId: string, baseDepth: number) => {
|
||||||
const room = mx.getRoom(parentRoomId);
|
const room = mx.getRoom(parentRoomId);
|
||||||
if (!room || room.isSpaceRoom()) return;
|
if (!room || isSpace(room)) return;
|
||||||
|
|
||||||
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
|
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
|
||||||
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
|
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
|
||||||
@@ -484,7 +327,7 @@ export function Space() {
|
|||||||
|
|
||||||
// Add sub-rooms after each non-space room
|
// Add sub-rooms after each non-space room
|
||||||
const room = mx.getRoom(entry.roomId);
|
const room = mx.getRoom(entry.roomId);
|
||||||
if (room && !room.isSpaceRoom()) {
|
if (room && !isSpace(room)) {
|
||||||
addSubRooms(entry.roomId, 0);
|
addSubRooms(entry.roomId, 0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -506,6 +349,10 @@ export function Space() {
|
|||||||
const getToLink = (roomId: string) =>
|
const getToLink = (roomId: string) =>
|
||||||
getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
|
getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
|
||||||
|
|
||||||
|
if (shouldShowForumLobby(space)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageNav>
|
<PageNav>
|
||||||
<SpaceHeader />
|
<SpaceHeader />
|
||||||
@@ -616,7 +463,7 @@ export function Space() {
|
|||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
if (room.isSpaceRoom()) {
|
if (isSpace(room)) {
|
||||||
const categoryId = makeNavCategoryId(space.roomId, roomId);
|
const categoryId = makeNavCategoryId(space.roomId, roomId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -72,25 +72,101 @@ export const isDirectInvite = (room: Room | null, myUserId: string | null): bool
|
|||||||
return content?.is_direct === true;
|
return content?.is_direct === true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read room create type with a resilient fallback for rooms whose live timeline state
|
||||||
|
* does not currently expose m.room.create.
|
||||||
|
*/
|
||||||
|
const getRoomCreateType = (room: Room): string | undefined => {
|
||||||
|
const roomKindEvent = room.currentState?.getStateEvents(StateEvent.PaarrotRoomKind, '');
|
||||||
|
const roomKind = roomKindEvent?.getContent<{ kind?: string }>()?.kind;
|
||||||
|
if (roomKind === 'forum_space') {
|
||||||
|
return RoomType.Forum;
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveCreate = getStateEvent(room, StateEvent.RoomCreate);
|
||||||
|
const liveType = liveCreate?.getContent<{ type?: string }>()?.type;
|
||||||
|
if (typeof liveType === 'string') {
|
||||||
|
return liveType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateCreate = room.currentState?.getStateEvents(StateEvent.RoomCreate, '');
|
||||||
|
const stateType = stateCreate?.getContent<{ type?: string }>()?.type;
|
||||||
|
if (typeof stateType === 'string') {
|
||||||
|
return stateType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export const isSpace = (room: Room | null): boolean => {
|
export const isSpace = (room: Room | null): boolean => {
|
||||||
if (!room) return false;
|
if (!room) return false;
|
||||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
const roomType = getRoomCreateType(room);
|
||||||
if (!event) return false;
|
return roomType === RoomType.Space || roomType === RoomType.Forum;
|
||||||
return event.getContent().type === RoomType.Space;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isForum = (room: Room | null): boolean => {
|
||||||
|
if (!room) return false;
|
||||||
|
return getRoomCreateType(room) === RoomType.Forum;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPaarrotRoomKind = (room: Room | null): string | undefined => {
|
||||||
|
if (!room) return undefined;
|
||||||
|
const kind = room.currentState
|
||||||
|
?.getStateEvents(StateEvent.PaarrotRoomKind, '')
|
||||||
|
?.getContent<{ kind?: string }>()?.kind;
|
||||||
|
return typeof kind === 'string' ? kind : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* m.forum space root (forum board lobby), not a plain m.space or a lone m.forum topic channel.
|
||||||
|
* Matrix sets create type m.forum on forum spaces but SDK isSpaceRoom() is often still false;
|
||||||
|
* those rooms still have m.space.child entries like a normal space.
|
||||||
|
*/
|
||||||
|
export const isForumContainer = (room: Room | null): boolean => {
|
||||||
|
if (!room) return false;
|
||||||
|
if (getPaarrotRoomKind(room) === 'forum_space') return true;
|
||||||
|
if (getRoomCreateType(room) !== RoomType.Forum) return false;
|
||||||
|
if (room.isSpaceRoom()) return true;
|
||||||
|
return getSpaceChildren(room).length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @deprecated Use isForumContainer */
|
||||||
|
export const isForumSpace = isForumContainer;
|
||||||
|
|
||||||
|
export const shouldShowForumLobby = isForumContainer;
|
||||||
|
|
||||||
|
/** Nearest m.forum / forum_space ancestor for a topic room. */
|
||||||
|
export const findForumRootSpace = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
roomToParents: RoomToParents
|
||||||
|
): string | undefined => {
|
||||||
|
const ordered = [roomId, ...Array.from(getAllParents(roomToParents, roomId))];
|
||||||
|
for (const id of ordered) {
|
||||||
|
const room = mx.getRoom(id);
|
||||||
|
if (room && shouldShowForumLobby(room)) return id;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @deprecated Use findForumRootSpace */
|
||||||
|
export const findForumSpaceAncestor = findForumRootSpace;
|
||||||
|
|
||||||
export const isRoom = (room: Room | null): boolean => {
|
export const isRoom = (room: Room | null): boolean => {
|
||||||
if (!room) return false;
|
if (!room) return false;
|
||||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
const roomType = getRoomCreateType(room);
|
||||||
if (!event) return true;
|
if (!roomType) return true;
|
||||||
return event.getContent().type !== RoomType.Space;
|
return roomType !== RoomType.Space && roomType !== RoomType.Forum;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isUnsupportedRoom = (room: Room | null): boolean => {
|
export const isUnsupportedRoom = (room: Room | null): boolean => {
|
||||||
if (!room) return false;
|
if (!room) return false;
|
||||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
const roomType = getRoomCreateType(room);
|
||||||
if (!event) return true; // Consider room unsupported if m.room.create event doesn't exist
|
if (!roomType) return true; // Consider room unsupported if m.room.create event doesn't exist
|
||||||
return event.getContent().type !== undefined && event.getContent().type !== RoomType.Space;
|
return (
|
||||||
|
roomType !== RoomType.Space &&
|
||||||
|
roomType !== RoomType.Forum
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isValidChild(mEvent: MatrixEvent): boolean {
|
export function isValidChild(mEvent: MatrixEvent): boolean {
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export enum StateEvent {
|
|||||||
/** Sub-rooms: child rooms nested under this room */
|
/** Sub-rooms: child rooms nested under this room */
|
||||||
PaarrotSubRooms = 'im.paarrot.sub_rooms',
|
PaarrotSubRooms = 'im.paarrot.sub_rooms',
|
||||||
|
|
||||||
|
/** Paarrot room classification marker for custom room behaviors */
|
||||||
|
PaarrotRoomKind = 'im.paarrot.room.kind',
|
||||||
|
|
||||||
/** Matrix RTC call membership (MSC3401) */
|
/** Matrix RTC call membership (MSC3401) */
|
||||||
CallMember = 'org.matrix.msc3401.call.member',
|
CallMember = 'org.matrix.msc3401.call.member',
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,7 @@ export enum MessageEvent {
|
|||||||
|
|
||||||
export enum RoomType {
|
export enum RoomType {
|
||||||
Space = 'm.space',
|
Space = 'm.space',
|
||||||
|
Forum = 'm.forum',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MSpaceChildContent = {
|
export type MSpaceChildContent = {
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ export default defineConfig({
|
|||||||
port: 38347,
|
port: 38347,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
cors: true, // Enable CORS for dev server
|
cors: true, // Enable CORS for dev server
|
||||||
|
open: false,
|
||||||
fs: {
|
fs: {
|
||||||
// Allow serving files from one level up to the project root
|
// Allow serving files from one level up to the project root
|
||||||
allow: ['..'],
|
allow: ['..'],
|
||||||
|
|||||||
Reference in New Issue
Block a user