refactor: Consolidate icon imports across components and streamline import statements for improved readability
This commit is contained in:
@@ -2,24 +2,8 @@ import { useAtomValue } from 'jotai';
|
||||
import React, { ChangeEventHandler, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
MenuItem,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Scroll,
|
||||
Text,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Avatar, Box, Button, Header, IconButton, Input, MenuItem, Modal, Overlay, OverlayBackdrop, OverlayCenter, Scroll, Text, config } from 'folds';
|
||||
import { Icon, Icons } from '../components/icons';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
|
||||
import { allRoomsAtom } from '../state/room-list/roomList';
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
config,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { Avatar, Box, Button, config, Header, IconButton, Input, Menu, MenuItem, Modal, Overlay, OverlayBackdrop, OverlayCenter, Scroll, Spinner, Text } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import React, {
|
||||
ChangeEventHandler,
|
||||
MouseEventHandler,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider, color } from 'folds';
|
||||
import { Box, IconButton, Text, Tooltip, TooltipProvider, color } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useCall } from './useCall';
|
||||
import { useRoomCallMembers } from './useRoomCallMembers';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider, Scroll, color } from 'folds';
|
||||
import { Box, IconButton, Text, Tooltip, TooltipProvider, Scroll, color } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useCall } from './useCall';
|
||||
import { ScreenShareItem, ScreenShareEntry } from './CallOverlay';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* eslint-disable no-console */
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
|
||||
import { Box, Icon, IconButton, Icons, Text, color } from 'folds';
|
||||
import { Box, IconButton, Text, color } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useCall } from './useCall';
|
||||
import { CallType } from './types';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Text, Tooltip, TooltipProvider, color } from 'folds';
|
||||
import { Box, IconButton, Scroll, Text, Tooltip, TooltipProvider, color } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useCall } from './useCall';
|
||||
import { useRoomCallMembers } from './useRoomCallMembers';
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
IconButton,
|
||||
Icon,
|
||||
Icons,
|
||||
Scroll,
|
||||
Switch,
|
||||
Button,
|
||||
MenuItem,
|
||||
config,
|
||||
color,
|
||||
TextArea,
|
||||
Dialog,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Modal,
|
||||
} from 'folds';
|
||||
import { Box, Text, IconButton, Scroll, Switch, Button, MenuItem, config, color, TextArea, Dialog, Overlay, OverlayBackdrop, OverlayCenter, Modal } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Icon,
|
||||
Icons,
|
||||
IconButton,
|
||||
Text,
|
||||
config,
|
||||
Button,
|
||||
Spinner,
|
||||
color,
|
||||
TextArea as TextAreaComponent,
|
||||
Input,
|
||||
} from 'folds';
|
||||
import { Box, Chip, IconButton, Text, config, Button, Spinner, color, TextAreaComponent as TextArea, Input } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { Page, PageHeader } from '../../../components/page';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Icon,
|
||||
Icons,
|
||||
IconButton,
|
||||
Chip,
|
||||
Scroll,
|
||||
config,
|
||||
TextArea as TextAreaComponent,
|
||||
color,
|
||||
Spinner,
|
||||
Button,
|
||||
} from 'folds';
|
||||
import { Box, Text, IconButton, Chip, Scroll, config, TextAreaComponent as TextArea, color, Spinner, Button } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { Page, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||
import { Box, IconButton, Scroll, Text } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { ImagePack } from '../../../plugins/custom-emoji';
|
||||
import { ImagePackView } from '../../../components/image-pack-view';
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Button,
|
||||
Icon,
|
||||
Icons,
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
toRem,
|
||||
config,
|
||||
Input,
|
||||
Spinner,
|
||||
color,
|
||||
IconButton,
|
||||
Menu,
|
||||
} from 'folds';
|
||||
import { Box, Text, Button, Avatar, AvatarFallback, toRem, config, Input, Spinner, color, IconButton, Menu } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import {
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import React, { FormEventHandler, useCallback, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
color,
|
||||
config,
|
||||
Icon,
|
||||
Icons,
|
||||
Input,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
} from 'folds';
|
||||
import { Box, Button, Chip, color, config, Input, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
import React, { FormEventHandler, useCallback, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
color,
|
||||
config,
|
||||
Icon,
|
||||
Icons,
|
||||
Input,
|
||||
Spinner,
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { Badge, Box, Button, Checkbox, Chip, color, config, Input, Spinner, Text, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
color,
|
||||
config,
|
||||
Dialog,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Spinner,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { Badge, Box, Button, color, config, Dialog, Header, IconButton, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
color,
|
||||
config,
|
||||
Icon,
|
||||
Icons,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Spinner,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { Button, color, config, Menu, MenuItem, PopOut, RectCords, Spinner, Text } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { HistoryVisibility, MatrixError } from 'matrix-js-sdk';
|
||||
import { RoomHistoryVisibilityEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
color,
|
||||
Icon,
|
||||
Icons,
|
||||
Input,
|
||||
Spinner,
|
||||
Text,
|
||||
TextArea,
|
||||
} from 'folds';
|
||||
import { Avatar, Box, Button, Chip, color, Input, Spinner, Text, TextArea } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import Linkify from 'linkify-react';
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
color,
|
||||
Spinner,
|
||||
Text,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Dialog,
|
||||
Header,
|
||||
config,
|
||||
Box,
|
||||
IconButton,
|
||||
Icon,
|
||||
Icons,
|
||||
} from 'folds';
|
||||
import { Button, color, Spinner, Text, Overlay, OverlayBackdrop, OverlayCenter, Dialog, Header, config, Box, IconButton } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { MatrixError, Method } from 'matrix-js-sdk';
|
||||
import { RoomTombstoneEventContent } from 'matrix-js-sdk/lib/types';
|
||||
|
||||
@@ -6,21 +6,8 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
config,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { Box, Chip, config, IconButton, Input, PopOut, RectCords, Scroll, Spinner, Text, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { RoomMember } from 'matrix-js-sdk';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable react/no-array-index-key */
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Badge, Box, Button, Chip, config, Icon, Icons, Menu, Spinner, Text } from 'folds';
|
||||
import { Badge, Box, Button, Chip, config, Menu, Spinner, Text } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import produce from 'immer';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
import React, { FormEventHandler, MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Chip,
|
||||
Icon,
|
||||
Icons,
|
||||
IconButton,
|
||||
Scroll,
|
||||
Button,
|
||||
Input,
|
||||
RectCords,
|
||||
PopOut,
|
||||
Menu,
|
||||
config,
|
||||
Spinner,
|
||||
toRem,
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
} from 'folds';
|
||||
import { Box, Text, Chip, IconButton, Scroll, Button, Input, RectCords, PopOut, Menu, config, Spinner, toRem, TooltipProvider, Tooltip } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Box, Button, color, config, Icon, Icons, Input, Spinner, Switch, Text } from 'folds';
|
||||
import { Box, Button, color, config, Input, Spinner, Switch, Text } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import React, { FormEventHandler, useCallback, useState } from 'react';
|
||||
import { ICreateRoomStateEvent, MatrixError, Preset, Visibility } from 'matrix-js-sdk';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
color,
|
||||
config,
|
||||
Icon,
|
||||
Icons,
|
||||
Input,
|
||||
Spinner,
|
||||
Switch,
|
||||
Text,
|
||||
TextArea,
|
||||
} from 'folds';
|
||||
import { Box, Button, Chip, color, config, Input, Spinner, Switch, Text, TextArea } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { SettingTile } from '../../components/setting-tile';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import {
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
config,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Scroll,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { Box, config, Header, IconButton, Modal, Overlay, OverlayBackdrop, OverlayCenter, Scroll, Text } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
import React, { FormEventHandler, MouseEventHandler, useCallback, useEffect, useState } from 'react';
|
||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
color,
|
||||
config,
|
||||
Icon,
|
||||
Icons,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Spinner,
|
||||
Switch,
|
||||
Text,
|
||||
TextArea,
|
||||
} from 'folds';
|
||||
import { Box, Button, Chip, color, config, Input, Menu, MenuItem, PopOut, RectCords, Spinner, Switch, Text, TextArea } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { SettingTile } from '../../components/setting-tile';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import {
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
config,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Scroll,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { Box, config, Header, IconButton, Modal, Overlay, OverlayBackdrop, OverlayCenter, Scroll, Text } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
||||
import { SpaceProvider } from '../../hooks/useSpace';
|
||||
|
||||
@@ -67,11 +67,7 @@ export function ForumAuthorIdentity({
|
||||
<WrapTag className={theme.ForumSenderLabel} title={sender}>
|
||||
{name}
|
||||
</WrapTag>
|
||||
{server ? (
|
||||
<span className={theme.ForumAuthorServer} title="Matrix homeserver">
|
||||
{server}
|
||||
</span>
|
||||
) : null}
|
||||
{server ? <span className={theme.ForumAuthorServer}>{server}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,44 +1,14 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Box } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { useSpace } from '../../hooks/useSpace';
|
||||
import React from 'react';
|
||||
import { PageRoot } from '../../components/page';
|
||||
import { AnimatedOutlet } from '../../components/AnimatedOutlet';
|
||||
import { MobileFriendlyPageNav } from '../../pages/MobileFriendly';
|
||||
import { SPACE_PATH } from '../../pages/paths';
|
||||
import { Space } from '../../pages/client/space/Space';
|
||||
import { shouldShowForumLobby } from '../../utils/room';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import * as theme from './forumTheme.css';
|
||||
|
||||
const forumOutletShell: CSSProperties = {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
/**
|
||||
* Forum: server list only — forum fills main pane.
|
||||
* Other spaces: space sidebar + outlet.
|
||||
* Space sidebar + outlet. Forum spaces render ForumFeedSidebar in Space.tsx.
|
||||
*/
|
||||
export function ForumAwareSpaceLayout() {
|
||||
const space = useSpace();
|
||||
const isForum = shouldShowForumLobby(space);
|
||||
|
||||
if (isForum) {
|
||||
return (
|
||||
<Box grow="Yes" className={classNames(ContainerColor({ variant: 'Background' }), theme.ForumAppRoot)}>
|
||||
<div style={forumOutletShell}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageRoot
|
||||
nav={
|
||||
|
||||
@@ -1,38 +1,39 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Box, Icon, Icons, Text } from 'folds';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Scroll, Text, color } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import classNames from 'classnames';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Page, PageContent } from '../../components/page';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { ThreadView } from '../room/ThreadView';
|
||||
import { RoomInput } from '../room/RoomInput';
|
||||
import { useEditor } from '../../components/editor';
|
||||
import { Page, PageContent } from '../../components/page';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||
import { useForumBoardContext } from './ForumBoardContext';
|
||||
import * as css from './ForumBoardView.css';
|
||||
import { ForumThreadDetail } from './ForumThreadDetail';
|
||||
import type { ForumPost } from './types';
|
||||
import * as theme from './forumTheme.css';
|
||||
|
||||
/** Thread detail + composer (main pane); post list lives in ForumFeedSidebar. */
|
||||
/** Thread detail in the main pane; post list lives in ForumFeedSidebar. */
|
||||
export function ForumBoardDetail() {
|
||||
const mx = useMatrixClient();
|
||||
const editor = useEditor();
|
||||
const detailScrollRef = useRef<HTMLDivElement>(null);
|
||||
const [optimisticPost, setOptimisticPost] = useState<ForumPost | null>(null);
|
||||
|
||||
const {
|
||||
forumSpace,
|
||||
query,
|
||||
error,
|
||||
selectedPostId,
|
||||
activeTopicRoomId,
|
||||
error,
|
||||
threads,
|
||||
recordThreadReply,
|
||||
} = useForumBoardContext();
|
||||
|
||||
const activeTopicRoom = activeTopicRoomId ? mx.getRoom(activeTopicRoomId) : null;
|
||||
const topicPowerLevels = usePowerLevels(activeTopicRoom ?? forumSpace);
|
||||
const topicSelected = Boolean(query.topic);
|
||||
|
||||
const setActiveThreadId = useSetAtom(
|
||||
activeThreadIdAtomFamily(activeTopicRoomId ?? forumSpace.roomId)
|
||||
);
|
||||
|
||||
const topicSelected = Boolean(query.topic);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPostId && activeTopicRoomId) {
|
||||
setActiveThreadId(selectedPostId);
|
||||
@@ -42,51 +43,80 @@ export function ForumBoardDetail() {
|
||||
return undefined;
|
||||
}, [selectedPostId, activeTopicRoomId, setActiveThreadId]);
|
||||
|
||||
const showComposer = Boolean(activeTopicRoom && topicSelected);
|
||||
useEffect(() => {
|
||||
if (optimisticPost && optimisticPost.eventId !== selectedPostId) {
|
||||
setOptimisticPost(null);
|
||||
}
|
||||
}, [optimisticPost, selectedPostId]);
|
||||
|
||||
const selectedThread = useMemo(
|
||||
() => threads.find((t) => t.eventId === selectedPostId),
|
||||
[threads, selectedPostId]
|
||||
);
|
||||
|
||||
const handleReplySent = useCallback(
|
||||
(_reply: ForumPost) => {
|
||||
if (selectedPostId) {
|
||||
recordThreadReply(selectedPostId);
|
||||
}
|
||||
},
|
||||
[recordThreadReply, selectedPostId]
|
||||
);
|
||||
|
||||
const showThread = Boolean(selectedPostId && activeTopicRoom);
|
||||
const threadInitialPost =
|
||||
optimisticPost?.eventId === selectedPostId ? optimisticPost : null;
|
||||
const detailDropRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageContent>
|
||||
<Box className={css.ForumDetailOnlyRoot}>
|
||||
{error && (
|
||||
<Box style={{ padding: '0.5rem 1rem' }}>
|
||||
<Text style={{ color: 'var(--fc-critical, #f38ba8)' }}>{error}</Text>
|
||||
</Box>
|
||||
<Page className={classNames(ContainerColor({ variant: 'Background' }), theme.ForumAppRoot)}>
|
||||
<PageContent className={theme.ForumPageContent}>
|
||||
{error && (
|
||||
<Text as="p" style={{ color: color.Critical.Main, padding: '0.5rem 1rem' }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<aside className={theme.ForumDetailPane}>
|
||||
{showThread && activeTopicRoom ? (
|
||||
<div ref={detailDropRef} className={theme.ForumTopicPostDetailThreadLayout}>
|
||||
<ForumThreadDetail
|
||||
roomId={activeTopicRoom.roomId}
|
||||
rootEventId={selectedPostId!}
|
||||
titleHint={selectedThread?.title}
|
||||
initialPost={threadInitialPost}
|
||||
fileDropContainerRef={detailDropRef}
|
||||
onReplySent={handleReplySent}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Scroll
|
||||
id="topicPostDetail"
|
||||
className={theme.ForumTopicPostDetailScroll}
|
||||
variant="Surface"
|
||||
direction="Vertical"
|
||||
size="300"
|
||||
hideTrack
|
||||
visibility="Hover"
|
||||
>
|
||||
<div className={theme.ForumTopicPostDetailInner}>
|
||||
<Box
|
||||
className={theme.ForumEmptyDetail}
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="300"
|
||||
>
|
||||
<Icon src={Icons.Thread} size="600" />
|
||||
<Text size="T300" priority="300">
|
||||
{topicSelected
|
||||
? 'Select a post to read the thread'
|
||||
: 'Pick a category and topic, then choose a post'}
|
||||
</Text>
|
||||
</Box>
|
||||
</div>
|
||||
</Scroll>
|
||||
)}
|
||||
<Box ref={detailScrollRef} className={css.ForumDetailPanelFull}>
|
||||
{selectedPostId && activeTopicRoom ? (
|
||||
<PowerLevelsContextProvider value={topicPowerLevels}>
|
||||
<ThreadView room={activeTopicRoom} threadRootId={selectedPostId} />
|
||||
</PowerLevelsContextProvider>
|
||||
) : (
|
||||
<Box grow="Yes" alignItems="Center" justifyContent="Center" direction="Column" gap="200">
|
||||
<Icon src={Icons.Thread} size="600" />
|
||||
<Text priority="300">
|
||||
{topicSelected
|
||||
? 'Select a post from the list'
|
||||
: 'Choose a topic in the sidebar'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showComposer && activeTopicRoom && (
|
||||
<Box
|
||||
shrink="No"
|
||||
direction="Column"
|
||||
style={{ padding: '0.75rem', borderTop: '1px solid var(--fc-surface-container)' }}
|
||||
>
|
||||
<PowerLevelsContextProvider value={topicPowerLevels}>
|
||||
<RoomInput
|
||||
room={activeTopicRoom}
|
||||
editor={editor}
|
||||
roomId={activeTopicRoom.roomId}
|
||||
fileDropContainerRef={detailScrollRef}
|
||||
/>
|
||||
</PowerLevelsContextProvider>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</aside>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { style, globalStyle } from '@vanilla-extract/css';
|
||||
import { color, config } from 'folds';
|
||||
|
||||
const border = color.Surface.Container;
|
||||
@@ -292,6 +292,14 @@ export const ForumPageNav = style({
|
||||
width: 'min(400px, 38vw)',
|
||||
minWidth: '300px',
|
||||
maxWidth: '420px',
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
});
|
||||
|
||||
/** PageNav column must shrink so the feed list can scroll inside. */
|
||||
globalStyle(`${ForumPageNav} > div`, {
|
||||
minHeight: 0,
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
export const ForumFeedSidebar = style({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { Box, Icon, Icons, Text } from 'folds';
|
||||
import { Box, Text } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Page, PageContent } from '../../components/page';
|
||||
|
||||
@@ -2,7 +2,8 @@ import React, { KeyboardEventHandler, useCallback, useRef, useState } from 'reac
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { Editor } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { Icon, IconButton, Icons, Line, PopOut } from 'folds';
|
||||
import { IconButton, Line, PopOut } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import {
|
||||
CustomEditor,
|
||||
Toolbar,
|
||||
|
||||
64
src/app/features/forum/ForumEventContent.tsx
Normal file
64
src/app/features/forum/ForumEventContent.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { EventType, Room } from 'matrix-js-sdk';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
import { getEditedEvent } from '../../utils/room';
|
||||
import { GetContentCallback } from '../../../types/matrix/room';
|
||||
import { ForumMessageBody } from './ForumMessageBody';
|
||||
import { useForumMessageRenderOptions } from './useForumMessageRenderOptions';
|
||||
import * as theme from './forumTheme.css';
|
||||
|
||||
type ForumEventContentProps = {
|
||||
room: Room | null;
|
||||
eventId: string;
|
||||
body: string;
|
||||
bodyHtml?: string;
|
||||
senderDisplayName?: string;
|
||||
};
|
||||
|
||||
/** Renders a forum post/reply with the same media + rich text pipeline as room messages. */
|
||||
export function ForumEventContent({
|
||||
room,
|
||||
eventId,
|
||||
body,
|
||||
bodyHtml,
|
||||
senderDisplayName,
|
||||
}: ForumEventContentProps) {
|
||||
const renderOptions = useForumMessageRenderOptions(room);
|
||||
const event = room?.findEventById(eventId);
|
||||
const timelineSet = room?.getUnfilteredTimelineSet();
|
||||
|
||||
const editedEvent = useMemo(() => {
|
||||
if (!event || !timelineSet) return undefined;
|
||||
return getEditedEvent(eventId, event, timelineSet);
|
||||
}, [event, eventId, timelineSet]);
|
||||
|
||||
const getContent = useCallback<GetContentCallback>(
|
||||
() => editedEvent?.getContent()['m.new_content'] ?? event?.getContent() ?? { body, msgtype: 'm.text' },
|
||||
[editedEvent, event, body]
|
||||
);
|
||||
|
||||
if (!event || event.getType() !== EventType.RoomMessage) {
|
||||
return <ForumMessageBody body={body} bodyHtml={bodyHtml} />;
|
||||
}
|
||||
|
||||
const content = getContent();
|
||||
const msgType = typeof content.msgtype === 'string' ? content.msgtype : 'm.text';
|
||||
|
||||
return (
|
||||
<div className={theme.ForumEventContentRoot}>
|
||||
<RenderMessageContent
|
||||
displayName={senderDisplayName ?? event.getSender() ?? ''}
|
||||
msgType={msgType}
|
||||
ts={event.getTs()}
|
||||
edited={Boolean(editedEvent)}
|
||||
getContent={getContent}
|
||||
mediaAutoLoad={renderOptions.mediaAutoLoad}
|
||||
urlPreview={renderOptions.showUrlPreview}
|
||||
htmlReactParserOptions={renderOptions.htmlReactParserOptions}
|
||||
linkifyOpts={renderOptions.linkifyOpts}
|
||||
outlineAttachment={renderOptions.outlineAttachment}
|
||||
disabledEmbedPatterns={renderOptions.disabledEmbedPatterns}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +1,21 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Box, Text } from 'folds';
|
||||
import { Text, color } from 'folds';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getSpaceLobbyPath } from '../../pages/pathUtils';
|
||||
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
||||
import { useSpace } from '../../hooks/useSpace';
|
||||
import {
|
||||
NavCategory,
|
||||
NavCategoryHeader,
|
||||
NavItem,
|
||||
NavItemContent,
|
||||
NavLink,
|
||||
} from '../../components/nav';
|
||||
import { RoomNavCategoryButton } from '../room-nav';
|
||||
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
|
||||
import { makeNavCategoryId } from '../../state/closedNavCategories';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategories';
|
||||
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { PowerLevelsContextProvider } from '../../hooks/usePowerLevels';
|
||||
import { useForumBoardContext } from './ForumBoardContext';
|
||||
import type { ForumPublishedPost } from './types';
|
||||
import { ForumFilterBar } from './ForumFilterBar';
|
||||
import { ForumNewPostBox } from './ForumNewPostBox';
|
||||
import { ForumPostList } from './ForumPostList';
|
||||
import * as css from './ForumBoardView.css';
|
||||
import { ForumNewPostBox } from './ForumNewPostBox';
|
||||
import type { ForumPublishedPost } from './types';
|
||||
import * as theme from './forumTheme.css';
|
||||
|
||||
/**
|
||||
* matrixsso-style left column: category/topic nav + filters + post list.
|
||||
* Renders inside the space PageNav instead of Lobby / ROOMS.
|
||||
*/
|
||||
/** Categories, filters, and post list in the space sidebar (matrixsso-style). */
|
||||
export function ForumFeedSidebar() {
|
||||
const mx = useMatrixClient();
|
||||
const space = useSpace();
|
||||
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
|
||||
const lobbyPath = getSpaceLobbyPath(spaceIdOrAlias);
|
||||
|
||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
|
||||
closedCategories.has(categoryId)
|
||||
);
|
||||
|
||||
const {
|
||||
forumSpace,
|
||||
sections,
|
||||
threads,
|
||||
query,
|
||||
@@ -47,6 +24,7 @@ export function ForumFeedSidebar() {
|
||||
error,
|
||||
selectedPostId,
|
||||
setQueryParam,
|
||||
setCategory,
|
||||
selectPost,
|
||||
loadMorePosts,
|
||||
hasMorePosts,
|
||||
@@ -54,20 +32,7 @@ export function ForumFeedSidebar() {
|
||||
insertThreadFromNewPost,
|
||||
} = useForumBoardContext();
|
||||
|
||||
const topicSelected = Boolean(query.topic);
|
||||
const lobbyBase = lobbyPath;
|
||||
|
||||
const buildLobbyTo = useCallback(
|
||||
(params: Record<string, string | undefined>) => {
|
||||
const sp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v) sp.set(k, v);
|
||||
});
|
||||
const q = sp.toString();
|
||||
return q ? `${lobbyBase}?${q}` : lobbyBase;
|
||||
},
|
||||
[lobbyBase]
|
||||
);
|
||||
const spacePowerLevels = usePowerLevels(forumSpace);
|
||||
|
||||
const getTopicRoom = useCallback(
|
||||
(roomId: string | undefined) => (roomId ? mx.getRoom(roomId) : null),
|
||||
@@ -83,103 +48,44 @@ export function ForumFeedSidebar() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className={css.ForumFeedSidebar} direction="Column" gap="200">
|
||||
{error && (
|
||||
<Text size="T200" style={{ color: 'var(--fc-critical, #f38ba8)' }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<NavCategory>
|
||||
<NavItem variant="Background" radii="400" aria-selected={!query.topic && !query.category}>
|
||||
<NavLink to={buildLobbyTo({})}>
|
||||
<NavItemContent>
|
||||
<Text size="L400">All posts</Text>
|
||||
</NavItemContent>
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
</NavCategory>
|
||||
|
||||
{sections.map((section) => {
|
||||
const categoryId = makeNavCategoryId(space.roomId, `forum-${section.title}`);
|
||||
const closed = closedCategories.has(categoryId);
|
||||
const categoryActive = query.category === section.title;
|
||||
|
||||
return (
|
||||
<NavCategory key={section.title}>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
data-category-id={categoryId}
|
||||
onClick={handleCategoryClick}
|
||||
closed={closed}
|
||||
>
|
||||
{section.title}
|
||||
</RoomNavCategoryButton>
|
||||
</NavCategoryHeader>
|
||||
{!closed && (
|
||||
<>
|
||||
<NavItem variant="Background" radii="400" aria-selected={categoryActive && !query.topic}>
|
||||
<NavLink to={buildLobbyTo({ category: section.title })}>
|
||||
<NavItemContent>
|
||||
<Text size="T200" priority="300">
|
||||
All topics
|
||||
</Text>
|
||||
</NavItemContent>
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
{section.topics.map((topic) => (
|
||||
<NavItem
|
||||
key={topic.roomId}
|
||||
variant="Background"
|
||||
radii="400"
|
||||
aria-selected={query.topic === topic.roomId}
|
||||
>
|
||||
<NavLink to={buildLobbyTo({ category: section.title, topic: topic.roomId })}>
|
||||
<NavItemContent>
|
||||
<Text size="L400" truncate>
|
||||
{topic.name}
|
||||
</Text>
|
||||
</NavItemContent>
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</NavCategory>
|
||||
);
|
||||
})}
|
||||
|
||||
<Box className={css.ForumFiltersCard} shrink="No" style={{ padding: 0, border: 'none' }}>
|
||||
<ForumFilterBar
|
||||
sections={sections}
|
||||
threads={threads}
|
||||
query={query}
|
||||
hideCategoryTopic
|
||||
onQueryChange={setQueryParam}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box className={css.ForumListCard} grow="Yes" style={{ padding: 0, border: 'none', minHeight: 0 }}>
|
||||
<ForumNewPostBox
|
||||
sections={sections}
|
||||
postCount={threads.length}
|
||||
loading={loadingSections || loadingThreads}
|
||||
hasMore={hasMorePosts}
|
||||
defaultCategory={query.category}
|
||||
defaultTopicRoomId={query.topic}
|
||||
onPublished={handlePostPublished}
|
||||
/>
|
||||
<ForumPostList
|
||||
threads={threads}
|
||||
selectedPostId={selectedPostId}
|
||||
loading={loadingSections || loadingThreads}
|
||||
getTopicRoom={getTopicRoom}
|
||||
onSelectPost={selectPost}
|
||||
hasMore={hasMorePosts}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={loadMorePosts}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<PowerLevelsContextProvider value={spacePowerLevels}>
|
||||
<div className={theme.ForumFeedSidebarRoot}>
|
||||
<section className={theme.ForumFiltersCard}>
|
||||
<ForumFilterBar
|
||||
sections={sections}
|
||||
threads={threads}
|
||||
query={query}
|
||||
onQueryChange={setQueryParam}
|
||||
onCategoryChange={setCategory}
|
||||
/>
|
||||
</section>
|
||||
<section className={theme.ForumListCard}>
|
||||
{error && (
|
||||
<Text as="p" className={theme.ForumListLoading} style={{ color: color.Critical.Main }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<ForumNewPostBox
|
||||
sections={sections}
|
||||
postCount={threads.length}
|
||||
loading={loadingSections || loadingThreads}
|
||||
hasMore={hasMorePosts}
|
||||
defaultCategory={query.category}
|
||||
defaultTopicRoomId={query.topic}
|
||||
onPublished={handlePostPublished}
|
||||
/>
|
||||
<ForumPostList
|
||||
threads={threads}
|
||||
selectedPostId={selectedPostId}
|
||||
loading={loadingSections || loadingThreads}
|
||||
getTopicRoom={getTopicRoom}
|
||||
onSelectPost={selectPost}
|
||||
hasMore={hasMorePosts}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={loadMorePosts}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</PowerLevelsContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
||||
import { Icon, IconButton, Text, Tooltip, TooltipProvider } from 'folds';
|
||||
import { IconButton, Text, Tooltip, TooltipProvider } from 'folds';
|
||||
import { Icon } from '../../components/icons';
|
||||
import type { ForumBoardQuery, ForumSection, ForumThreadSummary } from './types';
|
||||
import { ForumSortIcons } from './forumLucideIcons';
|
||||
import { ForumTopicSearchInput } from './ForumTopicSearchInput';
|
||||
|
||||
@@ -1,29 +1,175 @@
|
||||
import React from 'react';
|
||||
import { Icon, Icons } from 'folds';
|
||||
import React, { MouseEventHandler, useEffect, useRef, useState } from 'react';
|
||||
import { Box, IconButton, Menu, PopOut } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { EmojiBoard } from '../../components/emoji-board';
|
||||
import { copyToClipboard } from '../../utils/dom';
|
||||
import { PluginButtonSlot } from '../settings/plugins/PluginButtonSlot';
|
||||
import { useForumMessageMenu } from './ForumMessageMenuContext';
|
||||
import * as theme from './forumTheme.css';
|
||||
|
||||
type ForumMessageActionsProps = {
|
||||
imagePackRooms: Room[];
|
||||
eventId?: string;
|
||||
body?: string;
|
||||
canSendReaction?: boolean;
|
||||
onReactionToggle?: (targetEventId: string, key: string, shortcode?: string) => void;
|
||||
onReply: () => void;
|
||||
replyActive?: boolean;
|
||||
onEdit?: () => void;
|
||||
canEdit?: boolean;
|
||||
onDelete?: () => void;
|
||||
canDelete?: boolean;
|
||||
};
|
||||
|
||||
export function ForumMessageActions({ onReply, replyActive }: ForumMessageActionsProps) {
|
||||
export function ForumMessageActions({
|
||||
imagePackRooms,
|
||||
eventId,
|
||||
body,
|
||||
canSendReaction,
|
||||
onReactionToggle,
|
||||
onReply,
|
||||
replyActive,
|
||||
onEdit,
|
||||
canEdit,
|
||||
onDelete,
|
||||
canDelete,
|
||||
}: ForumMessageActionsProps) {
|
||||
const menu = useForumMessageMenu();
|
||||
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<DOMRect>();
|
||||
const [shiftHeld, setShiftHeld] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') setShiftHeld(true);
|
||||
};
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') setShiftHeld(false);
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement> = () => {
|
||||
setEmojiBoardAnchor(emojiBtnRef.current?.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const target = evt.currentTarget.parentElement ?? evt.currentTarget;
|
||||
menu?.openMenu(target.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
evt.stopPropagation();
|
||||
onReply();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={theme.ForumMessageActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
replyActive
|
||||
? `${theme.ForumMessageActionBtn} ${theme.ForumMessageActionBtnActive}`
|
||||
: theme.ForumMessageActionBtn
|
||||
}
|
||||
onClick={onReply}
|
||||
<Menu className={theme.ForumMessageOptionsBar} variant="SurfaceVariant">
|
||||
<Box gap="100" direction="Row" alignItems="Center">
|
||||
{canSendReaction && eventId && onReactionToggle && (
|
||||
<PopOut
|
||||
position="Bottom"
|
||||
align="End"
|
||||
anchor={emojiBoardAnchor}
|
||||
content={
|
||||
<EmojiBoard
|
||||
imagePackRooms={imagePackRooms}
|
||||
returnFocusOnDeactivate={false}
|
||||
allowTextCustomEmoji
|
||||
onEmojiSelect={(key) => {
|
||||
onReactionToggle(eventId, key);
|
||||
setEmojiBoardAnchor(undefined);
|
||||
}}
|
||||
onCustomEmojiSelect={(mxc, shortcode) => {
|
||||
onReactionToggle(eventId, mxc, shortcode);
|
||||
setEmojiBoardAnchor(undefined);
|
||||
}}
|
||||
requestClose={() => setEmojiBoardAnchor(undefined)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
ref={emojiBtnRef}
|
||||
onClick={handleOpenEmojiBoard}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Add reaction"
|
||||
title="Add reaction"
|
||||
aria-pressed={!!emojiBoardAnchor}
|
||||
>
|
||||
<Icon src={Icons.SmilePlus} size="100" />
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={handleReplyClick}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Reply"
|
||||
title="Reply"
|
||||
aria-pressed={replyActive}
|
||||
className={replyActive ? theme.ForumMessageActionBtnActive : undefined}
|
||||
>
|
||||
<Icon className={theme.ForumMessageActionIcon} src={Icons.ReplyArrow} size="200" />
|
||||
</button>
|
||||
</div>
|
||||
<Icon src={Icons.ReplyArrow} size="100" />
|
||||
</IconButton>
|
||||
{canEdit && onEdit && (
|
||||
<IconButton
|
||||
onClick={onEdit}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Edit"
|
||||
title="Edit"
|
||||
>
|
||||
<Icon src={Icons.Pencil} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
{body && (
|
||||
<IconButton
|
||||
onClick={() => copyToClipboard(body)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Copy raw text"
|
||||
title="Copy raw text"
|
||||
>
|
||||
<Icon src={Icons.File} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
{shiftHeld && canDelete && onDelete && (
|
||||
<IconButton
|
||||
onClick={onDelete}
|
||||
variant="Critical"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Delete"
|
||||
title="Delete message (Shift held)"
|
||||
>
|
||||
<Icon src={Icons.Delete} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
<PluginButtonSlot location="message-actions" />
|
||||
{menu && (
|
||||
<IconButton
|
||||
onClick={handleOpenMenu}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Message menu"
|
||||
title="More actions"
|
||||
>
|
||||
<Icon src={Icons.VerticalDots} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
256
src/app/features/forum/ForumMessageContextMenu.tsx
Normal file
256
src/app/features/forum/ForumMessageContextMenu.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { MouseEventHandler, ReactNode, useCallback, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Box,
|
||||
Line,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { EmojiBoard } from '../../components/emoji-board';
|
||||
import {
|
||||
MessageAllReactionItem,
|
||||
MessageCopyLinkItem,
|
||||
MessageCopyRawTextItem,
|
||||
MessageDeleteItem,
|
||||
MessagePinItem,
|
||||
MessageQuickReactions,
|
||||
MessageReadReceiptItem,
|
||||
MessageReportItem,
|
||||
MessageSourceCodeItem,
|
||||
} from '../room/message/Message';
|
||||
import * as menuCss from '../room/message/styles.css';
|
||||
import { ForumMessageMenuProvider } from './ForumMessageMenuContext';
|
||||
import { useForumMessageReactions } from './useForumMessageReactions';
|
||||
|
||||
type ForumMessageContextMenuProps = {
|
||||
room: Room;
|
||||
mEvent: MatrixEvent | undefined;
|
||||
imagePackRooms: Room[];
|
||||
disabled?: boolean;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
onReply: () => void;
|
||||
onEdit?: () => void;
|
||||
onDeleted?: () => void;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function ForumMessageContextMenu({
|
||||
room,
|
||||
mEvent,
|
||||
imagePackRooms,
|
||||
disabled,
|
||||
canEdit,
|
||||
canDelete,
|
||||
onReply,
|
||||
onEdit,
|
||||
onDeleted,
|
||||
className,
|
||||
children,
|
||||
}: ForumMessageContextMenuProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||
|
||||
const {
|
||||
canSendReaction,
|
||||
canPinEvent,
|
||||
reactionRelations,
|
||||
hasReactions,
|
||||
handleReactionToggle,
|
||||
eventId,
|
||||
} = useForumMessageReactions(room, mEvent);
|
||||
|
||||
const openMenu = useCallback((anchor: RectCords) => {
|
||||
setMenuAnchor(anchor);
|
||||
}, []);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setMenuAnchor(undefined);
|
||||
}, []);
|
||||
|
||||
const handleContextMenu: MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
(evt) => {
|
||||
if (disabled || !mEvent || evt.altKey || !window.getSelection()?.isCollapsed) return;
|
||||
const tag = (evt.target as HTMLElement).tagName;
|
||||
if (tag.toLowerCase() === 'a' || tag.toLowerCase() === 'img') return;
|
||||
evt.preventDefault();
|
||||
setMenuAnchor({
|
||||
x: evt.clientX,
|
||||
y: evt.clientY,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
},
|
||||
[disabled, mEvent]
|
||||
);
|
||||
|
||||
const handleAddReactions: MouseEventHandler<HTMLButtonElement> = useCallback(() => {
|
||||
const rect = menuAnchor;
|
||||
closeMenu();
|
||||
setTimeout(() => {
|
||||
setEmojiBoardAnchor(rect);
|
||||
}, 100);
|
||||
}, [closeMenu, menuAnchor]);
|
||||
|
||||
if (!mEvent) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ForumMessageMenuProvider openMenu={openMenu}>
|
||||
<div className={className} onContextMenu={handleContextMenu}>
|
||||
{children}
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
|
||||
offset={menuAnchor?.width === 0 ? 0 : undefined}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: closeMenu,
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
{canSendReaction && (
|
||||
<MessageQuickReactions
|
||||
onReaction={(key, shortcode) => {
|
||||
if (eventId) handleReactionToggle(eventId, key, shortcode);
|
||||
closeMenu();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box direction="Column" gap="100" className={menuCss.MessageMenuGroup}>
|
||||
{canSendReaction && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.SmilePlus} />}
|
||||
radii="300"
|
||||
onClick={handleAddReactions}
|
||||
>
|
||||
<Text className={menuCss.MessageMenuItemText} as="span" size="T300" truncate>
|
||||
Add Reaction
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{hasReactions && reactionRelations && (
|
||||
<MessageAllReactionItem
|
||||
room={room}
|
||||
relations={reactionRelations}
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.ReplyArrow} />}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
onReply();
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text className={menuCss.MessageMenuItemText} as="span" size="T300" truncate>
|
||||
Reply
|
||||
</Text>
|
||||
</MenuItem>
|
||||
{canEdit && onEdit && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Pencil} />}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text className={menuCss.MessageMenuItemText} as="span" size="T300" truncate>
|
||||
Edit Message
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{!hideActivity && eventId && (
|
||||
<MessageReadReceiptItem room={room} eventId={eventId} onClose={closeMenu} />
|
||||
)}
|
||||
{developerTools && (
|
||||
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
)}
|
||||
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
<MessageCopyRawTextItem mEvent={mEvent} onClose={closeMenu} />
|
||||
{canPinEvent && (
|
||||
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
)}
|
||||
</Box>
|
||||
{((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && (
|
||||
<>
|
||||
<Line size="300" />
|
||||
<Box direction="Column" gap="100" className={menuCss.MessageMenuGroup}>
|
||||
{!mEvent.isRedacted() && canDelete && (
|
||||
<MessageDeleteItem
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
onClose={() => {
|
||||
closeMenu();
|
||||
onDeleted?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{mEvent.getSender() !== mx.getUserId() && (
|
||||
<MessageReportItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
{canSendReaction && (
|
||||
<PopOut
|
||||
position="Bottom"
|
||||
align={emojiBoardAnchor?.width === 0 ? 'Start' : 'End'}
|
||||
offset={emojiBoardAnchor?.width === 0 ? 0 : undefined}
|
||||
anchor={emojiBoardAnchor}
|
||||
content={
|
||||
<EmojiBoard
|
||||
imagePackRooms={imagePackRooms}
|
||||
returnFocusOnDeactivate={false}
|
||||
allowTextCustomEmoji
|
||||
onEmojiSelect={(key) => {
|
||||
if (eventId) handleReactionToggle(eventId, key);
|
||||
setEmojiBoardAnchor(undefined);
|
||||
}}
|
||||
onCustomEmojiSelect={(mxc, shortcode) => {
|
||||
if (eventId) handleReactionToggle(eventId, mxc, shortcode);
|
||||
setEmojiBoardAnchor(undefined);
|
||||
}}
|
||||
requestClose={() => setEmojiBoardAnchor(undefined)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span hidden aria-hidden />
|
||||
</PopOut>
|
||||
)}
|
||||
</div>
|
||||
</ForumMessageMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { useForumMessageReactions };
|
||||
26
src/app/features/forum/ForumMessageMenuContext.tsx
Normal file
26
src/app/features/forum/ForumMessageMenuContext.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { RectCords } from 'folds';
|
||||
|
||||
type ForumMessageMenuContextValue = {
|
||||
openMenu: (anchor: RectCords) => void;
|
||||
};
|
||||
|
||||
const ForumMessageMenuContext = createContext<ForumMessageMenuContextValue | null>(null);
|
||||
|
||||
export function ForumMessageMenuProvider({
|
||||
openMenu,
|
||||
children,
|
||||
}: {
|
||||
openMenu: (anchor: RectCords) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ForumMessageMenuContext.Provider value={{ openMenu }}>
|
||||
{children}
|
||||
</ForumMessageMenuContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useForumMessageMenu() {
|
||||
return useContext(ForumMessageMenuContext);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Box, Button, Icon, Icons, Text } from 'folds';
|
||||
import { Box, Button, Text } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import type { ForumPost, ForumPublishedPost } from './types';
|
||||
import type { ForumPost, ForumPublishedPost, ForumSection } from './types';
|
||||
import { ForumNewPostModal } from './ForumNewPostModal';
|
||||
import * as t from './forumTheme.css';
|
||||
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
import React, { FormEventHandler, KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Line,
|
||||
Scroll,
|
||||
Text,
|
||||
color,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { Box, Button, Header, IconButton, Modal, Overlay, OverlayBackdrop, OverlayCenter, Line, Scroll, Text, color, config, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import {
|
||||
CustomEditor,
|
||||
Toolbar,
|
||||
@@ -32,10 +16,11 @@ import {
|
||||
} from '../../components/editor';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { sendForumPost } from './forumFeed';
|
||||
import { sendForumPost, sendForumMediaRoot, sendForumThreadAttachment } from './forumFeed';
|
||||
import { FORUM_EDITOR_OUTPUT_OPTS } from './forumRichText';
|
||||
import type { ForumPublishedPost, ForumSection } from './types';
|
||||
import { ForumTopicTargetFields } from './ForumTopicTargetFields';
|
||||
import { ForumPostUploadField, useForumPostUploads } from './ForumPostUploadField';
|
||||
import * as t from './forumTheme.css';
|
||||
|
||||
type ForumNewPostModalProps = {
|
||||
@@ -61,6 +46,14 @@ export function ForumNewPostModal({
|
||||
const [topicRoomId, setTopicRoomId] = useState(initialTopicRoomId);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const uploadRoomKey = topicRoomId || '__forum_new_post_draft__';
|
||||
const {
|
||||
selectedFiles,
|
||||
uploadBoardHandlers,
|
||||
handleUploadsReady,
|
||||
collectUploadContents,
|
||||
clearUploads,
|
||||
} = useForumPostUploads(uploadRoomKey);
|
||||
|
||||
const categoryTopics = useMemo(() => {
|
||||
if (category) {
|
||||
@@ -80,8 +73,9 @@ export function ForumNewPostModal({
|
||||
if (sending) return;
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
clearUploads();
|
||||
onClose();
|
||||
}, [editor, onClose, sending]);
|
||||
}, [clearUploads, editor, onClose, sending]);
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
async (evt) => {
|
||||
@@ -94,6 +88,9 @@ export function ForumNewPostModal({
|
||||
toMatrixCustomHTML(editor.children, FORUM_EDITOR_OUTPUT_OPTS)
|
||||
);
|
||||
|
||||
const hasText = !isEmptyEditor(editor);
|
||||
const hasUploads = selectedFiles.length > 0;
|
||||
|
||||
if (!topicRoomId) {
|
||||
setError('Select a topic for this post.');
|
||||
return;
|
||||
@@ -102,27 +99,53 @@ export function ForumNewPostModal({
|
||||
setError('Post title is required.');
|
||||
return;
|
||||
}
|
||||
if (isEmptyEditor(editor)) {
|
||||
setError('Post body is required.');
|
||||
if (!hasText && !hasUploads) {
|
||||
setError('Post body or an attachment is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const eventId = await sendForumPost(mx, topicRoomId, {
|
||||
title: trimmedTitle,
|
||||
plainText,
|
||||
formattedHtml,
|
||||
});
|
||||
const attachmentContents = hasUploads ? await collectUploadContents() : [];
|
||||
let eventId: string;
|
||||
let publishedPlainText = plainText;
|
||||
let publishedHtml = formattedHtml;
|
||||
|
||||
if (hasText) {
|
||||
eventId = await sendForumPost(mx, topicRoomId, {
|
||||
title: trimmedTitle,
|
||||
plainText,
|
||||
formattedHtml,
|
||||
});
|
||||
} else {
|
||||
const [first, ...rest] = attachmentContents;
|
||||
if (!first) {
|
||||
throw new Error('Attachments failed to upload.');
|
||||
}
|
||||
eventId = await sendForumMediaRoot(mx, topicRoomId, trimmedTitle, first);
|
||||
publishedPlainText = typeof first.body === 'string' ? first.body : trimmedTitle;
|
||||
publishedHtml = '';
|
||||
for (const content of rest) {
|
||||
await sendForumThreadAttachment(mx, topicRoomId, eventId, content);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasText) {
|
||||
for (const content of attachmentContents) {
|
||||
await sendForumThreadAttachment(mx, topicRoomId, eventId, content);
|
||||
}
|
||||
}
|
||||
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
clearUploads();
|
||||
onPublished?.({
|
||||
eventId,
|
||||
topicRoomId,
|
||||
title: trimmedTitle,
|
||||
plainText,
|
||||
formattedHtml,
|
||||
plainText: publishedPlainText,
|
||||
formattedHtml: publishedHtml,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -131,7 +154,18 @@ export function ForumNewPostModal({
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[editor, mx, onClose, onPublished, sending, title, topicRoomId]
|
||||
[
|
||||
clearUploads,
|
||||
collectUploadContents,
|
||||
editor,
|
||||
mx,
|
||||
onClose,
|
||||
onPublished,
|
||||
selectedFiles.length,
|
||||
sending,
|
||||
title,
|
||||
topicRoomId,
|
||||
]
|
||||
);
|
||||
|
||||
const handleEditorKeyDown: KeyboardEventHandler = useCallback(
|
||||
@@ -230,7 +264,16 @@ export function ForumNewPostModal({
|
||||
bottom={
|
||||
<div>
|
||||
<Line variant="SurfaceVariant" size="300" />
|
||||
<Toolbar />
|
||||
<Box alignItems="Center" gap="200" style={{ padding: `0 ${config.space.S200}` }}>
|
||||
<ForumPostUploadField
|
||||
roomId={uploadRoomKey}
|
||||
disabled={sending || !topicRoomId}
|
||||
uploadBoardHandlers={uploadBoardHandlers}
|
||||
onUploadsReady={handleUploadsReady}
|
||||
/>
|
||||
<Box grow="Yes" />
|
||||
<Toolbar />
|
||||
</Box>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
|
||||
import React, { KeyboardEventHandler } from 'react';
|
||||
import { Box, Button, Scroll, Spinner, Text } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import type { ForumThreadSummary } from './types';
|
||||
import { formatForumTimeAgo } from './forumTime';
|
||||
@@ -38,7 +39,14 @@ export function ForumPostList({
|
||||
}: ForumPostListProps) {
|
||||
return (
|
||||
<>
|
||||
<div className={t.ForumPostScroll}>
|
||||
<Scroll
|
||||
className={t.ForumPostScroll}
|
||||
variant="Background"
|
||||
direction="Vertical"
|
||||
size="300"
|
||||
hideTrack
|
||||
visibility="Hover"
|
||||
>
|
||||
{loading && threads.length === 0 && (
|
||||
<Box alignItems="Center" gap="200" style={{ padding: '1rem' }}>
|
||||
<Spinner size="200" />
|
||||
@@ -56,14 +64,24 @@ export function ForumPostList({
|
||||
{threads.map((thread) => {
|
||||
const isActive = thread.eventId === selectedPostId;
|
||||
const topicRoom = getTopicRoom(thread.topicRoomId);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onSelectPost(thread.eventId, thread.topicRoomId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li key={thread.eventId} className={t.ForumPostItem}>
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={t.ForumPostLink}
|
||||
data-active={isActive ? 'true' : undefined}
|
||||
data-pinned={thread.isPinned ? 'true' : undefined}
|
||||
onClick={() => onSelectPost(thread.eventId, thread.topicRoomId)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className={t.ForumPostHead}>
|
||||
<div className={t.ForumPostHeadMain}>
|
||||
@@ -99,12 +117,12 @@ export function ForumPostList({
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</Scroll>
|
||||
{hasMore && onLoadMore && (
|
||||
<div className={t.ForumListFooter}>
|
||||
<Button
|
||||
|
||||
248
src/app/features/forum/ForumPostUploadField.tsx
Normal file
248
src/app/features/forum/ForumPostUploadField.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { MutableRefObject, useCallback, useRef, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { IContent, MatrixClient } from 'matrix-js-sdk';
|
||||
import { Box, IconButton, Scroll } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||
import { safeFile } from '../../utils/mimeTypes';
|
||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||
import {
|
||||
TUploadContent,
|
||||
TUploadItem,
|
||||
TUploadMetadata,
|
||||
roomIdToUploadItemsAtomFamily,
|
||||
roomUploadAtomFamily,
|
||||
} from '../../state/room/roomInputDrafts';
|
||||
import {
|
||||
Upload,
|
||||
UploadStatus,
|
||||
UploadSuccess,
|
||||
createUploadFamilyObserverAtom,
|
||||
} from '../../state/upload';
|
||||
import { UploadCardRenderer } from '../../components/upload-card';
|
||||
import {
|
||||
UploadBoard,
|
||||
UploadBoardContent,
|
||||
UploadBoardHeader,
|
||||
UploadBoardImperativeHandlers,
|
||||
} from '../../components/upload-board';
|
||||
import {
|
||||
getAudioMsgContent,
|
||||
getFileMsgContent,
|
||||
getImageMsgContent,
|
||||
getVideoMsgContent,
|
||||
} from '../room/msgContent';
|
||||
|
||||
export async function buildContentsFromUploads(
|
||||
mx: MatrixClient,
|
||||
items: TUploadItem[],
|
||||
uploads: UploadSuccess[]
|
||||
): Promise<IContent[]> {
|
||||
const contentsPromises = uploads.map(async (upload) => {
|
||||
const fileItem = items.find((f) => f.file === upload.file);
|
||||
if (!fileItem) throw new Error('Broken upload');
|
||||
|
||||
if (fileItem.file.type.startsWith('image')) {
|
||||
return getImageMsgContent(mx, fileItem, upload.mxc);
|
||||
}
|
||||
if (fileItem.file.type.startsWith('video')) {
|
||||
return getVideoMsgContent(mx, fileItem, upload.mxc);
|
||||
}
|
||||
if (fileItem.file.type.startsWith('audio')) {
|
||||
return getAudioMsgContent(fileItem, upload.mxc);
|
||||
}
|
||||
return getFileMsgContent(fileItem, upload.mxc);
|
||||
});
|
||||
return fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||||
}
|
||||
|
||||
type ForumPostUploadFieldProps = {
|
||||
roomId: string;
|
||||
disabled?: boolean;
|
||||
uploadBoardHandlers: MutableRefObject<UploadBoardImperativeHandlers | undefined>;
|
||||
onUploadsReady: (uploads: UploadSuccess[]) => Promise<void>;
|
||||
};
|
||||
|
||||
export function ForumPostUploadField({
|
||||
roomId,
|
||||
disabled,
|
||||
uploadBoardHandlers,
|
||||
onUploadsReady,
|
||||
}: ForumPostUploadFieldProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
|
||||
const [uploadBoard, setUploadBoard] = useState(true);
|
||||
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
|
||||
roomUploadAtomFamily,
|
||||
selectedFiles.map((f) => f.file)
|
||||
);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
if (disabled) return;
|
||||
const safe = files.map(safeFile).filter((f): f is File => f !== null);
|
||||
if (safe.length === 0) return;
|
||||
setSelectedFiles({
|
||||
type: 'PUT',
|
||||
item: safe.map((file) => ({
|
||||
file,
|
||||
originalFile: file,
|
||||
metadata: { markedAsSpoiler: false } satisfies TUploadMetadata,
|
||||
encInfo: undefined,
|
||||
})),
|
||||
});
|
||||
},
|
||||
[disabled, setSelectedFiles]
|
||||
);
|
||||
|
||||
const pickFiles = useFilePicker(handleFiles, true);
|
||||
|
||||
const handleFileMetadata = useCallback(
|
||||
(fileItem: TUploadItem, metadata: TUploadMetadata) => {
|
||||
setSelectedFiles({
|
||||
type: 'REPLACE',
|
||||
item: fileItem,
|
||||
replacement: { ...fileItem, metadata },
|
||||
});
|
||||
},
|
||||
[setSelectedFiles]
|
||||
);
|
||||
|
||||
const handleRemoveUpload = useCallback(
|
||||
(upload: TUploadContent | TUploadContent[]) => {
|
||||
const uploads = Array.isArray(upload) ? upload : [upload];
|
||||
setSelectedFiles({
|
||||
type: 'DELETE',
|
||||
item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)),
|
||||
});
|
||||
uploads.forEach((u) => roomUploadAtomFamily.remove(u));
|
||||
},
|
||||
[selectedFiles, setSelectedFiles]
|
||||
);
|
||||
|
||||
const handleCancelUpload = (uploads: Upload[]) => {
|
||||
uploads.forEach((upload) => {
|
||||
if (upload.status === UploadStatus.Loading) {
|
||||
mx.cancelUpload(upload.promise);
|
||||
}
|
||||
});
|
||||
handleRemoveUpload(uploads.map((upload) => upload.file));
|
||||
};
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
return (
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={pickFiles}
|
||||
disabled={disabled}
|
||||
aria-label="Attach files"
|
||||
>
|
||||
<Icon src={Icons.Attachment} size="200" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="200">
|
||||
<Box direction="Row" gap="200" alignItems="Center">
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={pickFiles}
|
||||
disabled={disabled}
|
||||
aria-label="Attach more files"
|
||||
>
|
||||
<Icon src={Icons.Attachment} size="200" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<UploadBoard
|
||||
header={
|
||||
<UploadBoardHeader
|
||||
open={uploadBoard}
|
||||
onToggle={() => setUploadBoard(!uploadBoard)}
|
||||
uploadFamilyObserverAtom={uploadFamilyObserverAtom}
|
||||
onSend={onUploadsReady}
|
||||
imperativeHandlerRef={uploadBoardHandlers}
|
||||
onCancel={handleCancelUpload}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{uploadBoard && (
|
||||
<Scroll size="300" hideTrack visibility="Hover">
|
||||
<UploadBoardContent>
|
||||
{Array.from(selectedFiles)
|
||||
.reverse()
|
||||
.map((fileItem, index) => (
|
||||
<UploadCardRenderer
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
isEncrypted={!!fileItem.encInfo}
|
||||
fileItem={fileItem}
|
||||
setMetadata={handleFileMetadata}
|
||||
onRemove={handleRemoveUpload}
|
||||
/>
|
||||
))}
|
||||
</UploadBoardContent>
|
||||
</Scroll>
|
||||
)}
|
||||
</UploadBoard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function useForumPostUploads(roomId: string) {
|
||||
const mx = useMatrixClient();
|
||||
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
|
||||
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
|
||||
const uploadResolverRef = useRef<{
|
||||
resolve: (contents: IContent[]) => void;
|
||||
reject: (err: unknown) => void;
|
||||
} | null>(null);
|
||||
|
||||
const clearUploads = useCallback(() => {
|
||||
if (selectedFiles.length > 0) {
|
||||
setSelectedFiles({ type: 'DELETE', item: selectedFiles });
|
||||
selectedFiles.forEach((item) => roomUploadAtomFamily.remove(item.file));
|
||||
}
|
||||
}, [selectedFiles, setSelectedFiles]);
|
||||
|
||||
const handleUploadsReady = useCallback(
|
||||
async (uploads: UploadSuccess[]) => {
|
||||
try {
|
||||
const contents = await buildContentsFromUploads(mx, selectedFiles, uploads);
|
||||
uploads.forEach((upload) => roomUploadAtomFamily.remove(upload.file));
|
||||
if (selectedFiles.length > 0) {
|
||||
setSelectedFiles({ type: 'DELETE', item: selectedFiles });
|
||||
}
|
||||
uploadResolverRef.current?.resolve(contents);
|
||||
} catch (err) {
|
||||
uploadResolverRef.current?.reject(err);
|
||||
} finally {
|
||||
uploadResolverRef.current = null;
|
||||
}
|
||||
},
|
||||
[mx, selectedFiles, setSelectedFiles]
|
||||
);
|
||||
|
||||
const collectUploadContents = useCallback(async (): Promise<IContent[]> => {
|
||||
if (selectedFiles.length === 0) return [];
|
||||
return new Promise<IContent[]>((resolve, reject) => {
|
||||
uploadResolverRef.current = { resolve, reject };
|
||||
uploadBoardHandlers.current?.handleSend().catch(reject);
|
||||
});
|
||||
}, [selectedFiles.length]);
|
||||
|
||||
return {
|
||||
selectedFiles,
|
||||
uploadBoardHandlers,
|
||||
handleUploadsReady,
|
||||
collectUploadContents,
|
||||
clearUploads,
|
||||
};
|
||||
}
|
||||
62
src/app/features/forum/ForumReplyComposer.tsx
Normal file
62
src/app/features/forum/ForumReplyComposer.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { RefObject, useCallback } from 'react';
|
||||
import { Editor } from 'slate';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Button, Text } from 'folds';
|
||||
import { resetEditor, resetEditorHistory } from '../../components/editor';
|
||||
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { RoomInput } from '../room/RoomInput';
|
||||
import * as t from './forumTheme.css';
|
||||
|
||||
type ForumReplyComposerProps = {
|
||||
editor: Editor;
|
||||
room: Room;
|
||||
roomId: string;
|
||||
threadRootId: string;
|
||||
replyToEventId: string;
|
||||
fileDropContainerRef: RefObject<HTMLElement>;
|
||||
onSent?: () => void;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
|
||||
/** Thread reply composer with uploads, emoji, and rich text (same as room messages). */
|
||||
export function ForumReplyComposer({
|
||||
editor,
|
||||
room,
|
||||
roomId,
|
||||
threadRootId,
|
||||
replyToEventId,
|
||||
fileDropContainerRef,
|
||||
onSent,
|
||||
onCancel,
|
||||
}: ForumReplyComposerProps) {
|
||||
const powerLevels = usePowerLevels(room);
|
||||
|
||||
const handleMessageSent = useCallback(() => {
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
onSent?.();
|
||||
}, [editor, onSent]);
|
||||
return (
|
||||
<div className={t.ForumFeedReplyForm}>
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
<RoomInput
|
||||
room={room}
|
||||
roomId={roomId}
|
||||
editor={editor}
|
||||
threadRootId={threadRootId}
|
||||
threadReplyToEventId={replyToEventId}
|
||||
hideReplyThreadIndicator
|
||||
fileDropContainerRef={fileDropContainerRef}
|
||||
onMessageSent={handleMessageSent}
|
||||
/>
|
||||
</PowerLevelsContextProvider>
|
||||
{onCancel && (
|
||||
<div className={t.ForumReplyFormActions}>
|
||||
<Button type="button" variant="SurfaceVariant" size="300" onClick={onCancel}>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Icon, Icons, Text, color } from 'folds';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Text, color, Scroll } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import classNames from 'classnames';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
@@ -99,6 +100,7 @@ export function ForumSpaceShell() {
|
||||
|
||||
const threadInitialPost =
|
||||
optimisticPost?.eventId === selectedPostId ? optimisticPost : null;
|
||||
const detailDropRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<PowerLevelsContextProvider value={spacePowerLevels}>
|
||||
@@ -149,32 +151,43 @@ export function ForumSpaceShell() {
|
||||
|
||||
<aside className={theme.ForumDetailPane}>
|
||||
{showThread && activeTopicRoom ? (
|
||||
<div id="topicPostDetail" className={theme.ForumTopicPostDetail}>
|
||||
<div ref={detailDropRef} className={theme.ForumTopicPostDetailThreadLayout}>
|
||||
<ForumThreadDetail
|
||||
roomId={activeTopicRoom.roomId}
|
||||
rootEventId={selectedPostId!}
|
||||
titleHint={selectedThread?.title}
|
||||
initialPost={threadInitialPost}
|
||||
fileDropContainerRef={detailDropRef}
|
||||
onReplySent={handleReplySent}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div id="topicPostDetail" className={theme.ForumTopicPostDetail}>
|
||||
<Box
|
||||
className={theme.ForumEmptyDetail}
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="300"
|
||||
>
|
||||
<Icon src={Icons.Thread} size="600" />
|
||||
<Text size="T300" priority="300">
|
||||
{topicSelected
|
||||
? 'Select a post to read the thread'
|
||||
: 'Pick a category and topic, then choose a post'}
|
||||
</Text>
|
||||
</Box>
|
||||
</div>
|
||||
<Scroll
|
||||
id="topicPostDetail"
|
||||
className={theme.ForumTopicPostDetailScroll}
|
||||
variant="Surface"
|
||||
direction="Vertical"
|
||||
size="300"
|
||||
hideTrack
|
||||
visibility="Hover"
|
||||
>
|
||||
<div className={theme.ForumTopicPostDetailInner}>
|
||||
<Box
|
||||
className={theme.ForumEmptyDetail}
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="300"
|
||||
>
|
||||
<Icon src={Icons.Thread} size="600" />
|
||||
<Text size="T300" priority="300">
|
||||
{topicSelected
|
||||
? 'Select a post to read the thread'
|
||||
: 'Pick a category and topic, then choose a post'}
|
||||
</Text>
|
||||
</Box>
|
||||
</div>
|
||||
</Scroll>
|
||||
)}
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
@@ -1,86 +1,281 @@
|
||||
// @refresh reset
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Spinner, Text } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode, type RefObject } from 'react';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { Box, Scroll, Spinner, Text, config } from 'folds';
|
||||
import { RelationType, Room } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||
import { canEditEvent, getEditedEvent } from '../../utils/room';
|
||||
import { MessageEditor } from '../room/message/MessageEditor';
|
||||
import { Reactions } from '../room/message';
|
||||
import { formatForumTimeAgo } from './forumTime';
|
||||
import { loadPostThread } from './forumFeed';
|
||||
import { appendReplyToThread } from './forumTopicHelpers';
|
||||
import { loadPostThread, loadPostThreadLocal } from './forumFeed';
|
||||
import { countAllReplies } from './forumTopicHelpers';
|
||||
import type { ForumPost } from './types';
|
||||
import { ForumAuthorIdentity } from './ForumAuthorIdentity';
|
||||
import { ForumMessageBody } from './ForumMessageBody';
|
||||
import { ForumEventContent } from './ForumEventContent';
|
||||
import { ForumMessageContextMenu } from './ForumMessageContextMenu';
|
||||
import { ForumMessageActions } from './ForumMessageActions';
|
||||
import { ForumRichReplyComposer } from './ForumRichReplyComposer';
|
||||
import { ForumReplyComposer } from './ForumReplyComposer';
|
||||
import { useForumMessageReactions } from './useForumMessageReactions';
|
||||
import { useForumRoomLiveUpdates } from './useForumRoomLiveUpdates';
|
||||
import { resetEditor, resetEditorHistory, useEditor } from '../../components/editor';
|
||||
import { mobileOrTablet } from '../../utils/user-agent';
|
||||
import * as theme from './forumTheme.css';
|
||||
|
||||
function ForumThreadReply({
|
||||
reply,
|
||||
room,
|
||||
threadRootId,
|
||||
roomId,
|
||||
depth,
|
||||
onReplySent,
|
||||
}: {
|
||||
reply: ForumPost;
|
||||
room: Room | null;
|
||||
threadRootId: string;
|
||||
roomId: string;
|
||||
depth: number;
|
||||
onReplySent: (reply: ForumPost) => void;
|
||||
}) {
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
function forumBranchKey(eventId: string): string {
|
||||
return `branch:${eventId}`;
|
||||
}
|
||||
|
||||
function forumMessageSurfaceClass(
|
||||
baseClass: string,
|
||||
eventId: string,
|
||||
replyTargetEventId?: string
|
||||
): string {
|
||||
if (replyTargetEventId && replyTargetEventId === eventId) {
|
||||
return `${baseClass} ${theme.ForumThreadReplyTarget}`;
|
||||
}
|
||||
return baseClass;
|
||||
}
|
||||
|
||||
type ForumThreadBranchProps = {
|
||||
className: string;
|
||||
railClassName: string;
|
||||
collapsed: boolean;
|
||||
hiddenCount: number;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function morePostsLabel(count: number): string {
|
||||
return `${count} more ${count === 1 ? 'post' : 'posts'}`;
|
||||
}
|
||||
|
||||
function ForumThreadBranch({
|
||||
className,
|
||||
railClassName,
|
||||
collapsed,
|
||||
hiddenCount,
|
||||
onToggle,
|
||||
children,
|
||||
}: ForumThreadBranchProps) {
|
||||
const isRootBranch = className === theme.ForumThread;
|
||||
|
||||
const handleToggle = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onToggle();
|
||||
};
|
||||
|
||||
if (collapsed && hiddenCount > 0) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
isRootBranch ? theme.ForumThreadCollapsedRowRoot : theme.ForumThreadCollapsedRowNested
|
||||
}
|
||||
aria-label={`Show ${morePostsLabel(hiddenCount)}`}
|
||||
title={`Show ${morePostsLabel(hiddenCount)}`}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
isRootBranch ? theme.ForumThreadCollapsedRailRoot : theme.ForumThreadCollapsedRailNested
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={theme.ForumThreadCollapsedMiniPost}>
|
||||
<span className={theme.ForumThreadCollapsedMoreLabel}>{morePostsLabel(hiddenCount)}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={theme.ForumThreadReply} data-event-id={reply.eventId}>
|
||||
<div className={theme.ForumThreadReplyInner}>
|
||||
<div className={`${className} ${theme.ForumThreadCollapsible}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={railClassName}
|
||||
aria-label="Collapse replies"
|
||||
title="Collapse replies"
|
||||
onClick={handleToggle}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ForumThreadMessageProps = {
|
||||
post: ForumPost;
|
||||
room: Room;
|
||||
roomId: string;
|
||||
threadRootId: string;
|
||||
fileDropContainerRef: RefObject<HTMLElement>;
|
||||
editEventId?: string;
|
||||
onEditEventId: (eventId?: string) => void;
|
||||
onComposerSent: () => void;
|
||||
onDeleted: () => void;
|
||||
onReplyTo: (eventId: string) => void;
|
||||
replyTargetEventId?: string;
|
||||
collapsedBranches: Set<string>;
|
||||
onToggleBranch: (branchKey: string) => void;
|
||||
};
|
||||
|
||||
function ForumThreadMessage({
|
||||
post,
|
||||
room,
|
||||
roomId,
|
||||
threadRootId,
|
||||
fileDropContainerRef,
|
||||
editEventId,
|
||||
onEditEventId,
|
||||
onComposerSent,
|
||||
onDeleted,
|
||||
onReplyTo,
|
||||
replyTargetEventId,
|
||||
collapsedBranches,
|
||||
onToggleBranch,
|
||||
}: ForumThreadMessageProps) {
|
||||
const mx = useMatrixClient();
|
||||
const mEvent = room.findEventById(post.eventId);
|
||||
const isEditing = editEventId === post.eventId;
|
||||
const branchKey = forumBranchKey(post.eventId);
|
||||
const replyCount = countAllReplies(post.replies);
|
||||
const repliesCollapsed = collapsedBranches.has(branchKey);
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const imagePackRooms = useImagePackRooms(roomId, roomToParents);
|
||||
|
||||
const canEdit = mEvent ? canEditEvent(mx, mEvent) : false;
|
||||
const canDelete = mEvent
|
||||
? permissions.action('redact', mx.getSafeUserId()) || mEvent.getSender() === mx.getUserId()
|
||||
: false;
|
||||
const { canSendReaction, reactionRelations, handleReactionToggle } = useForumMessageReactions(
|
||||
room,
|
||||
mEvent
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!mEvent) return;
|
||||
const eventId = mEvent.getId();
|
||||
if (!eventId) return;
|
||||
mx.redactEvent(roomId, eventId).then(onDeleted);
|
||||
}, [mEvent, mx, onDeleted, roomId]);
|
||||
|
||||
const handleEditClose = useCallback(() => {
|
||||
onEditEventId(undefined);
|
||||
onComposerSent();
|
||||
}, [onComposerSent, onEditEventId]);
|
||||
|
||||
return (
|
||||
<article className={theme.ForumThreadReply} data-event-id={post.eventId}>
|
||||
<ForumMessageContextMenu
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
disabled={isEditing}
|
||||
canEdit={canEdit}
|
||||
canDelete={canDelete}
|
||||
onReply={() => onReplyTo(post.eventId)}
|
||||
onEdit={() => onEditEventId(post.eventId)}
|
||||
onDeleted={onDeleted}
|
||||
className={forumMessageSurfaceClass(theme.ForumThreadReplyInner, post.eventId, replyTargetEventId)}
|
||||
>
|
||||
<header className={theme.ForumPostHeader}>
|
||||
<div className={theme.ForumPostHeaderMain}>
|
||||
<ForumAuthorIdentity room={room} sender={reply.sender} displayName={reply.senderDisplayName} />
|
||||
<time className={theme.ForumPostTime} dateTime={new Date(reply.timestamp).toISOString()}>
|
||||
{formatForumTimeAgo(reply.timestamp)}
|
||||
<ForumAuthorIdentity room={room} sender={post.sender} displayName={post.senderDisplayName} />
|
||||
<time className={theme.ForumPostTime} dateTime={new Date(post.timestamp).toISOString()}>
|
||||
{formatForumTimeAgo(post.timestamp)}
|
||||
</time>
|
||||
</div>
|
||||
</header>
|
||||
{reply.replyToDeleted && (
|
||||
{post.replyToDeleted && (
|
||||
<p className={theme.ForumReplyToDeleted} role="note">
|
||||
Replying to a deleted message
|
||||
</p>
|
||||
)}
|
||||
<ForumMessageBody body={reply.body} bodyHtml={reply.bodyHtml} />
|
||||
{!replyOpen && (
|
||||
<div className={theme.ForumReplyActionsFooter}>
|
||||
<ForumMessageActions onReply={() => setReplyOpen(true)} />
|
||||
</div>
|
||||
)}
|
||||
{replyOpen && (
|
||||
<div className={theme.ForumMessageActionPanels}>
|
||||
<ForumRichReplyComposer
|
||||
roomId={roomId}
|
||||
threadRootId={threadRootId}
|
||||
replyToEventId={reply.eventId}
|
||||
onSent={(sentReply) => {
|
||||
setReplyOpen(false);
|
||||
onReplySent(sentReply);
|
||||
}}
|
||||
onCancel={() => setReplyOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{reply.replies.length > 0 && (
|
||||
<div className={theme.ForumThreadChildren}>
|
||||
{reply.replies.map((child) => (
|
||||
<ForumThreadReply
|
||||
key={child.eventId}
|
||||
reply={child}
|
||||
{isEditing && mEvent ? (
|
||||
<MessageEditor
|
||||
room={room}
|
||||
roomId={roomId}
|
||||
mEvent={mEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
onCancel={handleEditClose}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ForumEventContent
|
||||
room={room}
|
||||
threadRootId={threadRootId}
|
||||
roomId={roomId}
|
||||
depth={depth + 1}
|
||||
onReplySent={onReplySent}
|
||||
eventId={post.eventId}
|
||||
body={post.body}
|
||||
bodyHtml={post.bodyHtml}
|
||||
senderDisplayName={post.senderDisplayName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{reactionRelations && (
|
||||
<Reactions
|
||||
style={{ marginTop: config.space.S200 }}
|
||||
room={room}
|
||||
relations={reactionRelations}
|
||||
mEventId={post.eventId}
|
||||
canSendReaction={canSendReaction}
|
||||
onReactionToggle={handleReactionToggle}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isEditing && (
|
||||
<div className={theme.ForumMessageOptionsBase}>
|
||||
<ForumMessageActions
|
||||
imagePackRooms={imagePackRooms}
|
||||
eventId={post.eventId}
|
||||
body={post.body}
|
||||
canSendReaction={canSendReaction}
|
||||
onReactionToggle={handleReactionToggle}
|
||||
onReply={() => onReplyTo(post.eventId)}
|
||||
replyActive={replyTargetEventId === post.eventId}
|
||||
onEdit={() => onEditEventId(post.eventId)}
|
||||
canEdit={canEdit}
|
||||
onDelete={handleDelete}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ForumMessageContextMenu>
|
||||
{post.replies.length > 0 && (
|
||||
<ForumThreadBranch
|
||||
className={theme.ForumThreadChildren}
|
||||
railClassName={theme.ForumThreadRailHitNested}
|
||||
collapsed={repliesCollapsed}
|
||||
hiddenCount={replyCount}
|
||||
onToggle={() => onToggleBranch(branchKey)}
|
||||
>
|
||||
{!repliesCollapsed &&
|
||||
post.replies.map((child) => (
|
||||
<ForumThreadMessage
|
||||
key={child.eventId}
|
||||
post={child}
|
||||
room={room}
|
||||
roomId={roomId}
|
||||
threadRootId={threadRootId}
|
||||
fileDropContainerRef={fileDropContainerRef}
|
||||
editEventId={editEventId}
|
||||
onEditEventId={onEditEventId}
|
||||
onComposerSent={onComposerSent}
|
||||
onDeleted={onDeleted}
|
||||
onReplyTo={onReplyTo}
|
||||
replyTargetEventId={replyTargetEventId}
|
||||
collapsedBranches={collapsedBranches}
|
||||
onToggleBranch={onToggleBranch}
|
||||
/>
|
||||
))}
|
||||
</ForumThreadBranch>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
@@ -91,31 +286,324 @@ type ForumThreadDetailProps = {
|
||||
rootEventId: string;
|
||||
titleHint?: string;
|
||||
initialPost?: ForumPost | null;
|
||||
fileDropContainerRef?: RefObject<HTMLElement>;
|
||||
onReplySent?: (reply: ForumPost) => void;
|
||||
};
|
||||
|
||||
type ForumThreadDetailBodyProps = ForumThreadDetailProps & {
|
||||
post: ForumPost;
|
||||
room: Room;
|
||||
onPostUpdate: (post: ForumPost | null) => void;
|
||||
};
|
||||
|
||||
function ForumThreadDetailBody({
|
||||
roomId,
|
||||
rootEventId,
|
||||
titleHint,
|
||||
post,
|
||||
room,
|
||||
fileDropContainerRef,
|
||||
onReplySent,
|
||||
onPostUpdate,
|
||||
}: ForumThreadDetailBodyProps) {
|
||||
const mx = useMatrixClient();
|
||||
const editor = useEditor();
|
||||
const [editEventId, setEditEventId] = useState<string | undefined>();
|
||||
const [replyToEventId, setReplyToEventId] = useState(rootEventId);
|
||||
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
|
||||
const timelineSet = useMemo(() => room.getUnfilteredTimelineSet(), [room]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setReplyToEventId(rootEventId);
|
||||
setReplyDraft(undefined);
|
||||
}, [rootEventId, setReplyDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!replyDraft) {
|
||||
setReplyToEventId(rootEventId);
|
||||
}
|
||||
}, [replyDraft, rootEventId]);
|
||||
const [contentRevision, setContentRevision] = useState(0);
|
||||
const [collapsedBranches, setCollapsedBranches] = useState<Set<string>>(() => new Set());
|
||||
const localDropRef = useRef<HTMLDivElement>(null);
|
||||
const dropRef = fileDropContainerRef ?? localDropRef;
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const imagePackRooms = useImagePackRooms(roomId, roomToParents);
|
||||
|
||||
const reloadThread = useCallback(async () => {
|
||||
const local = loadPostThreadLocal(mx, roomId, rootEventId);
|
||||
if (local) onPostUpdate(local);
|
||||
|
||||
const loaded = await loadPostThread(mx, roomId, rootEventId);
|
||||
onPostUpdate(loaded);
|
||||
return loaded;
|
||||
}, [mx, onPostUpdate, roomId, rootEventId]);
|
||||
|
||||
const handleComposerSent = useCallback(async () => {
|
||||
const applyLoaded = (loaded: ForumPost | null) => {
|
||||
if (!loaded) return;
|
||||
onReplySent?.({
|
||||
eventId: loaded.eventId,
|
||||
title: loaded.title,
|
||||
body: loaded.body,
|
||||
sender: loaded.sender,
|
||||
timestamp: Date.now(),
|
||||
replies: loaded.replies,
|
||||
});
|
||||
};
|
||||
|
||||
const loaded = await reloadThread();
|
||||
applyLoaded(loaded);
|
||||
|
||||
// Local echo can land slightly after send; refresh once more.
|
||||
window.setTimeout(() => {
|
||||
void reloadThread().then(applyLoaded);
|
||||
}, 400);
|
||||
}, [onReplySent, reloadThread]);
|
||||
|
||||
const handleDeleted = useCallback(() => {
|
||||
void handleComposerSent();
|
||||
}, [handleComposerSent]);
|
||||
|
||||
useForumRoomLiveUpdates(
|
||||
[room],
|
||||
() => {
|
||||
setContentRevision((revision) => revision + 1);
|
||||
void reloadThread();
|
||||
},
|
||||
{ threadRootId: rootEventId, wait: 150 }
|
||||
);
|
||||
|
||||
const rootEvent = room.findEventById(rootEventId);
|
||||
const rootEditing = editEventId === rootEventId;
|
||||
const canEditRoot = rootEvent ? canEditEvent(mx, rootEvent) : false;
|
||||
const canDeleteRoot = rootEvent
|
||||
? permissions.action('redact', mx.getSafeUserId()) || rootEvent.getSender() === mx.getUserId()
|
||||
: false;
|
||||
const { canSendReaction, reactionRelations, handleReactionToggle } = useForumMessageReactions(
|
||||
room,
|
||||
rootEvent
|
||||
);
|
||||
|
||||
const handleDeleteRoot = useCallback(() => {
|
||||
if (!rootEvent) return;
|
||||
const eventId = rootEvent.getId();
|
||||
if (!eventId) return;
|
||||
mx.redactEvent(roomId, eventId).then(() => {
|
||||
void handleComposerSent();
|
||||
});
|
||||
}, [handleComposerSent, mx, rootEvent, roomId]);
|
||||
|
||||
const handleEditRootClose = useCallback(() => {
|
||||
setEditEventId(undefined);
|
||||
setContentRevision((revision) => revision + 1);
|
||||
void handleComposerSent();
|
||||
}, [handleComposerSent]);
|
||||
|
||||
const toggleBranch = useCallback((branchKey: string) => {
|
||||
setCollapsedBranches((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(branchKey)) next.delete(branchKey);
|
||||
else next.add(branchKey);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleReplyTo = useCallback(
|
||||
(eventId: string) => {
|
||||
const replyEvt = room.findEventById(eventId);
|
||||
if (!replyEvt) return;
|
||||
|
||||
const editedReply = getEditedEvent(eventId, replyEvt, timelineSet);
|
||||
const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
||||
const { body, formatted_body: formattedBody } = content;
|
||||
const senderId = replyEvt.getSender();
|
||||
|
||||
setReplyToEventId(eventId);
|
||||
if (senderId && typeof body === 'string') {
|
||||
setReplyDraft({
|
||||
userId: senderId,
|
||||
eventId,
|
||||
body,
|
||||
formattedBody,
|
||||
relation: { rel_type: RelationType.Thread, event_id: rootEventId },
|
||||
});
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
const target = document.querySelector(`[data-event-id="${eventId}"]`);
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||
}, 0);
|
||||
},
|
||||
[editor, rootEventId, room, setReplyDraft, timelineSet]
|
||||
);
|
||||
|
||||
const rootBranchKey = forumBranchKey(rootEventId);
|
||||
const rootReplyCount = countAllReplies(post.replies);
|
||||
const rootRepliesCollapsed = collapsedBranches.has(rootBranchKey);
|
||||
|
||||
const replyTargetEventId = replyDraft?.eventId;
|
||||
|
||||
const title = useMemo(() => {
|
||||
const local = loadPostThreadLocal(mx, roomId, rootEventId);
|
||||
return local?.title || post.title || titleHint || '(untitled post)';
|
||||
}, [contentRevision, mx, post.title, roomId, rootEventId, titleHint]);
|
||||
|
||||
return (
|
||||
<article ref={localDropRef} className={theme.ForumTopicDetailRoot} data-event-id={post.eventId}>
|
||||
<Scroll
|
||||
className={theme.ForumTopicDetailBodyScroll}
|
||||
variant="Surface"
|
||||
direction="Vertical"
|
||||
size="300"
|
||||
hideTrack
|
||||
visibility="Hover"
|
||||
>
|
||||
<div className={theme.ForumTopicDetailBody}>
|
||||
<header className={theme.ForumTopicDetailHead}>
|
||||
<div className={theme.ForumTopicDetailHeadMain}>
|
||||
<h1 className={theme.ForumDetailTitle}>{title}</h1>
|
||||
<div className={theme.ForumTopicDetailAuthor}>
|
||||
<ForumAuthorIdentity room={room} sender={post.sender} displayName={post.senderDisplayName} />
|
||||
<span className={theme.ForumPostTime}>· {formatForumTimeAgo(post.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<ForumMessageContextMenu
|
||||
room={room}
|
||||
mEvent={rootEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
disabled={rootEditing}
|
||||
canEdit={canEditRoot}
|
||||
canDelete={canDeleteRoot}
|
||||
onReply={() => handleReplyTo(post.eventId)}
|
||||
onEdit={() => setEditEventId(rootEventId)}
|
||||
onDeleted={handleDeleted}
|
||||
className={forumMessageSurfaceClass(theme.ForumTopicDetailOp, post.eventId, replyTargetEventId)}
|
||||
>
|
||||
{rootEditing && rootEvent ? (
|
||||
<MessageEditor
|
||||
room={room}
|
||||
roomId={roomId}
|
||||
mEvent={rootEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
onCancel={handleEditRootClose}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ForumEventContent
|
||||
room={room}
|
||||
eventId={post.eventId}
|
||||
body={post.body}
|
||||
bodyHtml={post.bodyHtml}
|
||||
senderDisplayName={post.senderDisplayName}
|
||||
/>
|
||||
{reactionRelations && (
|
||||
<Reactions
|
||||
style={{ marginTop: config.space.S200 }}
|
||||
room={room}
|
||||
relations={reactionRelations}
|
||||
mEventId={post.eventId}
|
||||
canSendReaction={canSendReaction}
|
||||
onReactionToggle={handleReactionToggle}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!rootEditing && (
|
||||
<div className={theme.ForumMessageOptionsBase}>
|
||||
<ForumMessageActions
|
||||
imagePackRooms={imagePackRooms}
|
||||
eventId={post.eventId}
|
||||
body={post.body}
|
||||
canSendReaction={canSendReaction}
|
||||
onReactionToggle={handleReactionToggle}
|
||||
onReply={() => handleReplyTo(post.eventId)}
|
||||
replyActive={replyTargetEventId === post.eventId}
|
||||
onEdit={() => setEditEventId(rootEventId)}
|
||||
canEdit={canEditRoot}
|
||||
onDelete={handleDeleteRoot}
|
||||
canDelete={canDeleteRoot}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ForumMessageContextMenu>
|
||||
{post.replies.length > 0 && (
|
||||
<ForumThreadBranch
|
||||
className={theme.ForumThread}
|
||||
railClassName={theme.ForumThreadRailHitRoot}
|
||||
collapsed={rootRepliesCollapsed}
|
||||
hiddenCount={rootReplyCount}
|
||||
onToggle={() => toggleBranch(rootBranchKey)}
|
||||
>
|
||||
{!rootRepliesCollapsed &&
|
||||
post.replies.map((reply) => (
|
||||
<ForumThreadMessage
|
||||
key={reply.eventId}
|
||||
post={reply}
|
||||
room={room}
|
||||
roomId={roomId}
|
||||
threadRootId={rootEventId}
|
||||
fileDropContainerRef={dropRef}
|
||||
editEventId={editEventId}
|
||||
onEditEventId={setEditEventId}
|
||||
onComposerSent={() => {
|
||||
void handleComposerSent();
|
||||
}}
|
||||
onDeleted={handleDeleted}
|
||||
onReplyTo={handleReplyTo}
|
||||
replyTargetEventId={replyTargetEventId}
|
||||
collapsedBranches={collapsedBranches}
|
||||
onToggleBranch={toggleBranch}
|
||||
/>
|
||||
))}
|
||||
</ForumThreadBranch>
|
||||
)}
|
||||
</div>
|
||||
</Scroll>
|
||||
<div className={theme.ForumThreadFooterComposer}>
|
||||
<ForumReplyComposer
|
||||
editor={editor}
|
||||
room={room}
|
||||
roomId={roomId}
|
||||
threadRootId={rootEventId}
|
||||
replyToEventId={replyToEventId}
|
||||
fileDropContainerRef={dropRef}
|
||||
onSent={() => {
|
||||
void handleComposerSent();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForumThreadDetail({
|
||||
roomId,
|
||||
rootEventId,
|
||||
titleHint,
|
||||
initialPost,
|
||||
fileDropContainerRef,
|
||||
onReplySent,
|
||||
}: ForumThreadDetailProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [post, setPost] = useState<ForumPost | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rootReplyOpen, setRootReplyOpen] = useState(false);
|
||||
const room = mx.getRoom(roomId);
|
||||
|
||||
const handleReplySent = (reply: ForumPost) => {
|
||||
setPost((current) => {
|
||||
if (!current || !reply.replyToEventId) return current;
|
||||
return appendReplyToThread(current, reply.replyToEventId, reply);
|
||||
});
|
||||
onReplySent?.(reply);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -160,7 +648,7 @@ export function ForumThreadDetail({
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !post) {
|
||||
if (error || !post || !room) {
|
||||
return (
|
||||
<Text as="p" className={theme.ForumListLoading} size="T300" priority="300">
|
||||
{error || 'Post not found.'}
|
||||
@@ -168,56 +656,16 @@ export function ForumThreadDetail({
|
||||
);
|
||||
}
|
||||
|
||||
const title = post.title || titleHint || '(untitled post)';
|
||||
|
||||
return (
|
||||
<article className={theme.ForumTopicDetailRoot} data-event-id={post.eventId}>
|
||||
<header className={theme.ForumTopicDetailHead}>
|
||||
<div className={theme.ForumTopicDetailHeadMain}>
|
||||
<h1 className={theme.ForumDetailTitle}>{title}</h1>
|
||||
<div className={theme.ForumTopicDetailAuthor}>
|
||||
<ForumAuthorIdentity room={room} sender={post.sender} displayName={post.senderDisplayName} />
|
||||
<span className={theme.ForumPostTime}>· {formatForumTimeAgo(post.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className={theme.ForumTopicDetailOp}>
|
||||
<ForumMessageBody body={post.body} bodyHtml={post.bodyHtml} />
|
||||
{!rootReplyOpen && (
|
||||
<div className={theme.ForumReplyActionsFooter}>
|
||||
<ForumMessageActions onReply={() => setRootReplyOpen(true)} />
|
||||
</div>
|
||||
)}
|
||||
{rootReplyOpen && (
|
||||
<div className={theme.ForumMessageActionPanels}>
|
||||
<ForumRichReplyComposer
|
||||
roomId={roomId}
|
||||
threadRootId={rootEventId}
|
||||
replyToEventId={post.eventId}
|
||||
onSent={(sentReply) => {
|
||||
setRootReplyOpen(false);
|
||||
handleReplySent(sentReply);
|
||||
}}
|
||||
onCancel={() => setRootReplyOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{post.replies.length > 0 && (
|
||||
<div className={theme.ForumThread} id="topicReplyThread">
|
||||
{post.replies.map((reply) => (
|
||||
<ForumThreadReply
|
||||
key={reply.eventId}
|
||||
reply={reply}
|
||||
room={room}
|
||||
threadRootId={rootEventId}
|
||||
roomId={roomId}
|
||||
depth={0}
|
||||
onReplySent={handleReplySent}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
<ForumThreadDetailBody
|
||||
roomId={roomId}
|
||||
rootEventId={rootEventId}
|
||||
titleHint={titleHint}
|
||||
post={post}
|
||||
room={room}
|
||||
fileDropContainerRef={fileDropContainerRef}
|
||||
onReplySent={onReplySent}
|
||||
onPostUpdate={setPost}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Icon, IconButton, Icons } from 'folds';
|
||||
import { IconButton } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import {
|
||||
TOPIC_SEARCH_QUALIFIERS,
|
||||
collectAuthorHintsFromThreads,
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import {
|
||||
Direction,
|
||||
EventType,
|
||||
type IContent,
|
||||
type IEventRelation,
|
||||
type IRoomEvent,
|
||||
type MatrixClient,
|
||||
type MatrixEvent,
|
||||
type Room,
|
||||
RelationType,
|
||||
} from 'matrix-js-sdk';
|
||||
import type { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
|
||||
import { getMemberDisplayName, getSpaceChildren, isSpace } from '../../utils/room';
|
||||
import type { ForumPost, ForumTopic } from './types';
|
||||
import { annotatePostsWithRoomScope } from './forumTopicHelpers';
|
||||
import { bodyHtmlFromMessageContent } from './forumRichText';
|
||||
import { bodyHtmlFromMessageContent, titleFromFormattedBody } from './forumRichText';
|
||||
|
||||
type RawRelation = {
|
||||
rel_type?: string;
|
||||
@@ -24,6 +27,80 @@ function threadRelationFromContent(content: Record<string, unknown>): RawRelatio
|
||||
return relates as RawRelation;
|
||||
}
|
||||
|
||||
function isAnnotationRoomMessage(content: Record<string, unknown>): boolean {
|
||||
const relation = threadRelationFromContent(content);
|
||||
if (!relation?.rel_type) return false;
|
||||
return (
|
||||
relation.rel_type === RelationType.Replace ||
|
||||
relation.rel_type === 'm.replace' ||
|
||||
relation.rel_type === RelationType.Annotation ||
|
||||
relation.rel_type === 'm.annotation'
|
||||
);
|
||||
}
|
||||
|
||||
function isForumRootContent(content: Record<string, unknown>): boolean {
|
||||
if (typeof content['com.matrixsso.title'] === 'string') return true;
|
||||
const formatted = content.formatted_body;
|
||||
return typeof formatted === 'string' && /<h1[\s>]/i.test(formatted);
|
||||
}
|
||||
|
||||
function syncForumRootContentAfterEdit(
|
||||
merged: Record<string, unknown>,
|
||||
baseContent: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
if (!isForumRootContent(baseContent)) return merged;
|
||||
|
||||
const formattedTitle =
|
||||
typeof merged.formatted_body === 'string'
|
||||
? titleFromFormattedBody(merged.formatted_body)
|
||||
: undefined;
|
||||
const { title, body } = postTitleFromContent({
|
||||
...merged,
|
||||
// Ignore stale title so edits can rename the post.
|
||||
'com.matrixsso.title': formattedTitle ?? undefined,
|
||||
});
|
||||
|
||||
const next: Record<string, unknown> = {
|
||||
...merged,
|
||||
'com.matrixsso.title': title,
|
||||
};
|
||||
|
||||
if (typeof merged.body === 'string') {
|
||||
const plain = merged.body.trim();
|
||||
if (body && plain !== body && !plain.startsWith(`${title}\n`)) {
|
||||
next.body = `${title}\n\n${body}`;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function getLatestEditContent(
|
||||
event: IRoomEvent,
|
||||
timelineEvents: IRoomEvent[]
|
||||
): Record<string, unknown> {
|
||||
const baseContent = event.content as Record<string, unknown>;
|
||||
const targetId = event.event_id;
|
||||
if (!targetId) return baseContent;
|
||||
|
||||
const edits = timelineEvents
|
||||
.filter((candidate) => {
|
||||
if (candidate.type !== EventType.RoomMessage || !candidate.content) return false;
|
||||
const rel = threadRelationFromContent(candidate.content as Record<string, unknown>);
|
||||
return rel?.rel_type === RelationType.Replace && rel.event_id === targetId;
|
||||
})
|
||||
.sort((a, b) => (b.origin_server_ts || 0) - (a.origin_server_ts || 0));
|
||||
|
||||
const latestEdit = edits[0];
|
||||
if (!latestEdit?.content) return baseContent;
|
||||
|
||||
const newContent = (latestEdit.content as Record<string, unknown>)['m.new_content'];
|
||||
if (!newContent || typeof newContent !== 'object') return baseContent;
|
||||
|
||||
const merged = { ...baseContent, ...(newContent as Record<string, unknown>) };
|
||||
return syncForumRootContentAfterEdit(merged, baseContent);
|
||||
}
|
||||
|
||||
function parseBundledThreadReplyCount(event: IRoomEvent): number | undefined {
|
||||
const relations = event.unsigned?.['m.relations'] as Record<string, unknown> | undefined;
|
||||
const thread = relations?.['m.thread'] as { count?: unknown } | undefined;
|
||||
@@ -38,8 +115,12 @@ function postTitleFromContent(content: Record<string, unknown>): { title: string
|
||||
typeof content['com.matrixsso.title'] === 'string'
|
||||
? String(content['com.matrixsso.title']).trim()
|
||||
: '';
|
||||
const htmlTitle =
|
||||
typeof content.formatted_body === 'string'
|
||||
? titleFromFormattedBody(content.formatted_body)
|
||||
: undefined;
|
||||
const firstLine = rawBody.split(/\r?\n/, 1)[0]?.trim() ?? '';
|
||||
const title = (customTitle || firstLine || '(untitled post)').slice(0, 160);
|
||||
const title = (customTitle || htmlTitle || firstLine || '(untitled post)').slice(0, 160);
|
||||
|
||||
let body = rawBody;
|
||||
if (customTitle && rawBody.startsWith(customTitle)) {
|
||||
@@ -56,6 +137,11 @@ function postTitleFromContent(content: Record<string, unknown>): { title: string
|
||||
return { title, body: body || rawBody };
|
||||
}
|
||||
|
||||
/** Title + body for a forum matrix message payload (including edits). */
|
||||
export function forumPostTitleFromContent(content: Record<string, unknown>): string {
|
||||
return postTitleFromContent(content).title;
|
||||
}
|
||||
|
||||
function sortRepliesRecursively(posts: ForumPost[]) {
|
||||
posts.sort((a, b) => a.timestamp - b.timestamp);
|
||||
for (const post of posts) {
|
||||
@@ -77,7 +163,10 @@ function buildPosts(mx: MatrixClient, roomId: string, timelineEvents: IRoomEvent
|
||||
!redactedIds.has(event.event_id)
|
||||
)
|
||||
.map((event) => {
|
||||
const content = event.content as Record<string, unknown>;
|
||||
const rawContent = event.content as Record<string, unknown>;
|
||||
if (isAnnotationRoomMessage(rawContent)) return null;
|
||||
|
||||
const content = getLatestEditContent(event, timelineEvents);
|
||||
const relation = threadRelationFromContent(content);
|
||||
const isThreadReply =
|
||||
relation?.rel_type === RelationType.Thread || relation?.rel_type === 'm.thread';
|
||||
@@ -272,6 +361,9 @@ export async function listTopicFeedPosts(
|
||||
if (!nextBatch || merged.length >= minRoots) break;
|
||||
}
|
||||
|
||||
const localPosts = buildPosts(mx, roomId, collectLocalRootPosts(mx, roomId));
|
||||
merged = mergeRootPosts(merged, localPosts);
|
||||
|
||||
return { posts: merged, nextBatch: nextBatch ?? null };
|
||||
}
|
||||
|
||||
@@ -283,11 +375,164 @@ function mergeRootPosts(existing: ForumPost[], incoming: ForumPost[]): ForumPost
|
||||
byId.set(post.eventId, post);
|
||||
continue;
|
||||
}
|
||||
previous.replies = [...previous.replies, ...post.replies];
|
||||
const replyIds = new Set(previous.replies.map((reply) => reply.eventId));
|
||||
const mergedReplies = [
|
||||
...previous.replies,
|
||||
...post.replies.filter((reply) => !replyIds.has(reply.eventId)),
|
||||
];
|
||||
byId.set(post.eventId, { ...post, replies: mergedReplies });
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => b.timestamp - a.timestamp);
|
||||
}
|
||||
|
||||
function mergeUniqueRoomEvents(...lists: IRoomEvent[][]): IRoomEvent[] {
|
||||
const seen = new Set<string>();
|
||||
const unique: IRoomEvent[] = [];
|
||||
for (const list of lists) {
|
||||
for (const event of list) {
|
||||
const id = event.event_id;
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
unique.push(event);
|
||||
}
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function isSecondaryMatrixEvent(mEvent: MatrixEvent): boolean {
|
||||
const relation = mEvent.getRelation();
|
||||
if (
|
||||
relation?.rel_type === RelationType.Replace ||
|
||||
relation?.rel_type === RelationType.Annotation
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return isAnnotationRoomMessage(mEvent.getContent());
|
||||
}
|
||||
|
||||
function eventBelongsToThread(mEvent: MatrixEvent, rootEventId: string): boolean {
|
||||
if (mEvent.getId() === rootEventId) return true;
|
||||
|
||||
const relation = mEvent.getRelation();
|
||||
if (relation?.rel_type === RelationType.Thread && relation.event_id === rootEventId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const contentRelation = threadRelationFromContent(mEvent.getContent());
|
||||
if (
|
||||
contentRelation?.rel_type === RelationType.Thread &&
|
||||
contentRelation.event_id === rootEventId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function matrixEventToRoomEvent(mEvent: MatrixEvent): IRoomEvent {
|
||||
return mEvent.event as IRoomEvent;
|
||||
}
|
||||
|
||||
/** Thread events already in the local room timeline (includes unsent/local echo). */
|
||||
export function collectLocalThreadEvents(
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
rootEventId: string
|
||||
): IRoomEvent[] {
|
||||
const rootId = rootEventId.trim();
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!rootId || !room) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const events: IRoomEvent[] = [];
|
||||
|
||||
const add = (mEvent: MatrixEvent | undefined | null) => {
|
||||
if (!mEvent || mEvent.isRedacted()) return;
|
||||
if (mEvent.getType() !== EventType.RoomMessage) return;
|
||||
if (isSecondaryMatrixEvent(mEvent)) return;
|
||||
if (!eventBelongsToThread(mEvent, rootId)) return;
|
||||
const id = mEvent.getId();
|
||||
if (!id || seen.has(id)) return;
|
||||
seen.add(id);
|
||||
events.push(matrixEventToRoomEvent(mEvent));
|
||||
};
|
||||
|
||||
add(room.findEventById(rootId));
|
||||
|
||||
const thread = room.getThread(rootId);
|
||||
if (thread) {
|
||||
add(thread.rootEvent);
|
||||
for (const reply of thread.events) add(reply);
|
||||
for (const reply of thread.liveTimeline?.getEvents() ?? []) add(reply);
|
||||
}
|
||||
|
||||
for (const mEvent of room.getUnfilteredTimelineSet().getLiveTimeline().getEvents()) {
|
||||
add(mEvent);
|
||||
}
|
||||
|
||||
const targetIds = new Set(seen);
|
||||
events.push(...collectLocalReplaceEdits(room, targetIds));
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function collectLocalReplaceEdits(room: Room, targetIds: Set<string>): IRoomEvent[] {
|
||||
const events: IRoomEvent[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const mEvent of room.getUnfilteredTimelineSet().getLiveTimeline().getEvents()) {
|
||||
if (mEvent.isRedacted() || mEvent.getType() !== EventType.RoomMessage) continue;
|
||||
const relation = mEvent.getRelation();
|
||||
if (relation?.rel_type !== RelationType.Replace) continue;
|
||||
const targetId = relation.event_id;
|
||||
if (!targetId || !targetIds.has(targetId)) continue;
|
||||
const id = mEvent.getId();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
events.push(matrixEventToRoomEvent(mEvent));
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function collectLocalRootPosts(mx: MatrixClient, roomId: string): IRoomEvent[] {
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const events: IRoomEvent[] = [];
|
||||
|
||||
for (const mEvent of room.getUnfilteredTimelineSet().getLiveTimeline().getEvents()) {
|
||||
if (mEvent.isRedacted() || mEvent.getType() !== EventType.RoomMessage) continue;
|
||||
if (isSecondaryMatrixEvent(mEvent)) continue;
|
||||
const relation = threadRelationFromContent(mEvent.getContent());
|
||||
const isThreadReply =
|
||||
relation?.rel_type === RelationType.Thread || relation?.rel_type === 'm.thread';
|
||||
if (isThreadReply) continue;
|
||||
const id = mEvent.getId();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
events.push(matrixEventToRoomEvent(mEvent));
|
||||
}
|
||||
|
||||
const rootIds = new Set(events.map((event) => event.event_id).filter(Boolean) as string[]);
|
||||
events.push(...collectLocalReplaceEdits(room, rootIds));
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
export function loadPostThreadLocal(
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
rootEventId: string
|
||||
): ForumPost | null {
|
||||
const rootId = rootEventId.trim();
|
||||
const events = collectLocalThreadEvents(mx, roomId, rootId);
|
||||
if (events.length === 0) return null;
|
||||
const posts = buildPosts(mx, roomId, events);
|
||||
return posts.find((post) => post.eventId === rootId) ?? posts[0] ?? null;
|
||||
}
|
||||
|
||||
async function fetchRootEvent(
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
@@ -314,10 +559,10 @@ export async function loadPostThread(
|
||||
rootEventId: string
|
||||
): Promise<ForumPost | null> {
|
||||
const rootId = rootEventId.trim();
|
||||
const rootEvent = await fetchRootEvent(mx, roomId, rootId);
|
||||
if (!rootEvent?.event_id) return null;
|
||||
const localEvents = collectLocalThreadEvents(mx, roomId, rootId);
|
||||
|
||||
const events: IRoomEvent[] = [rootEvent];
|
||||
const rootEvent = await fetchRootEvent(mx, roomId, rootId);
|
||||
const apiEvents: IRoomEvent[] = rootEvent?.event_id ? [rootEvent] : [];
|
||||
|
||||
const collectRelations = async (
|
||||
relationType: RelationType | null,
|
||||
@@ -332,31 +577,32 @@ export async function loadPostThread(
|
||||
recurse: true,
|
||||
});
|
||||
if (relPayload.chunk?.length) {
|
||||
events.push(...(relPayload.chunk as IRoomEvent[]));
|
||||
apiEvents.push(...(relPayload.chunk as IRoomEvent[]));
|
||||
}
|
||||
from = relPayload.next_batch ?? undefined;
|
||||
if (!from) break;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await collectRelations(RelationType.Thread, EventType.RoomMessage);
|
||||
} catch {
|
||||
if (rootEvent?.event_id) {
|
||||
try {
|
||||
await collectRelations(null, EventType.RoomMessage);
|
||||
await collectRelations(RelationType.Replace, EventType.RoomMessage);
|
||||
} catch {
|
||||
// Still show root without replies.
|
||||
// Root may have no replace relations.
|
||||
}
|
||||
try {
|
||||
await collectRelations(RelationType.Thread, EventType.RoomMessage);
|
||||
} catch {
|
||||
try {
|
||||
await collectRelations(null, EventType.RoomMessage);
|
||||
} catch {
|
||||
// Still show root without replies.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const unique: IRoomEvent[] = [];
|
||||
for (const event of events) {
|
||||
const id = event.event_id;
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
unique.push(event);
|
||||
}
|
||||
const unique = mergeUniqueRoomEvents(localEvents, apiEvents);
|
||||
if (unique.length === 0) return null;
|
||||
|
||||
const posts = buildPosts(mx, roomId, unique);
|
||||
return posts.find((post) => post.eventId === rootId) ?? posts[0] ?? null;
|
||||
@@ -423,6 +669,54 @@ export async function sendForumPost(
|
||||
return res.event_id;
|
||||
}
|
||||
|
||||
/** Image/file/video root post with a forum title. */
|
||||
export async function sendForumMediaRoot(
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
title: string,
|
||||
content: IContent
|
||||
): Promise<string> {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!trimmedTitle) {
|
||||
throw new Error('Post title is required.');
|
||||
}
|
||||
if (trimmedTitle.length > 140) {
|
||||
throw new Error('Post title is too long.');
|
||||
}
|
||||
|
||||
const res = await mx.sendMessage(roomId, {
|
||||
...content,
|
||||
'com.matrixsso.title': trimmedTitle,
|
||||
});
|
||||
return res.event_id;
|
||||
}
|
||||
|
||||
/** Thread attachment (image, file, etc.) under a forum post root. */
|
||||
export async function sendForumThreadAttachment(
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
threadRootId: string,
|
||||
content: IContent,
|
||||
replyToEventId?: string
|
||||
): Promise<string> {
|
||||
const rootId = threadRootId.trim();
|
||||
const parentId = replyToEventId?.trim() || rootId;
|
||||
if (!rootId) {
|
||||
throw new Error('Thread root event id is required.');
|
||||
}
|
||||
|
||||
const res = await mx.sendMessage(roomId, {
|
||||
...content,
|
||||
'm.relates_to': {
|
||||
rel_type: RelationType.Thread,
|
||||
event_id: rootId,
|
||||
is_falling_back: false,
|
||||
'm.in_reply_to': { event_id: parentId },
|
||||
} as IEventRelation,
|
||||
});
|
||||
return res.event_id;
|
||||
}
|
||||
|
||||
export type ForumReplyPayload = {
|
||||
plainText: string;
|
||||
formattedHtml?: string;
|
||||
|
||||
@@ -7,8 +7,16 @@ export const FORUM_EDITOR_OUTPUT_OPTS: OutputOptions = {
|
||||
allowBlockMarkdown: true,
|
||||
};
|
||||
|
||||
export function titleFromFormattedBody(html: string | undefined): string | undefined {
|
||||
if (!html) return undefined;
|
||||
const match = html.trim().match(/^<h1[^>]*>([\s\S]*?)<\/h1>/i);
|
||||
if (!match) return undefined;
|
||||
const text = match[1].replace(/<[^>]+>/g, '').trim();
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
export function stripLeadingTitleHeading(html: string): string {
|
||||
return html.replace(/^<h1[^>]*>[\s\S]*?<\/h1>\s*/i, '').trim();
|
||||
return html.replace(/^\s*<h1[^>]*>[\s\S]*?<\/h1>\s*/i, '').trim();
|
||||
}
|
||||
|
||||
export function bodyHtmlFromMessageContent(
|
||||
|
||||
@@ -96,6 +96,20 @@ export const ForumLeftColumn = style([
|
||||
},
|
||||
]);
|
||||
|
||||
/** Feed column inside Cinny space PageNav. */
|
||||
export const ForumFeedSidebarRoot = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'flex',
|
||||
flex: '1 1 0',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
||||
export const ForumLeftHeader = style({
|
||||
flexShrink: 0,
|
||||
borderBottom: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
|
||||
@@ -126,6 +140,7 @@ export const ForumListCard = style({
|
||||
|
||||
export const ForumDetailPane = style({
|
||||
display: 'flex',
|
||||
flex: '1 1 0',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
@@ -144,18 +159,32 @@ export const ForumTopicTargetFields = style({
|
||||
/** @deprecated use ForumDetailPane */
|
||||
export const ForumDetailAside = ForumDetailPane;
|
||||
|
||||
export const ForumTopicPostDetail = style({
|
||||
/** Scroll slot for folds `Scroll` in forum panels (post list + thread detail). */
|
||||
export const ForumPanelScroll = style({
|
||||
flex: '1 1 0',
|
||||
minHeight: 0,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
overscrollBehavior: 'contain',
|
||||
minWidth: 0,
|
||||
});
|
||||
|
||||
export const ForumTopicPostDetailScroll = style([
|
||||
ForumPanelScroll,
|
||||
{
|
||||
backgroundColor: color.Surface.Container,
|
||||
},
|
||||
]);
|
||||
|
||||
export const ForumTopicPostDetailInner = style({
|
||||
boxSizing: 'border-box',
|
||||
padding: `0 ${forumDetailPad} ${forumDetailPad}`,
|
||||
paddingRight: forumDetailPadRight,
|
||||
backgroundColor: color.Surface.Container,
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
/** @deprecated use ForumTopicPostDetailScroll + ForumTopicPostDetailInner */
|
||||
export const ForumTopicPostDetail = style([
|
||||
ForumTopicPostDetailScroll,
|
||||
ForumTopicPostDetailInner,
|
||||
]);
|
||||
|
||||
export const ForumTopicPostDetailWithThread = style({});
|
||||
|
||||
export const ForumComposerBar = style({
|
||||
@@ -392,13 +421,7 @@ export const ForumPostCountMeta = style({
|
||||
minWidth: 0,
|
||||
});
|
||||
|
||||
export const ForumPostScroll = style({
|
||||
flex: '1 1 0',
|
||||
minHeight: 0,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
overscrollBehavior: 'contain',
|
||||
});
|
||||
export const ForumPostScroll = style([ForumPanelScroll]);
|
||||
|
||||
export const ForumPostList = style({
|
||||
display: 'grid',
|
||||
@@ -526,6 +549,148 @@ export const ForumCategoryLabel = style({
|
||||
textTransform: 'lowercase',
|
||||
});
|
||||
|
||||
const forumThreadRailHitBase = {
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
bottom: 0,
|
||||
cursor: 'pointer',
|
||||
left: 0,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'absolute' as const,
|
||||
top: 0,
|
||||
width: toRem(20),
|
||||
zIndex: 10,
|
||||
};
|
||||
|
||||
export const ForumThreadRailHitRoot = style({
|
||||
...forumThreadRailHitBase,
|
||||
selectors: {
|
||||
'&::after': {
|
||||
...threadLineBefore,
|
||||
background: threadLineBase,
|
||||
width: threadLineWidth,
|
||||
},
|
||||
'&:hover::after': {
|
||||
background: threadLineActive,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ForumThreadRailHitNested = style({
|
||||
...forumThreadRailHitBase,
|
||||
selectors: {
|
||||
'&::after': {
|
||||
...threadLineBefore,
|
||||
background: threadLineChild,
|
||||
width: threadLineWidth,
|
||||
},
|
||||
'&:hover::after': {
|
||||
background: threadLineActive,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const forumThreadCollapsedRowBase = {
|
||||
alignItems: 'stretch',
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
boxSizing: 'border-box' as const,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
gap: threadRailGap,
|
||||
marginBottom: threadLineGap,
|
||||
marginTop: threadLineGap,
|
||||
minWidth: 0,
|
||||
padding: 0,
|
||||
textAlign: 'left' as const,
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
export const ForumThreadCollapsedRowRoot = style([DefaultReset, forumThreadCollapsedRowBase]);
|
||||
|
||||
export const ForumThreadCollapsedRowNested = style([
|
||||
DefaultReset,
|
||||
forumThreadCollapsedRowBase,
|
||||
{
|
||||
marginLeft: threadLevelIndent,
|
||||
width: `calc(100% - ${threadLevelIndent})`,
|
||||
},
|
||||
]);
|
||||
|
||||
export const ForumThreadCollapsedRailRoot = style({
|
||||
alignSelf: 'stretch',
|
||||
background: threadLineBase,
|
||||
borderRadius: threadLineRadius,
|
||||
flexShrink: 0,
|
||||
minHeight: toRem(40),
|
||||
transition: 'background 0.15s ease',
|
||||
width: threadLineWidth,
|
||||
selectors: {
|
||||
[`${ForumThreadCollapsedRowRoot}:hover &`]: {
|
||||
background: threadLineActive,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ForumThreadCollapsedRailNested = style({
|
||||
alignSelf: 'stretch',
|
||||
background: threadLineChild,
|
||||
borderRadius: threadLineRadius,
|
||||
flexShrink: 0,
|
||||
minHeight: toRem(40),
|
||||
transition: 'background 0.15s ease',
|
||||
width: threadLineWidth,
|
||||
selectors: {
|
||||
[`${ForumThreadCollapsedRowNested}:hover &`]: {
|
||||
background: threadLineActive,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ForumThreadCollapsedMiniPost = style({
|
||||
background: color.Background.Container,
|
||||
border: `${config.borderWidth.B300} solid ${threadLineBase}`,
|
||||
borderRadius: toRem(6),
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
gap: toRem(6),
|
||||
justifyContent: 'center',
|
||||
minHeight: toRem(40),
|
||||
minWidth: 0,
|
||||
padding: `${toRem(9)} ${toRem(11)}`,
|
||||
transition: 'background 0.15s ease, border-color 0.15s ease',
|
||||
selectors: {
|
||||
[`${ForumThreadCollapsedRowRoot}:hover &, ${ForumThreadCollapsedRowNested}:hover &`]: {
|
||||
background: color.Surface.ContainerHover,
|
||||
borderColor: color.Primary.Main,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ForumThreadCollapsedMoreLabel = style({
|
||||
color: muted,
|
||||
fontSize: toRem(13),
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
selectors: {
|
||||
[`${ForumThreadCollapsedRowRoot}:hover &, ${ForumThreadCollapsedRowNested}:hover &`]: {
|
||||
color: color.Primary.Main,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ForumThreadCollapsible = style({
|
||||
selectors: {
|
||||
'&::before': {
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ForumReplyMeta = style({
|
||||
alignItems: 'center',
|
||||
display: 'inline-flex',
|
||||
@@ -574,11 +739,38 @@ export const ForumEmptyDetail = style({
|
||||
|
||||
export const ForumTopicDetailRoot = style({
|
||||
display: 'flex',
|
||||
flex: '1 1 0',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const ForumTopicDetailBodyScroll = style([
|
||||
ForumPanelScroll,
|
||||
{
|
||||
flex: '1 1 0',
|
||||
minHeight: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
export const ForumTopicDetailBody = style({
|
||||
boxSizing: 'border-box',
|
||||
padding: `0 ${forumDetailPad} ${forumDetailPad}`,
|
||||
paddingRight: forumDetailPadRight,
|
||||
});
|
||||
|
||||
export const ForumTopicPostDetailThreadLayout = style([
|
||||
ForumPanelScroll,
|
||||
{
|
||||
backgroundColor: color.Surface.Container,
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
export const ForumTopicDetailHead = style({
|
||||
alignItems: 'flex-start',
|
||||
background: color.Surface.Container,
|
||||
@@ -610,7 +802,16 @@ export const ForumTopicDetailOp = style({
|
||||
marginRight: 0,
|
||||
maxWidth: '100%',
|
||||
minWidth: 0,
|
||||
paddingBottom: toRem(36),
|
||||
paddingBottom: toRem(12),
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const ForumThreadFooterComposer = style({
|
||||
backgroundColor: color.Surface.Container,
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
boxSizing: 'border-box',
|
||||
flexShrink: 0,
|
||||
padding: `${toRem(12)} ${forumDetailPadRight} ${toRem(8)} ${forumDetailPad}`,
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
@@ -630,6 +831,11 @@ export const ForumMessageBodyRich = style({
|
||||
wordBreak: 'break-word',
|
||||
});
|
||||
|
||||
export const ForumEventContentRoot = style({
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
globalStyle(`${ForumMessageBodyRich} p`, {
|
||||
margin: '0 0 0.65em',
|
||||
});
|
||||
@@ -645,11 +851,35 @@ globalStyle(`${ForumMessageBodyRich} blockquote`, {
|
||||
color: muted,
|
||||
});
|
||||
|
||||
const forumNativeScroll = `${ForumTopicPostDetailInner} pre, ${ForumMessageBodyRich} pre`;
|
||||
|
||||
globalStyle(`${ForumMessageBodyRich} pre`, {
|
||||
background: color.SurfaceVariant.Container,
|
||||
borderRadius: config.radii.R300,
|
||||
overflow: 'auto',
|
||||
padding: config.space.S300,
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${color.Surface.ContainerLine} transparent`,
|
||||
});
|
||||
|
||||
globalStyle(`${forumNativeScroll}::-webkit-scrollbar`, {
|
||||
height: toRem(6),
|
||||
width: toRem(6),
|
||||
});
|
||||
|
||||
globalStyle(`${forumNativeScroll}::-webkit-scrollbar-track`, {
|
||||
background: 'transparent',
|
||||
});
|
||||
|
||||
globalStyle(`${forumNativeScroll}::-webkit-scrollbar-thumb`, {
|
||||
backgroundColor: color.Surface.ContainerLine,
|
||||
border: '2px solid transparent',
|
||||
backgroundClip: 'padding-box',
|
||||
borderRadius: config.radii.Pill,
|
||||
});
|
||||
|
||||
globalStyle(`${forumNativeScroll}::-webkit-scrollbar-thumb:hover`, {
|
||||
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||
});
|
||||
|
||||
globalStyle(`${ForumMessageBodyRich} code`, {
|
||||
@@ -672,28 +902,29 @@ globalStyle(`${ForumMessageBodyRich} img`, {
|
||||
borderRadius: config.radii.R300,
|
||||
});
|
||||
|
||||
export const ForumReplyActionsFooter = style({
|
||||
bottom: toRem(6),
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
margin: 0,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
right: toRem(10),
|
||||
zIndex: 2,
|
||||
maxWidth: `calc(100% - ${toRem(12)})`,
|
||||
});
|
||||
export const ForumMessageOptionsBase = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
top: toRem(6),
|
||||
right: toRem(6),
|
||||
zIndex: 3,
|
||||
// Extend hitbox for easier hover targeting (matches room messages).
|
||||
paddingLeft: toRem(80),
|
||||
marginLeft: toRem(-80),
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
export const ForumMessageActions = style({
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
gap: toRem(3),
|
||||
marginLeft: 'auto',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 0.16s ease',
|
||||
});
|
||||
export const ForumMessageOptionsBar = style([
|
||||
DefaultReset,
|
||||
{
|
||||
padding: config.space.S100,
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 0.12s ease',
|
||||
},
|
||||
]);
|
||||
|
||||
export const ForumMessageActionBtn = style({
|
||||
alignItems: 'center',
|
||||
@@ -751,6 +982,10 @@ export const ForumFeedReplyForm = style({
|
||||
marginTop: 0,
|
||||
});
|
||||
|
||||
export const ForumThreadReplyTarget = style({
|
||||
borderLeft: `3px solid ${color.Primary.Main}`,
|
||||
});
|
||||
|
||||
export const ForumReplyToDeleted = style({
|
||||
borderLeft: `4px solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
color: muted,
|
||||
@@ -1044,33 +1279,45 @@ globalStyle(`${ForumPostModalEditorWrap} .${editorCss.EditorTextarea}`, {
|
||||
minHeight: toRem(180),
|
||||
});
|
||||
|
||||
globalStyle(`${ForumThreadReplyInner}:hover ${ForumReplyActionsFooter} ${ForumMessageActions}`, {
|
||||
opacity: 1,
|
||||
pointerEvents: 'auto',
|
||||
});
|
||||
const forumMessageOptionsVisible = `${ForumMessageOptionsBase}:hover ${ForumMessageOptionsBar}, ${ForumMessageOptionsBase}:focus-within ${ForumMessageOptionsBar}`;
|
||||
|
||||
globalStyle(`${ForumThreadReplyInner}:focus-within ${ForumReplyActionsFooter} ${ForumMessageActions}`, {
|
||||
opacity: 1,
|
||||
pointerEvents: 'auto',
|
||||
});
|
||||
globalStyle(
|
||||
`${ForumThreadReplyInner}:hover ${ForumMessageOptionsBase}, ${ForumThreadReplyInner}:focus-within ${ForumMessageOptionsBase}`,
|
||||
{
|
||||
pointerEvents: 'auto',
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(`${ForumThreadReplyInner}:has(${ForumMessageActionPanels}) ${ForumReplyActionsFooter}`, {
|
||||
globalStyle(
|
||||
`${ForumThreadReplyInner}:hover ${ForumMessageOptionsBar}, ${ForumThreadReplyInner}:focus-within ${ForumMessageOptionsBar}, ${forumMessageOptionsVisible}`,
|
||||
{
|
||||
opacity: 1,
|
||||
pointerEvents: 'auto',
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(`${ForumThreadReplyInner}:has(${ForumMessageActionPanels}) ${ForumMessageOptionsBase}`, {
|
||||
display: 'none',
|
||||
});
|
||||
|
||||
globalStyle(`${ForumTopicDetailOp}:has(${ForumMessageActionPanels}) ${ForumReplyActionsFooter}`, {
|
||||
globalStyle(`${ForumTopicDetailOp}:has(${ForumMessageActionPanels}) ${ForumMessageOptionsBase}`, {
|
||||
display: 'none',
|
||||
});
|
||||
|
||||
globalStyle(`${ForumTopicDetailOp}:hover ${ForumReplyActionsFooter} ${ForumMessageActions}`, {
|
||||
opacity: 1,
|
||||
pointerEvents: 'auto',
|
||||
});
|
||||
globalStyle(
|
||||
`${ForumTopicDetailOp}:hover ${ForumMessageOptionsBase}, ${ForumTopicDetailOp}:focus-within ${ForumMessageOptionsBase}`,
|
||||
{
|
||||
pointerEvents: 'auto',
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(`${ForumTopicDetailOp}:focus-within ${ForumReplyActionsFooter} ${ForumMessageActions}`, {
|
||||
opacity: 1,
|
||||
pointerEvents: 'auto',
|
||||
});
|
||||
globalStyle(
|
||||
`${ForumTopicDetailOp}:hover ${ForumMessageOptionsBar}, ${ForumTopicDetailOp}:focus-within ${ForumMessageOptionsBar}, ${ForumTopicDetailOp} ${forumMessageOptionsVisible}`,
|
||||
{
|
||||
opacity: 1,
|
||||
pointerEvents: 'auto',
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(
|
||||
`${ForumThread}:has(> ${ForumThreadReply} > ${ForumThreadReplyInner}:hover)::before, ${ForumThread}:has(> ${ForumThreadReply} > ${ForumThreadReplyInner}:focus-within)::before`,
|
||||
@@ -1110,7 +1357,7 @@ globalStyle(`${ForumTopicDetailOp} ${ForumMessageBody}, ${ForumTopicDetailOp} ${
|
||||
|
||||
// Legacy aliases
|
||||
export const ForumApp = ForumTopicLiveApp;
|
||||
export const ForumDetailScroll = ForumTopicPostDetail;
|
||||
export const ForumDetailScroll = ForumTopicPostDetailScroll;
|
||||
export const ForumDetailComposerBar = ForumComposerBar;
|
||||
export const ForumChrome = style({ display: 'none' });
|
||||
export const ForumBrand = style({ display: 'none' });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { ForumBoardView } from './ForumBoardView';
|
||||
export { ForumFeedSidebar } from './ForumFeedSidebar';
|
||||
export { ForumBoardDetail } from './ForumBoardDetail';
|
||||
export { ForumSortIcons } from './forumLucideIcons';
|
||||
export { ForumSpaceShell } from './ForumSpaceShell';
|
||||
export { ForumThreadDetail } from './ForumThreadDetail';
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
||||
import { getSpaceLobbyPath } from '../../pages/pathUtils';
|
||||
import { createRoomModalAtom } from '../../state/createRoomModal';
|
||||
import { createSpaceModalAtom } from '../../state/createSpaceModal';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
@@ -18,7 +21,14 @@ import {
|
||||
listTopicFeedPosts,
|
||||
loadAggregatedTopicFeed,
|
||||
} from './forumFeed';
|
||||
import type { ForumBoardQuery, ForumPublishedPost, ForumSection, ForumThreadSummary } from './types';
|
||||
import type {
|
||||
ForumBoardQuery,
|
||||
ForumPost,
|
||||
ForumPublishedPost,
|
||||
ForumSection,
|
||||
ForumThreadSummary,
|
||||
} from './types';
|
||||
import { useForumRoomLiveUpdates } from './useForumRoomLiveUpdates';
|
||||
|
||||
export type ForumBoardScope = {
|
||||
forumSpaceId: string;
|
||||
@@ -62,17 +72,24 @@ function scopesFromSelection(
|
||||
|
||||
export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
|
||||
const mx = useMatrixClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
|
||||
const searchParams = useMemo(
|
||||
() => new URLSearchParams(location.search),
|
||||
[location.search]
|
||||
);
|
||||
|
||||
const filterQuery: ForumBoardQuery = useMemo(
|
||||
() => ({
|
||||
category: searchParams.get('category') || undefined,
|
||||
topic: scope.topicRoomId || searchParams.get('topic') || undefined,
|
||||
topic: searchParams.get('topic') || undefined,
|
||||
q: searchParams.get('q') || undefined,
|
||||
status: (searchParams.get('status') as ForumBoardQuery['status']) || 'all',
|
||||
sort: (searchParams.get('sort') as ForumBoardQuery['sort']) || 'hot',
|
||||
}),
|
||||
[searchParams, scope.topicRoomId]
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
const query: ForumBoardQuery = useMemo(
|
||||
@@ -92,41 +109,53 @@ export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
|
||||
const [postsNextBatch, setPostsNextBatch] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const setQueryParam = useCallback(
|
||||
(key: string, value: string | null) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (!value) next.delete(key);
|
||||
else next.set(key, value);
|
||||
if (key !== 'post') {
|
||||
next.delete('post');
|
||||
next.delete('postRoom');
|
||||
}
|
||||
return next;
|
||||
const applyForumSearchParams = useCallback(
|
||||
(mutate: (next: URLSearchParams) => void) => {
|
||||
const next = new URLSearchParams(location.search);
|
||||
mutate(next);
|
||||
const search = next.toString();
|
||||
|
||||
const onTopicRoomRoute =
|
||||
Boolean(selectedRoomId) && selectedRoomId !== forumSpace.roomId;
|
||||
|
||||
navigate(
|
||||
{
|
||||
pathname: onTopicRoomRoute
|
||||
? getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, forumSpace.roomId))
|
||||
: location.pathname,
|
||||
search: search ? `?${search}` : '',
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
},
|
||||
[setSearchParams]
|
||||
[location.pathname, location.search, navigate, mx, forumSpace.roomId, selectedRoomId]
|
||||
);
|
||||
|
||||
const setQueryParam = useCallback(
|
||||
(key: string, value: string | null) => {
|
||||
applyForumSearchParams((next) => {
|
||||
if (!value) next.delete(key);
|
||||
else next.set(key, value);
|
||||
if (key !== 'post') {
|
||||
next.delete('post');
|
||||
next.delete('postRoom');
|
||||
}
|
||||
});
|
||||
},
|
||||
[applyForumSearchParams]
|
||||
);
|
||||
|
||||
const setCategory = useCallback(
|
||||
(category: string | null) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (!category) next.delete('category');
|
||||
else next.set('category', category);
|
||||
next.delete('topic');
|
||||
next.delete('post');
|
||||
next.delete('postRoom');
|
||||
return next;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
applyForumSearchParams((next) => {
|
||||
if (!category) next.delete('category');
|
||||
else next.set('category', category);
|
||||
next.delete('topic');
|
||||
next.delete('post');
|
||||
next.delete('postRoom');
|
||||
});
|
||||
},
|
||||
[setSearchParams]
|
||||
[applyForumSearchParams]
|
||||
);
|
||||
|
||||
const loadSections = useCallback(async () => {
|
||||
@@ -142,8 +171,8 @@ export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
|
||||
}
|
||||
}, [mx, scope.forumSpaceId]);
|
||||
|
||||
const refreshThreads = useCallback(async () => {
|
||||
setLoadingThreads(true);
|
||||
const refreshThreads = useCallback(async (options?: { silent?: boolean }) => {
|
||||
if (!options?.silent) setLoadingThreads(true);
|
||||
setError(null);
|
||||
try {
|
||||
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
|
||||
@@ -172,7 +201,7 @@ export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load forum posts');
|
||||
} finally {
|
||||
setLoadingThreads(false);
|
||||
if (!options?.silent) setLoadingThreads(false);
|
||||
}
|
||||
}, [mx, sections, filterQuery]);
|
||||
|
||||
@@ -214,17 +243,29 @@ export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
|
||||
}, [createSpaceModal, createRoomModal, loadSections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadingSections) {
|
||||
refreshThreads();
|
||||
}
|
||||
}, [loadingSections, refreshThreads]);
|
||||
if (loadingSections) return;
|
||||
void refreshThreads();
|
||||
}, [
|
||||
loadingSections,
|
||||
refreshThreads,
|
||||
filterQuery.category,
|
||||
filterQuery.topic,
|
||||
filterQuery.q,
|
||||
filterQuery.status,
|
||||
filterQuery.sort,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
refreshThreads();
|
||||
}, 30_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refreshThreads]);
|
||||
const scopedRooms = useMemo(() => {
|
||||
if (loadingSections) return [];
|
||||
const roomScopes = scopesFromSelection(sections, filterQuery.category, filterQuery.topic);
|
||||
return roomScopes
|
||||
.map((scope) => mx.getRoom(scope.roomId))
|
||||
.filter((room): room is Room => room !== null);
|
||||
}, [loadingSections, sections, filterQuery.category, filterQuery.topic, mx]);
|
||||
|
||||
useForumRoomLiveUpdates(scopedRooms, () => {
|
||||
void refreshThreads({ silent: true });
|
||||
});
|
||||
|
||||
const selectedPostId = query.post ?? null;
|
||||
const selectedThread = threads.find((t) => t.eventId === selectedPostId);
|
||||
@@ -233,23 +274,18 @@ export function useForumBoard(scope: ForumBoardScope, forumSpace: Room) {
|
||||
|
||||
const selectPost = useCallback(
|
||||
(eventId: string | null, topicRoomId?: string) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (!eventId) {
|
||||
next.delete('post');
|
||||
next.delete('postRoom');
|
||||
} else {
|
||||
next.set('post', eventId);
|
||||
if (topicRoomId) next.set('postRoom', topicRoomId);
|
||||
else next.delete('postRoom');
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
applyForumSearchParams((next) => {
|
||||
if (!eventId) {
|
||||
next.delete('post');
|
||||
next.delete('postRoom');
|
||||
} else {
|
||||
next.set('post', eventId);
|
||||
if (topicRoomId) next.set('postRoom', topicRoomId);
|
||||
else next.delete('postRoom');
|
||||
}
|
||||
});
|
||||
},
|
||||
[setSearchParams]
|
||||
[applyForumSearchParams]
|
||||
);
|
||||
|
||||
const insertThreadFromNewPost = useCallback(
|
||||
|
||||
135
src/app/features/forum/useForumMessageReactions.ts
Normal file
135
src/app/features/forum/useForumMessageReactions.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
EventType,
|
||||
MatrixEvent,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||
import { MessageEvent } from '../../../types/matrix/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { getEventReactions, getReactionContent } from '../../utils/room';
|
||||
import { eventWithShortcode, factoryEventSentBy } from '../../utils/matrix';
|
||||
|
||||
export function useForumMessageReactions(room: Room, mEvent: MatrixEvent | undefined) {
|
||||
const mx = useMatrixClient();
|
||||
const [relationsRevision, setRelationsRevision] = useState(0);
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
|
||||
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
||||
const canPinEvent = permissions.stateEvent('m.room.pinned_events', mx.getSafeUserId());
|
||||
|
||||
const roomTimelineSet = room.getUnfilteredTimelineSet();
|
||||
const eventId = mEvent?.getId();
|
||||
|
||||
const bumpRelations = useCallback(() => {
|
||||
setRelationsRevision((revision) => revision + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventId) return undefined;
|
||||
|
||||
const isReactionForEvent = (matrixEvent: MatrixEvent) => {
|
||||
if (matrixEvent.getType() !== EventType.Reaction) return false;
|
||||
return matrixEvent.getRelation()?.event_id === eventId;
|
||||
};
|
||||
|
||||
const handleTimeline = (matrixEvent: MatrixEvent, eventRoom?: Room) => {
|
||||
if (eventRoom?.roomId !== room.roomId) return;
|
||||
if (isReactionForEvent(matrixEvent)) {
|
||||
bumpRelations();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRedaction = (_matrixEvent: MatrixEvent, eventRoom?: Room) => {
|
||||
if (eventRoom?.roomId !== room.roomId) return;
|
||||
bumpRelations();
|
||||
};
|
||||
|
||||
mx.on(RoomEvent.Timeline, handleTimeline);
|
||||
mx.on(RoomEvent.Redaction, handleRedaction);
|
||||
|
||||
return () => {
|
||||
mx.removeListener(RoomEvent.Timeline, handleTimeline);
|
||||
mx.removeListener(RoomEvent.Redaction, handleRedaction);
|
||||
};
|
||||
}, [bumpRelations, eventId, mx, room.roomId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventId) return undefined;
|
||||
|
||||
const relations = getEventReactions(roomTimelineSet, eventId);
|
||||
if (!relations) return undefined;
|
||||
|
||||
const handleRelationsUpdate = () => {
|
||||
bumpRelations();
|
||||
};
|
||||
|
||||
relations.on(RelationsEvent.Add, handleRelationsUpdate);
|
||||
relations.on(RelationsEvent.Redaction, handleRelationsUpdate);
|
||||
relations.on(RelationsEvent.Remove, handleRelationsUpdate);
|
||||
|
||||
return () => {
|
||||
relations.removeListener(RelationsEvent.Add, handleRelationsUpdate);
|
||||
relations.removeListener(RelationsEvent.Redaction, handleRelationsUpdate);
|
||||
relations.removeListener(RelationsEvent.Remove, handleRelationsUpdate);
|
||||
};
|
||||
}, [bumpRelations, eventId, relationsRevision, roomTimelineSet]);
|
||||
|
||||
const reactionRelations = useMemo(() => {
|
||||
if (!eventId) return undefined;
|
||||
return getEventReactions(roomTimelineSet, eventId);
|
||||
// relationsRevision forces re-read after timeline / relation updates
|
||||
}, [eventId, relationsRevision, roomTimelineSet]);
|
||||
|
||||
const hasReactions = Boolean(reactionRelations?.getSortedAnnotationsByKey()?.length);
|
||||
|
||||
const handleReactionToggle = useCallback(
|
||||
(targetEventId: string, key: string, shortcode?: string) => {
|
||||
const relations = getEventReactions(roomTimelineSet, targetEventId);
|
||||
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
|
||||
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
|
||||
const reactions = reactionsSet ? Array.from(reactionsSet) : [];
|
||||
const myReaction = reactions.find(factoryEventSentBy(mx.getSafeUserId()));
|
||||
|
||||
if (myReaction?.isRelation()) {
|
||||
const myReactionId = myReaction.getId();
|
||||
if (myReactionId) {
|
||||
mx.redactEvent(room.roomId, myReactionId).finally(() => {
|
||||
bumpRelations();
|
||||
window.setTimeout(bumpRelations, 250);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rShortcode =
|
||||
shortcode ||
|
||||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
|
||||
mx.sendEvent(
|
||||
room.roomId,
|
||||
MessageEvent.Reaction as never,
|
||||
getReactionContent(targetEventId, key, rShortcode)
|
||||
).finally(() => {
|
||||
bumpRelations();
|
||||
window.setTimeout(bumpRelations, 250);
|
||||
});
|
||||
},
|
||||
[bumpRelations, mx, room.roomId, roomTimelineSet]
|
||||
);
|
||||
|
||||
return {
|
||||
canSendReaction,
|
||||
canPinEvent,
|
||||
reactionRelations,
|
||||
hasReactions,
|
||||
handleReactionToggle,
|
||||
eventId,
|
||||
};
|
||||
}
|
||||
70
src/app/features/forum/useForumMessageRenderOptions.ts
Normal file
70
src/app/features/forum/useForumMessageRenderOptions.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
||||
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
||||
import {
|
||||
LINKIFY_OPTS,
|
||||
factoryRenderLinkifyWithMention,
|
||||
getReactCustomHtmlParser,
|
||||
makeMentionCustomProps,
|
||||
renderMatrixMention,
|
||||
} from '../../plugins/react-custom-html-parser';
|
||||
import {
|
||||
combineEmbedFilters,
|
||||
useRoomEmbedFilters,
|
||||
useRoomWideEmbedFilters,
|
||||
} from '../../hooks/useRoomEmbedFilters';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { MessageLayout, settingsAtom } from '../../state/settings';
|
||||
|
||||
export function useForumMessageRenderOptions(room: Room | null | undefined) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [showUrlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||||
|
||||
const [personalEmbedFilters] = useRoomEmbedFilters(room ?? undefined);
|
||||
const roomWideEmbedFilters = useRoomWideEmbedFilters(room ?? undefined);
|
||||
const combinedEmbedFilters = useMemo(
|
||||
() => combineEmbedFilters(personalEmbedFilters, roomWideEmbedFilters),
|
||||
[personalEmbedFilters, roomWideEmbedFilters]
|
||||
);
|
||||
|
||||
const mentionClickHandler = useMentionClickHandler(room?.roomId ?? '');
|
||||
const spoilerClickHandler = useSpoilerClickHandler();
|
||||
|
||||
const linkifyOpts = useMemo<LinkifyOpts>(
|
||||
() => ({
|
||||
...LINKIFY_OPTS,
|
||||
render: factoryRenderLinkifyWithMention((href) =>
|
||||
renderMatrixMention(mx, room?.roomId ?? '', href, makeMentionCustomProps(mentionClickHandler))
|
||||
),
|
||||
}),
|
||||
[mx, room?.roomId, mentionClickHandler]
|
||||
);
|
||||
|
||||
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
|
||||
() =>
|
||||
getReactCustomHtmlParser(mx, room?.roomId ?? '', {
|
||||
linkifyOpts,
|
||||
useAuthentication,
|
||||
handleSpoilerClick: spoilerClickHandler,
|
||||
handleMentionClick: mentionClickHandler,
|
||||
}),
|
||||
[mx, room?.roomId, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication]
|
||||
);
|
||||
|
||||
return {
|
||||
linkifyOpts,
|
||||
htmlReactParserOptions,
|
||||
mediaAutoLoad,
|
||||
showUrlPreview,
|
||||
outlineAttachment: messageLayout === MessageLayout.Bubble,
|
||||
disabledEmbedPatterns: combinedEmbedFilters,
|
||||
};
|
||||
}
|
||||
114
src/app/features/forum/useForumRoomLiveUpdates.ts
Normal file
114
src/app/features/forum/useForumRoomLiveUpdates.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
EventType,
|
||||
MatrixEvent,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
||||
type ForumRoomLiveOptions = {
|
||||
/** When set, only sync for events in this thread root. */
|
||||
threadRootId?: string;
|
||||
/** Debounce window in ms. */
|
||||
wait?: number;
|
||||
};
|
||||
|
||||
function isForumThreadEvent(mEvent: MatrixEvent, threadRootId: string): boolean {
|
||||
if (mEvent.getId() === threadRootId) return true;
|
||||
|
||||
const relation = mEvent.getRelation();
|
||||
if (relation?.rel_type === RelationType.Thread && relation.event_id === threadRootId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const content = mEvent.getContent();
|
||||
const relates = content['m.relates_to'];
|
||||
if (relates && typeof relates === 'object') {
|
||||
const rel = relates as { rel_type?: string; event_id?: string };
|
||||
if (rel.rel_type === RelationType.Thread && rel.event_id === threadRootId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (relation?.rel_type === RelationType.Replace && relation.event_id === threadRootId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSecondaryMatrixEvent(mEvent: MatrixEvent): boolean {
|
||||
const relation = mEvent.getRelation();
|
||||
return (
|
||||
relation?.rel_type === RelationType.Replace ||
|
||||
relation?.rel_type === RelationType.Annotation
|
||||
);
|
||||
}
|
||||
|
||||
function isForumFeedEvent(mEvent: MatrixEvent): boolean {
|
||||
const type = mEvent.getType();
|
||||
return type === EventType.RoomMessage || type === EventType.RoomMessageEncrypted;
|
||||
}
|
||||
|
||||
function isRelevantEvent(mEvent: MatrixEvent, threadRootId?: string): boolean {
|
||||
if (mEvent.getType() === EventType.Reaction) return false;
|
||||
if (isSecondaryMatrixEvent(mEvent)) {
|
||||
if (!threadRootId) return false;
|
||||
const targetId = mEvent.getRelation()?.event_id;
|
||||
return targetId === threadRootId;
|
||||
}
|
||||
if (threadRootId) {
|
||||
return isForumThreadEvent(mEvent, threadRootId);
|
||||
}
|
||||
return isForumFeedEvent(mEvent);
|
||||
}
|
||||
|
||||
/** Debounced live sync when topic room timelines change. */
|
||||
export function useForumRoomLiveUpdates(
|
||||
rooms: Room[],
|
||||
onUpdate: () => void,
|
||||
options: ForumRoomLiveOptions = {}
|
||||
) {
|
||||
const mx = useMatrixClient();
|
||||
const { threadRootId, wait = 200 } = options;
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
onUpdateRef.current = onUpdate;
|
||||
|
||||
const roomIds = useMemo(() => new Set(rooms.map((room) => room.roomId)), [rooms]);
|
||||
const roomKey = useMemo(() => [...roomIds].sort().join(','), [roomIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomKey) return undefined;
|
||||
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
if (timeoutId) window.clearTimeout(timeoutId);
|
||||
timeoutId = window.setTimeout(() => {
|
||||
onUpdateRef.current();
|
||||
}, wait);
|
||||
};
|
||||
|
||||
const handleTimeline = (mEvent: MatrixEvent, eventRoom?: Room) => {
|
||||
if (!eventRoom || !roomIds.has(eventRoom.roomId)) return;
|
||||
if (!isRelevantEvent(mEvent, threadRootId)) return;
|
||||
scheduleUpdate();
|
||||
};
|
||||
|
||||
const handleRedaction = (_mEvent: MatrixEvent, eventRoom?: Room) => {
|
||||
if (!eventRoom || !roomIds.has(eventRoom.roomId)) return;
|
||||
scheduleUpdate();
|
||||
};
|
||||
|
||||
mx.on(RoomEvent.Timeline, handleTimeline);
|
||||
mx.on(RoomEvent.Redaction, handleRedaction);
|
||||
|
||||
return () => {
|
||||
if (timeoutId) window.clearTimeout(timeoutId);
|
||||
mx.removeListener(RoomEvent.Timeline, handleTimeline);
|
||||
mx.removeListener(RoomEvent.Redaction, handleRedaction);
|
||||
};
|
||||
}, [mx, roomKey, roomIds, threadRootId, wait]);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds';
|
||||
import { Box, IconButton, Scroll, Text, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { RoomCard } from '../../components/room-card';
|
||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
|
||||
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
|
||||
import classNames from 'classnames';
|
||||
import { Box, Icon, Icons, as } from 'folds';
|
||||
import { Box, as } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
|
||||
import * as css from './DnD.css';
|
||||
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import React, { MouseEventHandler, useCallback, useEffect, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Icon,
|
||||
Icons,
|
||||
PopOut,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Text,
|
||||
RectCords,
|
||||
config,
|
||||
Line,
|
||||
Spinner,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { Box, IconButton, PopOut, Menu, MenuItem, Text, RectCords, config, Line, Spinner, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config } from 'folds';
|
||||
import { Box, Chip, IconButton, Line, Scroll, Spinner, Text, config } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -41,7 +42,7 @@ import { StateEvent } from '../../../types/matrix/room';
|
||||
import { CanDropCallback, useDnDMonitor } from './DnD';
|
||||
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
|
||||
import { getStateEvent, shouldShowForumLobby } from '../../utils/room';
|
||||
import { ForumSpaceShell } from '../forum/ForumSpaceShell';
|
||||
import { ForumBoardDetail } from '../forum/ForumBoardDetail';
|
||||
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
|
||||
import {
|
||||
makeCinnySpacesContent,
|
||||
@@ -151,7 +152,7 @@ const useCanDropLobbyItem = (
|
||||
};
|
||||
|
||||
function ForumSpaceLobby() {
|
||||
return <ForumSpaceShell />;
|
||||
return <ForumBoardDetail />;
|
||||
}
|
||||
|
||||
export function Lobby() {
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
import React, { MouseEventHandler, forwardRef, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Line,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { Avatar, Box, IconButton, Line, Menu, MenuItem, PopOut, RectCords, Text, Tooltip, TooltipProvider, config, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { PageHeader } from '../../components/page';
|
||||
import { useSetSetting } from '../../state/hooks/settings';
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
import React, { MouseEventHandler, ReactNode, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Chip,
|
||||
Icon,
|
||||
Icons,
|
||||
Line,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
as,
|
||||
color,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { Avatar, Badge, Box, Chip, Line, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as, color, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
|
||||
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { MouseEventHandler, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Box, Chip, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config } from 'folds';
|
||||
import { Box, Chip, Menu, MenuItem, PopOut, RectCords, Text, config } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import type { HierarchyItem } from '../../hooks/useSpaceHierarchy';
|
||||
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
|
||||
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
import React, { MouseEventHandler, ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Avatar,
|
||||
Text,
|
||||
Chip,
|
||||
Icon,
|
||||
Icons,
|
||||
as,
|
||||
Badge,
|
||||
toRem,
|
||||
Spinner,
|
||||
PopOut,
|
||||
Menu,
|
||||
MenuItem,
|
||||
RectCords,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Box, Avatar, Text, Chip, as, Badge, toRem, Spinner, PopOut, Menu, MenuItem, RectCords, config } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import classNames from 'classnames';
|
||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { RefObject, useEffect, useMemo, useRef } from 'react';
|
||||
import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem } from 'folds';
|
||||
import { Text, Box, config, Spinner, IconButton, Line, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -6,25 +6,8 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Text,
|
||||
Icon,
|
||||
Icons,
|
||||
Line,
|
||||
config,
|
||||
PopOut,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Header,
|
||||
toRem,
|
||||
Scroll,
|
||||
Button,
|
||||
Input,
|
||||
Badge,
|
||||
RectCords,
|
||||
} from 'folds';
|
||||
import { Box, Chip, Text, Line, config, PopOut, Menu, MenuItem, Header, toRem, Scroll, Button, Input, Badge, RectCords } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { SearchOrderBy } from 'matrix-js-sdk';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { FormEventHandler, RefObject } from 'react';
|
||||
import { Box, Text, Input, Icon, Icons, Spinner, Chip, config } from 'folds';
|
||||
import { Box, Text, Input, Spinner, Chip, config } from 'folds';
|
||||
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
type SearchProps = {
|
||||
active?: boolean;
|
||||
loading?: boolean;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import React, { MouseEventHandler, useMemo } from 'react';
|
||||
import { IEventWithRoomId, JoinRule, RelationType, Room } from 'matrix-js-sdk';
|
||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import { Avatar, Box, Chip, Header, Icon, Icons, Text, config } from 'folds';
|
||||
import { Avatar, Box, Chip, Header, Text, config } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Avatar, Box, Text, Icon, Icons, config } from 'folds';
|
||||
import { Avatar, Box, Text, config } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomCallMembers } from '../call/useRoomCallMembers';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { as, Chip, Icon, Icons, Text } from 'folds';
|
||||
import { as, Chip, Text } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './styles.css';
|
||||
|
||||
|
||||
@@ -2,25 +2,8 @@ import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react';
|
||||
import { Room, EventTimeline } from 'matrix-js-sdk';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Text,
|
||||
Menu,
|
||||
MenuItem,
|
||||
config,
|
||||
PopOut,
|
||||
toRem,
|
||||
Line,
|
||||
RectCords,
|
||||
Badge,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
} from 'folds';
|
||||
import { Avatar, Box, IconButton, Text, Menu, MenuItem, config, PopOut, toRem, Line, RectCords, Badge, Spinner, Tooltip, TooltipProvider } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { useFocusWithin, useHover } from 'react-aria';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Icon,
|
||||
Icons,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
color,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { Box, Chip, Spinner, Text, Tooltip, TooltipProvider, color, config, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Avatar, Box, config, Icon, IconButton, Icons, IconSrc, MenuItem, Text } from 'folds';
|
||||
import { Avatar, Box, config, IconButton, MenuItem, Text } from 'folds';
|
||||
import { Icon, Icons, IconSrc } from '../../components/icons';
|
||||
import { JoinRule } from 'matrix-js-sdk';
|
||||
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||
import { Box, IconButton, Scroll, Text } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||
import { Box, IconButton, Scroll, Text } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Text,
|
||||
Icon,
|
||||
Icons,
|
||||
config,
|
||||
IconButton,
|
||||
Chip,
|
||||
Line,
|
||||
} from 'folds';
|
||||
import { Box, Button, Text, config, IconButton, Chip, Line } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
|
||||
@@ -6,26 +6,8 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Chip,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Avatar, Badge, Box, Chip, Header, IconButton, Input, MenuItem, PopOut, RectCords, Scroll, Spinner, Text, Tooltip, TooltipProvider, config } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import classNames from 'classnames';
|
||||
|
||||
@@ -12,24 +12,9 @@ import { isKeyHotkey } from 'is-hotkey';
|
||||
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { Transforms, Editor } from 'slate';
|
||||
import {
|
||||
Box,
|
||||
Dialog,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Line,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
PopOut,
|
||||
Scroll,
|
||||
Text,
|
||||
color,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { Box, Dialog, IconButton, Line, Overlay, OverlayBackdrop, OverlayCenter, PopOut, Scroll, Text, color, config, toRem } from 'folds';
|
||||
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import {
|
||||
CustomEditor,
|
||||
@@ -131,10 +116,32 @@ interface RoomInputProps {
|
||||
room: Room;
|
||||
/** When provided, all messages are sent as replies in this thread. */
|
||||
threadRootId?: string;
|
||||
/** Thread parent for nested replies (no reply preview). */
|
||||
threadReplyToEventId?: string;
|
||||
/** Hide the "replying to" banner (forum thread composers). */
|
||||
hideReplyPreview?: boolean;
|
||||
/** Hide the thread badge in the reply preview (forum thread composers). */
|
||||
hideReplyThreadIndicator?: boolean;
|
||||
/** Optional highlight style for the reply preview (e.g. left border). */
|
||||
replyPreviewHighlightClassName?: string;
|
||||
onMessageSent?: () => void;
|
||||
}
|
||||
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
({ editor, fileDropContainerRef, roomId, room, threadRootId, onMessageSent }, ref) => {
|
||||
(
|
||||
{
|
||||
editor,
|
||||
fileDropContainerRef,
|
||||
roomId,
|
||||
room,
|
||||
threadRootId,
|
||||
threadReplyToEventId,
|
||||
hideReplyPreview,
|
||||
hideReplyThreadIndicator,
|
||||
replyPreviewHighlightClassName,
|
||||
onMessageSent,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
@@ -440,11 +447,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
content['m.relates_to'].is_falling_back = false;
|
||||
}
|
||||
} else if (threadRootId) {
|
||||
const inReplyToEventId = threadReplyToEventId ?? threadRootId;
|
||||
content['m.relates_to'] = {
|
||||
rel_type: RelationType.Thread,
|
||||
event_id: threadRootId,
|
||||
is_falling_back: true,
|
||||
'm.in_reply_to': { event_id: threadRootId },
|
||||
is_falling_back: inReplyToEventId === threadRootId,
|
||||
'm.in_reply_to': { event_id: inReplyToEventId },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -475,7 +483,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
setCommandHint(undefined);
|
||||
sendTypingStatus(false);
|
||||
onMessageSent?.();
|
||||
}, [mx, roomId, threadRootId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons, onMessageSent]);
|
||||
}, [mx, roomId, threadRootId, threadReplyToEventId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons, onMessageSent]);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
@@ -702,7 +710,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{replyDraft && (
|
||||
{replyDraft && !hideReplyPreview && (
|
||||
<div>
|
||||
<Box
|
||||
alignItems="Center"
|
||||
@@ -717,8 +725,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
>
|
||||
<Icon src={Icons.Cross} size="50" />
|
||||
</IconButton>
|
||||
<Box direction="Row" gap="200" alignItems="Center">
|
||||
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
||||
<Box
|
||||
direction="Row"
|
||||
gap="200"
|
||||
alignItems="Center"
|
||||
grow="Yes"
|
||||
className={replyPreviewHighlightClassName}
|
||||
>
|
||||
{!hideReplyThreadIndicator &&
|
||||
replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
||||
<ReplyLayout
|
||||
userColor={replyUsernameColor}
|
||||
username={
|
||||
|
||||
@@ -30,22 +30,8 @@ import { ReactEditor } from 'slate-react';
|
||||
import { Editor } from 'slate';
|
||||
import to from 'await-to-js';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Chip,
|
||||
ContainerColor,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Line,
|
||||
Scroll,
|
||||
Text,
|
||||
as,
|
||||
color,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { Badge, Box, Chip, ContainerColor, IconButton, Line, Scroll, Text, as, color, config, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Icon,
|
||||
Icons,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
as,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Box, Modal, Overlay, OverlayBackdrop, OverlayCenter, Text, as, config } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import classNames from 'classnames';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
|
||||
@@ -1,28 +1,7 @@
|
||||
import React, { MouseEventHandler, forwardRef, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
Avatar,
|
||||
Text,
|
||||
Overlay,
|
||||
OverlayCenter,
|
||||
OverlayBackdrop,
|
||||
IconButton,
|
||||
Icon,
|
||||
Icons,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
Menu,
|
||||
MenuItem,
|
||||
toRem,
|
||||
config,
|
||||
Line,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Badge,
|
||||
Spinner,
|
||||
color,
|
||||
} from 'folds';
|
||||
import { Box, Avatar, Text, Overlay, OverlayCenter, OverlayBackdrop, IconButton, Tooltip, TooltipProvider, Menu, MenuItem, toRem, config, Line, PopOut, RectCords, Badge, Spinner, color } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { JoinRule, Room } from 'matrix-js-sdk';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||
import { Box, IconButton, Text, as } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import classNames from 'classnames';
|
||||
import { useSetAtom } from 'jotai';
|
||||
|
||||
@@ -15,19 +15,8 @@ import {
|
||||
ThreadEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Line,
|
||||
Scroll,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Badge, Box, IconButton, Line, Scroll, Text, Tooltip, TooltipProvider, config } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Dialog,
|
||||
Overlay,
|
||||
OverlayCenter,
|
||||
OverlayBackdrop,
|
||||
Header,
|
||||
config,
|
||||
Box,
|
||||
Text,
|
||||
IconButton,
|
||||
Icon,
|
||||
Icons,
|
||||
color,
|
||||
Button,
|
||||
Spinner,
|
||||
Chip,
|
||||
PopOut,
|
||||
RectCords,
|
||||
} from 'folds';
|
||||
import { Dialog, Overlay, OverlayCenter, OverlayBackdrop, Header, config, Box, Text, IconButton, color, Button, Spinner, Chip, PopOut, RectCords } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { Direction, MatrixError } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
|
||||
@@ -1,28 +1,5 @@
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
Line,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Spinner,
|
||||
Text,
|
||||
as,
|
||||
color,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Avatar, Box, Button, Dialog, Header, IconButton, Input, Line, Menu, MenuItem, Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOut, RectCords, Spinner, Text, as, color, config } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import React, {
|
||||
FormEventHandler,
|
||||
MouseEventHandler,
|
||||
|
||||
@@ -5,20 +5,8 @@ import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Line,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Spinner,
|
||||
Text,
|
||||
as,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Box, Chip, IconButton, Line, PopOut, RectCords, Spinner, Text, as, config } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { Editor, Transforms } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Line,
|
||||
MenuItem,
|
||||
Scroll,
|
||||
Text,
|
||||
as,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Avatar, Box, Header, IconButton, Line, MenuItem, Scroll, Text, as, config } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
|
||||
import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
||||
import { getMemberDisplayName } from '../../../utils/room';
|
||||
|
||||
@@ -2,22 +2,8 @@
|
||||
import React, { forwardRef, MouseEventHandler, useCallback, useMemo, useRef } from 'react';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Chip,
|
||||
color,
|
||||
config,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Menu,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { Avatar, Box, Chip, color, config, Header, IconButton, Menu, Scroll, Spinner, Text, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
config,
|
||||
Icon,
|
||||
Icons,
|
||||
Input,
|
||||
Line,
|
||||
MenuItem,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayCenter,
|
||||
Scroll,
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { Avatar, Box, config, Input, Line, MenuItem, Modal, Overlay, OverlayCenter, Scroll, Text, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../components/icons';
|
||||
import React, {
|
||||
ChangeEventHandler,
|
||||
KeyboardEventHandler,
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
config,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
IconSrc,
|
||||
MenuItem,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { Avatar, Box, Button, config, IconButton, MenuItem, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds';
|
||||
import { Icon, Icons, IconSrc } from '../../components/icons';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { General } from './general';
|
||||
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll, Button, config, toRem } from 'folds';
|
||||
import { Box, Text, IconButton, Scroll, Button, config, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
|
||||
import { Box, Text, IconButton, Scroll } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { MatrixId } from './MatrixId';
|
||||
import { Profile } from './Profile';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { ChangeEventHandler, FormEventHandler, useCallback, useState } from 'react';
|
||||
import { Box, Button, Chip, Icon, IconButton, Icons, Input, Spinner, Text, config } from 'folds';
|
||||
import { Box, Button, Chip, IconButton, Input, Spinner, Text, config } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
|
||||
@@ -9,26 +9,8 @@ import React, {
|
||||
useState,
|
||||
} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
IconButton,
|
||||
Icon,
|
||||
Icons,
|
||||
Input,
|
||||
Avatar,
|
||||
Button,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Modal,
|
||||
Dialog,
|
||||
Header,
|
||||
config,
|
||||
Spinner,
|
||||
color,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { Box, Text, IconButton, Input, Avatar, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal, Dialog, Header, config, Spinner, color, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { HexColorPicker, RgbaColorPicker, RgbaColor } from 'react-colorful';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
/* eslint-disable no-console */
|
||||
import React, { useCallback, useEffect, useRef, useState, MouseEventHandler } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
color,
|
||||
config,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Scroll,
|
||||
Switch,
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { Box, Button, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Box, Text, Icon, Icons, Button, MenuItem } from 'folds';
|
||||
import { Box, Text, Button, MenuItem } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
|
||||
import { Box, Text, IconButton, Scroll, Switch, Button } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import React, { FormEventHandler, ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
IconButton,
|
||||
Icon,
|
||||
Icons,
|
||||
Chip,
|
||||
Input,
|
||||
Button,
|
||||
color,
|
||||
Spinner,
|
||||
toRem,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
} from 'folds';
|
||||
import { Box, Text, IconButton, Chip, Input, Button, color, Spinner, toRem, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { IMyDevice, MatrixError } from 'matrix-js-sdk';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
|
||||
import { Box, Text, IconButton, Scroll } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds';
|
||||
import { Box, Button, color, Spinner, Text, toRem } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import FileSaver from 'file-saver';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
import React, { MouseEventHandler, useCallback, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
config,
|
||||
Icon,
|
||||
Icons,
|
||||
Spinner,
|
||||
Text,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
IconButton,
|
||||
RectCords,
|
||||
PopOut,
|
||||
Menu,
|
||||
MenuItem,
|
||||
} from 'folds';
|
||||
import { Badge, Box, Button, Chip, config, Spinner, Text, Overlay, OverlayBackdrop, OverlayCenter, IconButton, RectCords, PopOut, Menu, MenuItem } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { CryptoApi, VerificationRequest } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll, config, Switch } from 'folds';
|
||||
import { Box, Text, IconButton, Scroll, config, Switch } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { GlobalPacks } from './GlobalPacks';
|
||||
|
||||
@@ -1,25 +1,6 @@
|
||||
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Button,
|
||||
Icon,
|
||||
Icons,
|
||||
IconButton,
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
config,
|
||||
Spinner,
|
||||
Menu,
|
||||
RectCords,
|
||||
PopOut,
|
||||
Checkbox,
|
||||
toRem,
|
||||
Scroll,
|
||||
Header,
|
||||
Line,
|
||||
Chip,
|
||||
} from 'folds';
|
||||
import { Box, Text, Button, IconButton, Avatar, AvatarFallback, config, Spinner, Menu, RectCords, PopOut, Checkbox, toRem, Scroll, Header, Line, Chip } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user