mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-15 03:30:29 +03:00
URL navigation in interface and other improvements (#1633)
* load room on url change * add direct room list * render space room list * fix css syntax error * update scroll virtualizer * render subspaces room list * improve sidebar notification badge perf * add nav category components * add space recursive direct component * use nav category component in home, direct and space room list * add empty home and direct list layout * fix unread room menu ref * add more navigation items in room, direct and space tab * add more navigation * fix unread room menu to links * fix space lobby and search link * add explore navigation section * add notifications navigation menu * redirect to initial path after login * include unsupported room in rooms * move router hooks in hooks/router folder * add featured explore - WIP * load featured room with room summary * fix room card topic line clamp * add react query * load room summary using react query * add join button in room card * add content component * use content component in featured community content * fix content width * add responsive room card grid * fix async callback error status * add room card error button * fix client drawer shrink * add room topic viewer * open room card topic in viewer * fix room topic close btn * add get orphan parent util * add room card error dialog * add view featured room or space btn * refactor orphanParent to orphanParents * WIP - explore server * show space hint in room card * add room type filters * add per page item limit popout * reset scroll on public rooms load * refactor explore ui * refactor public rooms component * reset search on server change * fix typo * add empty featured section info * display user server on top * make server room card view btn clickable * add user server as default redirect for explore path * make home empty btn clickable * add thirdparty instance filter in server explore * remove since param on instance change * add server button in explore menu * rename notifications path to inbox * update react-virtual * Add notification messages inbox - WIP * add scroll top container component * add useInterval hook * add visibility change callback prop to scroll top container component * auto refresh notifications every 10 seconds * make message related component reusable * refactor matrix event renderer hoook * render notification message content * refactor matrix event renderer hook * update sequence card styles * move room navigate hook in global hooks * add open message button in notifications * add mark room as read button in notification group * show error in notification messages * add more featured spaces * render reply in notification messages * make notification message reply clickable * add outline prop for attachments * make old settings dialog viewable * add open featured communities as default config option * add invite count notification badge in sidebar and inbox menu * add element size observer hook * improve element size observer hook props * improve screen size hook * fix room avatar util function * allow Text props in Time component * fix dm room util function * add invitations * add no invites and notification cards * fix inbox tab unread badge visible without invite count * update folds and change inbox icon * memo search param construction * add message search in home * fix default message search order * fix display edited message new content * highlight search text in search messages * fix message search loading * disable log in production * add use space context * add useRoom context * fix space room list * fix inbox tab active state * add hook to get space child room recursive * add search for space * add virtual tile component * virtualize home and directs room list * update nav category component * use virtual tile component in more places * fix message highlight when click on reply twice * virtualize space room list * fix space room list lag issue * update folds * add room nav item component in space room list * use room nav item in home and direct room list * make space categories closable and save it in local storage * show unread room when category is collapsed * make home and direct room list category closable * rename room nav item show avatar prop * fix explore server category text alignment * rename closedRoomCategories to closedNavCategories * add nav category handler hook * save and restore last navigation path on space select * filter space rooms category by activity when it is closed * save and restore home and direct nav path state * save and restore inbox active path on open * save and restore explore tab active path * remove notification badge unread menu * add join room or space before navigate screen * move room component to features folder and add new room header * update folds * add room header menu * fix home room list activity sorting * do not hide selected room item on category closed in home and direct tab * replace old select room/tab call with navigate hook * improve state event hooks * show room card summary for joined rooms * prevent room from opening in wrong tab * only show message sender id on hover in modern layout * revert state event hooks changes * add key prop to room provider components * add welcome page * prevent excessive redirects * fix sidebar style with no spaces * move room settings in popup window * remove invite option from room settings * fix open room list search * add leave room prompt * standardize room and user avatar * fix avatar text size * add new reply layout * rename space hierarchy hook * add room topic hook * add room name hook * add room avatar hook and add direct room avatar util * space lobby - WIP * hide invalid space child event from space hierarchy in lobby * move lobby to features * fix element size observer hook width and height * add lobby header and hero section * add hierarchy room item error and loading state * add first and last child prop in sequence card * redirect to lobby from index path * memo and retry hierarchy room summary error * fix hierarchy room item styles * rename lobby hierarchy item card to room item card * show direct room avatar in space lobby * add hierarchy space item * add space item unknown room join button * fix space hierarchy hook refresh after new space join * change user avatar color and fallback render to user icon * change room avatar fallback to room icon * rename room/user avatar renderInitial prop to renderFallback * add room join and view button in space lobby * make power level api more reusable * fix space hierarchy not updating on child update * add menu to suggest or remove space children * show reply arrow in place of reply bend in message * fix typeerror in search because of wrong js-sdk t.ds * do not refetch hierarchy room summary on window focus * make room/user avatar un-draggable * change welcome page support button copy * drag-and-drop ordering of lobby spaces/rooms - WIP * add ASCIILexicalTable algorithms * fix wrong power level check in lobby items options * fix lobby can drop checks * fix join button error crash * fix reply spacing * fix m direct updated with other account data * add option to open room/space settings from lobby * add option in lobby to add new or existing room/spaces * fix room nav item selected styles * add space children reorder mechanism * fix space child reorder bug * fix hierarchy item sort function * Apply reorder of lobby into room list * add and improve space lobby menu items * add existing spaces menu in lobby * change restricted room allow params when dragging outside space * move featured servers config from homeserver list * removed unused features from space settings * add canonical alias as name fallback in lobby item * fix unreliable unread count update bug * fix after login redirect * fix room card topic hover style * Add dnd and folders in sidebar spaces * fix orphan space not visible in sidebar * fix sso login has mix of icon and button * fix space children not visible in home upon leaving space * recalculate notification on updating any space child * fix user color saturation/lightness * add user color to user avatar * add background colors to room avatar * show 2 length initial in sidebar space avatar * improve link color * add nav button component * open legacy create room and create direct * improve page route structure * handle hash router in path utils * mobile friendly router and navigation * make room header member drawer icon mobile friendly * setup index redirect for inbox and explore server route * add leave space prompt * improve member drawer filter menu * add space context menu * add context menu in home * add leave button in lobby items * render user tab avatar on sidebar * force overwrite netlify - test * netlify test * fix reset-password path without server redirected to login * add message link copy button in message menu * reset unread on sync prepared * fix stuck typing notifications * show typing indication in room nav item * refactor closedNavCategories atom to use userId in store key * refactor closedLobbyCategoriesAtom to include userId in store key * refactor navToActivePathAtom to use userId in storage key * remove unused file * refactor openedSidebarFolderAtom to include userId in storage key * add context menu for sidebar space tab * fix eslint not working * add option to pin/unpin child spaces * add context menu for directs tab * add context menu for direct and home tab * show lock icon for non-public space in header * increase matrix max listener count * wrap lobby add space room in callback hook
This commit is contained in:
parent
2b7d825694
commit
4c76a7fd18
290 changed files with 17447 additions and 3224 deletions
61
src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
Normal file
61
src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import React from 'react';
|
||||
import { Box, Scroll, Text, toRem } from 'folds';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { RoomCard } from '../../components/room-card';
|
||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||
import { Page, PageHeader } from '../../components/page';
|
||||
import { RoomSummaryLoader } from '../../components/RoomSummaryLoader';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
|
||||
type JoinBeforeNavigateProps = { roomIdOrAlias: string };
|
||||
export function JoinBeforeNavigate({ roomIdOrAlias }: JoinBeforeNavigateProps) {
|
||||
const mx = useMatrixClient();
|
||||
const allRooms = useAtomValue(allRoomsAtom);
|
||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||
|
||||
const handleView = (roomId: string) => {
|
||||
if (mx.getRoom(roomId)?.isSpaceRoom()) {
|
||||
navigateSpace(roomId);
|
||||
return;
|
||||
}
|
||||
navigateRoom(roomId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
{roomIdOrAlias}
|
||||
</Text>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover" size="0">
|
||||
<Box style={{ height: '100%' }} grow="Yes" alignItems="Center" justifyContent="Center">
|
||||
<RoomSummaryLoader roomIdOrAlias={roomIdOrAlias}>
|
||||
{(summary) => (
|
||||
<RoomCard
|
||||
style={{ maxWidth: toRem(364), width: '100%' }}
|
||||
roomIdOrAlias={roomIdOrAlias}
|
||||
allRooms={allRooms}
|
||||
avatarUrl={summary?.avatar_url}
|
||||
name={summary?.name}
|
||||
topic={summary?.topic}
|
||||
memberCount={summary?.num_joined_members}
|
||||
roomType={summary?.room_type}
|
||||
renderTopicViewer={(name, topic, requestClose) => (
|
||||
<RoomTopicViewer name={name} topic={topic} requestClose={requestClose} />
|
||||
)}
|
||||
onView={handleView}
|
||||
/>
|
||||
)}
|
||||
</RoomSummaryLoader>
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
1
src/app/features/join-before-navigate/index.ts
Normal file
1
src/app/features/join-before-navigate/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './JoinBeforeNavigate';
|
||||
91
src/app/features/lobby/DnD.css.ts
Normal file
91
src/app/features/lobby/DnD.css.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
|
||||
export const ItemDraggableTarget = style([
|
||||
ContainerColor({ variant: 'SurfaceVariant' }),
|
||||
{
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
cursor: 'grab',
|
||||
borderRadius: config.radii.R400,
|
||||
opacity: config.opacity.P300,
|
||||
|
||||
':active': {
|
||||
cursor: 'ns-resize',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const LineHeight = 4;
|
||||
const DropTargetLine = style({
|
||||
selectors: {
|
||||
'&[data-hover=true]:before': {
|
||||
content: '',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: '50%',
|
||||
zIndex: 1,
|
||||
transform: 'translateY(-50%)',
|
||||
|
||||
borderBottom: `${toRem(LineHeight)} solid currentColor`,
|
||||
},
|
||||
'&[data-hover=true]:after': {
|
||||
content: '',
|
||||
display: 'block',
|
||||
width: toRem(LineHeight * 3),
|
||||
height: toRem(LineHeight * 3),
|
||||
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: '50%',
|
||||
zIndex: 1,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
|
||||
backgroundColor: color.Surface.Container,
|
||||
border: `${toRem(LineHeight)} solid currentColor`,
|
||||
borderRadius: '50%',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const BaseAfterRoomItemDropTarget = style({
|
||||
width: '100%',
|
||||
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
zIndex: 99,
|
||||
|
||||
color: color.Success.Main,
|
||||
|
||||
selectors: {
|
||||
'&[data-error=true]': {
|
||||
color: color.Critical.Main,
|
||||
},
|
||||
},
|
||||
});
|
||||
const RoomTargetHeight = 32;
|
||||
export const AfterRoomItemDropTarget = style([
|
||||
BaseAfterRoomItemDropTarget,
|
||||
{
|
||||
height: toRem(RoomTargetHeight),
|
||||
transform: `translateY(${toRem(RoomTargetHeight / 2 + LineHeight / 2)})`,
|
||||
},
|
||||
DropTargetLine,
|
||||
]);
|
||||
const SpaceTargetHeight = 14;
|
||||
export const AfterSpaceItemDropTarget = style([
|
||||
BaseAfterRoomItemDropTarget,
|
||||
{
|
||||
height: toRem(SpaceTargetHeight),
|
||||
transform: `translateY(calc(100% - ${toRem(4)}))`,
|
||||
},
|
||||
DropTargetLine,
|
||||
]);
|
||||
146
src/app/features/lobby/DnD.tsx
Normal file
146
src/app/features/lobby/DnD.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import React, { RefObject, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
dropTargetForElements,
|
||||
draggable,
|
||||
monitorForElements,
|
||||
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
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 { HierarchyItem } from '../../hooks/useSpaceHierarchy';
|
||||
import * as css from './DnD.css';
|
||||
|
||||
export type DropContainerData = {
|
||||
item: HierarchyItem;
|
||||
nextRoomId?: string;
|
||||
};
|
||||
export type CanDropCallback = (item: HierarchyItem, container: DropContainerData) => boolean;
|
||||
|
||||
export const useDraggableItem = (
|
||||
item: HierarchyItem,
|
||||
targetRef: RefObject<HTMLElement>,
|
||||
onDragging: (item?: HierarchyItem) => void,
|
||||
dragHandleRef?: RefObject<HTMLElement>
|
||||
): boolean => {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const target = targetRef.current;
|
||||
const dragHandle = dragHandleRef?.current ?? undefined;
|
||||
|
||||
return !target
|
||||
? undefined
|
||||
: draggable({
|
||||
element: target,
|
||||
dragHandle,
|
||||
getInitialData: () => item,
|
||||
onDragStart: () => {
|
||||
setDragging(true);
|
||||
onDragging(item);
|
||||
},
|
||||
onDrop: () => {
|
||||
setDragging(false);
|
||||
onDragging(undefined);
|
||||
},
|
||||
});
|
||||
}, [targetRef, dragHandleRef, item, onDragging]);
|
||||
|
||||
return dragging;
|
||||
};
|
||||
|
||||
export const ItemDraggableTarget = as<'div'>(({ className, ...props }, ref) => (
|
||||
<Box
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
className={classNames(css.ItemDraggableTarget, className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<Icon size="50" src={Icons.VerticalDots} />
|
||||
</Box>
|
||||
));
|
||||
|
||||
type AfterItemDropTargetProps = {
|
||||
item: HierarchyItem;
|
||||
afterSpace?: boolean;
|
||||
nextRoomId?: string;
|
||||
canDrop: CanDropCallback;
|
||||
};
|
||||
export function AfterItemDropTarget({
|
||||
item,
|
||||
afterSpace,
|
||||
nextRoomId,
|
||||
canDrop,
|
||||
}: AfterItemDropTargetProps) {
|
||||
const targetRef = useRef<HTMLDivElement>(null);
|
||||
const [dropState, setDropState] = useState<'idle' | 'allow' | 'not-allow'>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
const target = targetRef.current;
|
||||
if (!target) {
|
||||
throw Error('drop target ref is not set properly');
|
||||
}
|
||||
|
||||
return dropTargetForElements({
|
||||
element: target,
|
||||
getData: () => {
|
||||
const container: DropContainerData = {
|
||||
item,
|
||||
nextRoomId,
|
||||
};
|
||||
return container;
|
||||
},
|
||||
onDragEnter: ({ source }) => {
|
||||
if (
|
||||
canDrop(source.data as HierarchyItem, {
|
||||
item,
|
||||
nextRoomId,
|
||||
})
|
||||
) {
|
||||
setDropState('allow');
|
||||
} else {
|
||||
setDropState('not-allow');
|
||||
}
|
||||
},
|
||||
onDragLeave: () => setDropState('idle'),
|
||||
onDrop: () => setDropState('idle'),
|
||||
});
|
||||
}, [item, nextRoomId, canDrop]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={afterSpace ? css.AfterSpaceItemDropTarget : css.AfterRoomItemDropTarget}
|
||||
data-hover={dropState !== 'idle'}
|
||||
data-error={dropState === 'not-allow'}
|
||||
ref={targetRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const useDnDMonitor = (
|
||||
scrollRef: RefObject<HTMLElement>,
|
||||
onDragging: (item?: HierarchyItem) => void,
|
||||
onReorder: (item: HierarchyItem, container: DropContainerData) => void
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollRef.current;
|
||||
if (!scrollElement) {
|
||||
throw Error('Scroll element ref not configured');
|
||||
}
|
||||
|
||||
return combine(
|
||||
monitorForElements({
|
||||
onDrop: ({ source, location }) => {
|
||||
onDragging(undefined);
|
||||
const { dropTargets } = location.current;
|
||||
if (dropTargets.length === 0) return;
|
||||
onReorder(source.data as HierarchyItem, dropTargets[0].data as DropContainerData);
|
||||
},
|
||||
}),
|
||||
autoScrollForElements({
|
||||
element: scrollElement,
|
||||
})
|
||||
);
|
||||
}, [scrollRef, onDragging, onReorder]);
|
||||
};
|
||||
306
src/app/features/lobby/HierarchyItemMenu.tsx
Normal file
306
src/app/features/lobby/HierarchyItemMenu.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
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 { HierarchyItem } from '../../hooks/useSpaceHierarchy';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
|
||||
import {
|
||||
openInviteUser,
|
||||
openSpaceSettings,
|
||||
toggleRoomSettings,
|
||||
} from '../../../client/action/navigation';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
|
||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
||||
|
||||
type HierarchyItemWithParent = HierarchyItem & {
|
||||
parentId: string;
|
||||
};
|
||||
|
||||
function SuggestMenuItem({
|
||||
item,
|
||||
requestClose,
|
||||
}: {
|
||||
item: HierarchyItemWithParent;
|
||||
requestClose: () => void;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const { roomId, parentId, content } = item;
|
||||
|
||||
const [toggleState, handleToggleSuggested] = useAsyncCallback(
|
||||
useCallback(() => {
|
||||
const newContent: MSpaceChildContent = { ...content, suggested: !content.suggested };
|
||||
return mx.sendStateEvent(parentId, StateEvent.SpaceChild, newContent, roomId);
|
||||
}, [mx, parentId, roomId, content])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (toggleState.status === AsyncStatus.Success) {
|
||||
requestClose();
|
||||
}
|
||||
}, [requestClose, toggleState]);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
onClick={handleToggleSuggested}
|
||||
size="300"
|
||||
radii="300"
|
||||
before={toggleState.status === AsyncStatus.Loading && <Spinner size="100" />}
|
||||
disabled={toggleState.status === AsyncStatus.Loading}
|
||||
>
|
||||
<Text as="span" size="T300" truncate>
|
||||
{content.suggested ? 'Unset Suggested' : 'Set Suggested'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveMenuItem({
|
||||
item,
|
||||
requestClose,
|
||||
}: {
|
||||
item: HierarchyItemWithParent;
|
||||
requestClose: () => void;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const { roomId, parentId } = item;
|
||||
|
||||
const [removeState, handleRemove] = useAsyncCallback(
|
||||
useCallback(
|
||||
() => mx.sendStateEvent(parentId, StateEvent.SpaceChild, {}, roomId),
|
||||
[mx, parentId, roomId]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (removeState.status === AsyncStatus.Success) {
|
||||
requestClose();
|
||||
}
|
||||
}, [requestClose, removeState]);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
onClick={handleRemove}
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
before={
|
||||
removeState.status === AsyncStatus.Loading && (
|
||||
<Spinner variant="Critical" fill="Soft" size="100" />
|
||||
)
|
||||
}
|
||||
disabled={removeState.status === AsyncStatus.Loading}
|
||||
>
|
||||
<Text as="span" size="T300" truncate>
|
||||
Remove
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteMenuItem({
|
||||
item,
|
||||
requestClose,
|
||||
disabled,
|
||||
}: {
|
||||
item: HierarchyItemWithParent;
|
||||
requestClose: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const handleInvite = () => {
|
||||
openInviteUser(item.roomId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
onClick={handleInvite}
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Primary"
|
||||
fill="None"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text as="span" size="T300" truncate>
|
||||
Invite
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsMenuItem({
|
||||
item,
|
||||
requestClose,
|
||||
disabled,
|
||||
}: {
|
||||
item: HierarchyItemWithParent;
|
||||
requestClose: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const handleSettings = () => {
|
||||
if (item.space) {
|
||||
openSpaceSettings(item.roomId);
|
||||
} else {
|
||||
toggleRoomSettings(item.roomId);
|
||||
}
|
||||
requestClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuItem onClick={handleSettings} size="300" radii="300" disabled={disabled}>
|
||||
<Text as="span" size="T300" truncate>
|
||||
Settings
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
type HierarchyItemMenuProps = {
|
||||
item: HierarchyItem & {
|
||||
parentId: string;
|
||||
};
|
||||
joined: boolean;
|
||||
canInvite: boolean;
|
||||
canEditChild: boolean;
|
||||
pinned?: boolean;
|
||||
onTogglePin?: (roomId: string) => void;
|
||||
};
|
||||
export function HierarchyItemMenu({
|
||||
item,
|
||||
joined,
|
||||
canInvite,
|
||||
canEditChild,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
}: HierarchyItemMenuProps) {
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleRequestClose = useCallback(() => setMenuAnchor(undefined), []);
|
||||
|
||||
if (!joined && !canEditChild) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box gap="200" alignItems="Center" shrink="No">
|
||||
<IconButton
|
||||
onClick={handleOpenMenu}
|
||||
size="300"
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-pressed={!!menuAnchor}
|
||||
>
|
||||
<Icon size="50" src={Icons.VerticalDots} />
|
||||
</IconButton>
|
||||
{menuAnchor && (
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
}}
|
||||
>
|
||||
<Menu style={{ maxWidth: toRem(150), width: '100vw' }}>
|
||||
{joined && (
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{onTogglePin && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
onTogglePin(item.roomId);
|
||||
handleRequestClose();
|
||||
}}
|
||||
>
|
||||
<Text as="span" size="T300" truncate>
|
||||
{pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
<InviteMenuItem
|
||||
item={item}
|
||||
requestClose={handleRequestClose}
|
||||
disabled={!canInvite}
|
||||
/>
|
||||
<SettingsMenuItem item={item} requestClose={handleRequestClose} />
|
||||
<UseStateProvider initial={false}>
|
||||
{(promptLeave, setPromptLeave) => (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => setPromptLeave(true)}
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
|
||||
radii="300"
|
||||
aria-pressed={promptLeave}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Leave
|
||||
</Text>
|
||||
</MenuItem>
|
||||
{promptLeave &&
|
||||
(item.space ? (
|
||||
<LeaveSpacePrompt
|
||||
roomId={item.roomId}
|
||||
onDone={handleRequestClose}
|
||||
onCancel={() => setPromptLeave(false)}
|
||||
/>
|
||||
) : (
|
||||
<LeaveRoomPrompt
|
||||
roomId={item.roomId}
|
||||
onDone={handleRequestClose}
|
||||
onCancel={() => setPromptLeave(false)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
</Box>
|
||||
)}
|
||||
{(joined || canEditChild) && (
|
||||
<Line size="300" variant="Surface" direction="Horizontal" />
|
||||
)}
|
||||
{canEditChild && (
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<SuggestMenuItem item={item} requestClose={handleRequestClose} />
|
||||
<RemoveMenuItem item={item} requestClose={handleRequestClose} />
|
||||
</Box>
|
||||
)}
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
528
src/app/features/lobby/Lobby.tsx
Normal file
528
src/app/features/lobby/Lobby.tsx
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { IJoinRuleEventContent, JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
|
||||
import { useSpace } from '../../hooks/useSpace';
|
||||
import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page';
|
||||
import { HierarchyItem, useSpaceHierarchy } from '../../hooks/useSpaceHierarchy';
|
||||
import { VirtualTile } from '../../components/virtualizer';
|
||||
import { spaceRoomsAtom } from '../../state/spaceRooms';
|
||||
import { MembersDrawer } from '../room/MembersDrawer';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { LobbyHeader } from './LobbyHeader';
|
||||
import { LobbyHero } from './LobbyHero';
|
||||
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
||||
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
||||
import {
|
||||
IPowerLevels,
|
||||
PowerLevelsContextProvider,
|
||||
powerLevelAPI,
|
||||
usePowerLevels,
|
||||
useRoomsPowerLevels,
|
||||
} from '../../hooks/usePowerLevels';
|
||||
import { RoomItemCard } from './RoomItem';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { SpaceItemCard } from './SpaceItem';
|
||||
import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
|
||||
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
||||
import { getSpaceRoomPath } from '../../pages/pathUtils';
|
||||
import { HierarchyItemMenu } from './HierarchyItemMenu';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { AfterItemDropTarget, CanDropCallback, useDnDMonitor } from './DnD';
|
||||
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
|
||||
import { getStateEvent } from '../../utils/room';
|
||||
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
|
||||
import {
|
||||
makeCinnySpacesContent,
|
||||
sidebarItemWithout,
|
||||
useSidebarItems,
|
||||
} from '../../hooks/useSidebarItems';
|
||||
import { useOrphanSpaces } from '../../state/hooks/roomList';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||
|
||||
export function Lobby() {
|
||||
const navigate = useNavigate();
|
||||
const mx = useMatrixClient();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const allRooms = useAtomValue(allRoomsAtom);
|
||||
const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
|
||||
const space = useSpace();
|
||||
const spacePowerLevels = usePowerLevels(space);
|
||||
const lex = useMemo(() => new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 6), []);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const heroSectionRef = useRef<HTMLDivElement>(null);
|
||||
const [heroSectionHeight, setHeroSectionHeight] = useState<number>();
|
||||
const [spaceRooms, setSpaceRooms] = useAtom(spaceRoomsAtom);
|
||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const screenSize = useScreenSizeContext();
|
||||
const [onTop, setOnTop] = useState(true);
|
||||
const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom());
|
||||
const [sidebarItems] = useSidebarItems(
|
||||
useOrphanSpaces(mx, allRoomsAtom, useAtomValue(roomToParentsAtom))
|
||||
);
|
||||
const sidebarSpaces = useMemo(() => {
|
||||
const sideSpaces = sidebarItems.flatMap((item) => {
|
||||
if (typeof item === 'string') return item;
|
||||
return item.content;
|
||||
});
|
||||
|
||||
return new Set(sideSpaces);
|
||||
}, [sidebarItems]);
|
||||
|
||||
useElementSizeObserver(
|
||||
useCallback(() => heroSectionRef.current, []),
|
||||
useCallback((w, height) => setHeroSectionHeight(height), [])
|
||||
);
|
||||
|
||||
const getRoom = useCallback(
|
||||
(rId: string) => {
|
||||
if (allJoinedRooms.has(rId)) {
|
||||
return mx.getRoom(rId) ?? undefined;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[mx, allJoinedRooms]
|
||||
);
|
||||
|
||||
const canEditSpaceChild = useCallback(
|
||||
(powerLevels: IPowerLevels) =>
|
||||
powerLevelAPI.canSendStateEvent(
|
||||
powerLevels,
|
||||
StateEvent.SpaceChild,
|
||||
powerLevelAPI.getPowerLevel(powerLevels, mx.getUserId() ?? undefined)
|
||||
),
|
||||
[mx]
|
||||
);
|
||||
|
||||
const [draggingItem, setDraggingItem] = useState<HierarchyItem>();
|
||||
const flattenHierarchy = useSpaceHierarchy(
|
||||
space.roomId,
|
||||
spaceRooms,
|
||||
getRoom,
|
||||
useCallback(
|
||||
(childId) =>
|
||||
closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || !!draggingItem?.space,
|
||||
[closedCategories, space.roomId, draggingItem]
|
||||
)
|
||||
);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flattenHierarchy.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 1,
|
||||
overscan: 2,
|
||||
paddingStart: heroSectionHeight ?? 258,
|
||||
});
|
||||
const vItems = virtualizer.getVirtualItems();
|
||||
|
||||
const roomsPowerLevels = useRoomsPowerLevels(
|
||||
useMemo(
|
||||
() => flattenHierarchy.map((i) => mx.getRoom(i.roomId)).filter((r) => !!r) as Room[],
|
||||
[mx, flattenHierarchy]
|
||||
)
|
||||
);
|
||||
|
||||
const canDrop: CanDropCallback = useCallback(
|
||||
(item, container): boolean => {
|
||||
const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted;
|
||||
if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) {
|
||||
// can not drop before or after itself
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.space) {
|
||||
if (!container.item.space) return false;
|
||||
const containerSpaceId = space.roomId;
|
||||
|
||||
if (
|
||||
getRoom(containerSpaceId) === undefined ||
|
||||
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const containerSpaceId = container.item.space
|
||||
? container.item.roomId
|
||||
: container.item.parentId;
|
||||
|
||||
const dropOutsideSpace = item.parentId !== containerSpaceId;
|
||||
|
||||
if (dropOutsideSpace && restrictedItem) {
|
||||
// do not allow restricted room to drop outside
|
||||
// current space if can't change join rule allow
|
||||
const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
|
||||
const userPLInItem = powerLevelAPI.getPowerLevel(
|
||||
itemPowerLevel,
|
||||
mx.getUserId() ?? undefined
|
||||
);
|
||||
const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent(
|
||||
itemPowerLevel,
|
||||
StateEvent.RoomJoinRules,
|
||||
userPLInItem
|
||||
);
|
||||
if (!canChangeJoinRuleAllow) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
getRoom(containerSpaceId) === undefined ||
|
||||
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[getRoom, space.roomId, roomsPowerLevels, canEditSpaceChild, mx]
|
||||
);
|
||||
|
||||
const reorderSpace = useCallback(
|
||||
(item: HierarchyItem, containerItem: HierarchyItem) => {
|
||||
if (!item.parentId) return;
|
||||
|
||||
const childItems = flattenHierarchy
|
||||
.filter((i) => i.parentId && i.space)
|
||||
.filter((i) => i.roomId !== item.roomId);
|
||||
|
||||
const beforeIndex = childItems.findIndex((i) => i.roomId === containerItem.roomId);
|
||||
const insertIndex = beforeIndex + 1;
|
||||
|
||||
childItems.splice(insertIndex, 0, {
|
||||
...item,
|
||||
content: { ...item.content, order: undefined },
|
||||
});
|
||||
|
||||
const currentOrders = childItems.map((i) => {
|
||||
if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
|
||||
return i.content.order;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const newOrders = orderKeys(lex, currentOrders);
|
||||
|
||||
newOrders?.forEach((orderKey, index) => {
|
||||
const itm = childItems[index];
|
||||
if (!itm || !itm.parentId) return;
|
||||
const parentPL = roomsPowerLevels.get(itm.parentId);
|
||||
const canEdit = parentPL && canEditSpaceChild(parentPL);
|
||||
if (canEdit && orderKey !== currentOrders[index]) {
|
||||
mx.sendStateEvent(
|
||||
itm.parentId,
|
||||
StateEvent.SpaceChild,
|
||||
{ ...itm.content, order: orderKey },
|
||||
itm.roomId
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
[mx, flattenHierarchy, lex, roomsPowerLevels, canEditSpaceChild]
|
||||
);
|
||||
|
||||
const reorderRoom = useCallback(
|
||||
(item: HierarchyItem, containerItem: HierarchyItem): void => {
|
||||
const itemRoom = mx.getRoom(item.roomId);
|
||||
if (!item.parentId) {
|
||||
return;
|
||||
}
|
||||
const containerParentId: string = containerItem.space
|
||||
? containerItem.roomId
|
||||
: containerItem.parentId;
|
||||
const itemContent = item.content;
|
||||
|
||||
if (item.parentId !== containerParentId) {
|
||||
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild, {}, item.roomId);
|
||||
}
|
||||
|
||||
if (
|
||||
itemRoom &&
|
||||
itemRoom.getJoinRule() === JoinRule.Restricted &&
|
||||
item.parentId !== containerParentId
|
||||
) {
|
||||
// change join rule allow parameter when dragging
|
||||
// restricted room from one space to another
|
||||
const joinRuleContent = getStateEvent(
|
||||
itemRoom,
|
||||
StateEvent.RoomJoinRules
|
||||
)?.getContent<IJoinRuleEventContent>();
|
||||
|
||||
if (joinRuleContent) {
|
||||
const allow =
|
||||
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? [];
|
||||
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
|
||||
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules, {
|
||||
...joinRuleContent,
|
||||
allow,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const childItems = flattenHierarchy
|
||||
.filter((i) => i.parentId === containerParentId && !i.space)
|
||||
.filter((i) => i.roomId !== item.roomId);
|
||||
|
||||
const beforeItem: HierarchyItem | undefined = containerItem.space ? undefined : containerItem;
|
||||
const beforeIndex = childItems.findIndex((i) => i.roomId === beforeItem?.roomId);
|
||||
const insertIndex = beforeIndex + 1;
|
||||
|
||||
childItems.splice(insertIndex, 0, {
|
||||
...item,
|
||||
parentId: containerParentId,
|
||||
content: { ...itemContent, order: undefined },
|
||||
});
|
||||
|
||||
const currentOrders = childItems.map((i) => {
|
||||
if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
|
||||
return i.content.order;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const newOrders = orderKeys(lex, currentOrders);
|
||||
|
||||
newOrders?.forEach((orderKey, index) => {
|
||||
const itm = childItems[index];
|
||||
if (itm && orderKey !== currentOrders[index]) {
|
||||
mx.sendStateEvent(
|
||||
containerParentId,
|
||||
StateEvent.SpaceChild,
|
||||
{ ...itm.content, order: orderKey },
|
||||
itm.roomId
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
[mx, flattenHierarchy, lex]
|
||||
);
|
||||
|
||||
useDnDMonitor(
|
||||
scrollRef,
|
||||
setDraggingItem,
|
||||
useCallback(
|
||||
(item, container) => {
|
||||
if (!canDrop(item, container)) {
|
||||
return;
|
||||
}
|
||||
if (item.space) {
|
||||
reorderSpace(item, container.item);
|
||||
} else {
|
||||
reorderRoom(item, container.item);
|
||||
}
|
||||
},
|
||||
[reorderRoom, reorderSpace, canDrop]
|
||||
)
|
||||
);
|
||||
|
||||
const addSpaceRoom = useCallback(
|
||||
(roomId: string) => setSpaceRooms({ type: 'PUT', roomId }),
|
||||
[setSpaceRooms]
|
||||
);
|
||||
|
||||
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
|
||||
closedCategories.has(categoryId)
|
||||
);
|
||||
|
||||
const handleOpenRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const rId = evt.currentTarget.getAttribute('data-room-id');
|
||||
if (!rId) return;
|
||||
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
|
||||
navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId)));
|
||||
};
|
||||
|
||||
const togglePinToSidebar = useCallback(
|
||||
(rId: string) => {
|
||||
const newItems = sidebarItemWithout(sidebarItems, rId);
|
||||
if (!sidebarSpaces.has(rId)) {
|
||||
newItems.push(rId);
|
||||
}
|
||||
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
|
||||
mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
|
||||
},
|
||||
[mx, sidebarItems, sidebarSpaces]
|
||||
);
|
||||
|
||||
return (
|
||||
<PowerLevelsContextProvider value={spacePowerLevels}>
|
||||
<Box grow="Yes">
|
||||
<Page>
|
||||
<LobbyHeader
|
||||
showProfile={!onTop}
|
||||
powerLevels={roomsPowerLevels.get(space.roomId) ?? {}}
|
||||
/>
|
||||
<Box style={{ position: 'relative' }} grow="Yes">
|
||||
<Scroll ref={scrollRef} hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<PageContentCenter>
|
||||
<ScrollTopContainer
|
||||
scrollRef={scrollRef}
|
||||
anchorRef={heroSectionRef}
|
||||
onVisibilityChange={setOnTop}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => virtualizer.scrollToOffset(0)}
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
outlined
|
||||
size="300"
|
||||
aria-label="Scroll to Top"
|
||||
>
|
||||
<Icon src={Icons.ChevronTop} size="300" />
|
||||
</IconButton>
|
||||
</ScrollTopContainer>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
<PageHeroSection ref={heroSectionRef} style={{ paddingTop: 0 }}>
|
||||
<LobbyHero />
|
||||
</PageHeroSection>
|
||||
{vItems.map((vItem) => {
|
||||
const item = flattenHierarchy[vItem.index];
|
||||
if (!item) return null;
|
||||
const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
|
||||
const userPLInItem = powerLevelAPI.getPowerLevel(
|
||||
itemPowerLevel,
|
||||
mx.getUserId() ?? undefined
|
||||
);
|
||||
const canInvite = powerLevelAPI.canDoAction(
|
||||
itemPowerLevel,
|
||||
'invite',
|
||||
userPLInItem
|
||||
);
|
||||
const isJoined = allJoinedRooms.has(item.roomId);
|
||||
|
||||
const nextRoomId: string | undefined =
|
||||
flattenHierarchy[vItem.index + 1]?.roomId;
|
||||
|
||||
const dragging =
|
||||
draggingItem?.roomId === item.roomId &&
|
||||
draggingItem.parentId === item.parentId;
|
||||
|
||||
if (item.space) {
|
||||
const categoryId = makeLobbyCategoryId(space.roomId, item.roomId);
|
||||
const { parentId } = item;
|
||||
const parentPowerLevels = parentId
|
||||
? roomsPowerLevels.get(parentId) ?? {}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
style={{
|
||||
paddingTop: vItem.index === 0 ? 0 : config.space.S500,
|
||||
}}
|
||||
ref={virtualizer.measureElement}
|
||||
key={vItem.index}
|
||||
>
|
||||
<SpaceItemCard
|
||||
item={item}
|
||||
joined={allJoinedRooms.has(item.roomId)}
|
||||
categoryId={categoryId}
|
||||
closed={closedCategories.has(categoryId) || !!draggingItem?.space}
|
||||
handleClose={handleCategoryClick}
|
||||
getRoom={getRoom}
|
||||
canEditChild={canEditSpaceChild(
|
||||
roomsPowerLevels.get(item.roomId) ?? {}
|
||||
)}
|
||||
canReorder={
|
||||
parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false
|
||||
}
|
||||
options={
|
||||
parentId &&
|
||||
parentPowerLevels && (
|
||||
<HierarchyItemMenu
|
||||
item={{ ...item, parentId }}
|
||||
canInvite={canInvite}
|
||||
joined={isJoined}
|
||||
canEditChild={canEditSpaceChild(parentPowerLevels)}
|
||||
pinned={sidebarSpaces.has(item.roomId)}
|
||||
onTogglePin={togglePinToSidebar}
|
||||
/>
|
||||
)
|
||||
}
|
||||
before={item.parentId ? undefined : undefined}
|
||||
after={
|
||||
<AfterItemDropTarget
|
||||
item={item}
|
||||
nextRoomId={nextRoomId}
|
||||
afterSpace
|
||||
canDrop={canDrop}
|
||||
/>
|
||||
}
|
||||
onDragging={setDraggingItem}
|
||||
data-dragging={dragging}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
}
|
||||
|
||||
const parentPowerLevels = roomsPowerLevels.get(item.parentId) ?? {};
|
||||
const prevItem: HierarchyItem | undefined = flattenHierarchy[vItem.index - 1];
|
||||
const nextItem: HierarchyItem | undefined = flattenHierarchy[vItem.index + 1];
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
style={{ paddingTop: config.space.S100 }}
|
||||
ref={virtualizer.measureElement}
|
||||
key={vItem.index}
|
||||
>
|
||||
<RoomItemCard
|
||||
item={item}
|
||||
onSpaceFound={addSpaceRoom}
|
||||
dm={mDirects.has(item.roomId)}
|
||||
firstChild={!prevItem || prevItem.space === true}
|
||||
lastChild={!nextItem || nextItem.space === true}
|
||||
onOpen={handleOpenRoom}
|
||||
getRoom={getRoom}
|
||||
canReorder={canEditSpaceChild(parentPowerLevels)}
|
||||
options={
|
||||
<HierarchyItemMenu
|
||||
item={item}
|
||||
canInvite={canInvite}
|
||||
joined={isJoined}
|
||||
canEditChild={canEditSpaceChild(parentPowerLevels)}
|
||||
/>
|
||||
}
|
||||
after={
|
||||
<AfterItemDropTarget
|
||||
item={item}
|
||||
nextRoomId={nextRoomId}
|
||||
canDrop={canDrop}
|
||||
/>
|
||||
}
|
||||
data-dragging={dragging}
|
||||
onDragging={setDraggingItem}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PageContentCenter>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
{screenSize === ScreenSize.Desktop && isDrawer && (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<MembersDrawer room={space} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</PowerLevelsContextProvider>
|
||||
);
|
||||
}
|
||||
13
src/app/features/lobby/LobbyHeader.css.ts
Normal file
13
src/app/features/lobby/LobbyHeader.css.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
|
||||
export const Header = style({
|
||||
borderBottomColor: 'transparent',
|
||||
});
|
||||
export const HeaderTopic = style({
|
||||
':hover': {
|
||||
cursor: 'pointer',
|
||||
opacity: config.opacity.P500,
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
});
|
||||
214
src/app/features/lobby/LobbyHeader.tsx
Normal file
214
src/app/features/lobby/LobbyHeader.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import React, { MouseEventHandler, forwardRef, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Line,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { PageHeader } from '../../components/page';
|
||||
import { useSetSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta';
|
||||
import { useSpace } from '../../hooks/useSpace';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { RoomAvatar } from '../../components/room-avatar';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import * as css from './LobbyHeader.css';
|
||||
import { openInviteUser, openSpaceSettings } from '../../../client/action/navigation';
|
||||
import { IPowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
|
||||
|
||||
type LobbyMenuProps = {
|
||||
roomId: string;
|
||||
powerLevels: IPowerLevels;
|
||||
requestClose: () => void;
|
||||
};
|
||||
const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
|
||||
({ roomId, powerLevels, requestClose }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
|
||||
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
|
||||
|
||||
const handleInvite = () => {
|
||||
openInviteUser(roomId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const handleRoomSettings = () => {
|
||||
openSpaceSettings(roomId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleInvite}
|
||||
variant="Primary"
|
||||
fill="None"
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.UserPlus} />}
|
||||
radii="300"
|
||||
disabled={!canInvite}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Invite
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleRoomSettings}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Setting} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Space Settings
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<UseStateProvider initial={false}>
|
||||
{(promptLeave, setPromptLeave) => (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => setPromptLeave(true)}
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
|
||||
radii="300"
|
||||
aria-pressed={promptLeave}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Leave Space
|
||||
</Text>
|
||||
</MenuItem>
|
||||
{promptLeave && (
|
||||
<LeaveSpacePrompt
|
||||
roomId={roomId}
|
||||
onDone={requestClose}
|
||||
onCancel={() => setPromptLeave(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
type LobbyHeaderProps = {
|
||||
showProfile?: boolean;
|
||||
powerLevels: IPowerLevels;
|
||||
};
|
||||
export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
||||
const mx = useMatrixClient();
|
||||
const space = useSpace();
|
||||
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
const name = useRoomName(space);
|
||||
const avatarMxc = useRoomAvatar(space);
|
||||
const avatarUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc, 96, 96, 'crop') ?? undefined : undefined;
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
return (
|
||||
<PageHeader className={showProfile ? undefined : css.Header}>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes" basis="No" />
|
||||
<Box justifyContent="Center" alignItems="Center" gap="300">
|
||||
{showProfile && (
|
||||
<>
|
||||
<Avatar size="300">
|
||||
<RoomAvatar
|
||||
roomId={space.roomId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => <Text size="H4">{nameInitials(name)}</Text>}
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="H3" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<Box shrink="No" grow="Yes" basis="No" justifyContent="End">
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Members</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
|
||||
<Icon size="400" src={Icons.User} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>More Options</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
|
||||
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
}}
|
||||
>
|
||||
<LobbyMenu
|
||||
roomId={space.roomId}
|
||||
powerLevels={powerLevels}
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
15
src/app/features/lobby/LobbyHero.css.tsx
Normal file
15
src/app/features/lobby/LobbyHero.css.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
|
||||
export const LobbyHeroTopic = style({
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
|
||||
':hover': {
|
||||
cursor: 'pointer',
|
||||
opacity: config.opacity.P500,
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
});
|
||||
77
src/app/features/lobby/LobbyHero.tsx
Normal file
77
src/app/features/lobby/LobbyHero.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React from 'react';
|
||||
import { Avatar, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
||||
import { useSpace } from '../../hooks/useSpace';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { RoomAvatar } from '../../components/room-avatar';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||
import * as css from './LobbyHero.css';
|
||||
import { PageHero } from '../../components/page';
|
||||
import { onEnterOrSpace } from '../../utils/keyboard';
|
||||
|
||||
export function LobbyHero() {
|
||||
const mx = useMatrixClient();
|
||||
const space = useSpace();
|
||||
|
||||
const name = useRoomName(space);
|
||||
const topic = useRoomTopic(space);
|
||||
const avatarMxc = useRoomAvatar(space);
|
||||
const avatarUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc, 96, 96, 'crop') ?? undefined : undefined;
|
||||
|
||||
return (
|
||||
<PageHero
|
||||
icon={
|
||||
<Avatar size="500">
|
||||
<RoomAvatar
|
||||
roomId={space.roomId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => <Text size="H4">{nameInitials(name)}</Text>}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
title={name}
|
||||
subTitle={
|
||||
topic && (
|
||||
<UseStateProvider initial={false}>
|
||||
{(viewTopic, setViewTopic) => (
|
||||
<>
|
||||
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
clickOutsideDeactivates: true,
|
||||
onDeactivate: () => setViewTopic(false),
|
||||
}}
|
||||
>
|
||||
<RoomTopicViewer
|
||||
name={name}
|
||||
topic={topic}
|
||||
requestClose={() => setViewTopic(false)}
|
||||
/>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
<Text
|
||||
as="span"
|
||||
onClick={() => setViewTopic(true)}
|
||||
onKeyDown={onEnterOrSpace(() => setViewTopic(true))}
|
||||
tabIndex={0}
|
||||
className={css.LobbyHeroTopic}
|
||||
size="Inherit"
|
||||
priority="300"
|
||||
>
|
||||
{topic}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
22
src/app/features/lobby/RoomItem.css.ts
Normal file
22
src/app/features/lobby/RoomItem.css.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
|
||||
export const RoomItemCard = style({
|
||||
padding: config.space.S400,
|
||||
borderRadius: 0,
|
||||
position: 'relative',
|
||||
selectors: {
|
||||
'&[data-dragging=true]': {
|
||||
opacity: config.opacity.Disabled,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const RoomProfileTopic = style({
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
});
|
||||
export const ErrorNameContainer = style({
|
||||
gap: toRem(2),
|
||||
});
|
||||
441
src/app/features/lobby/RoomItem.tsx
Normal file
441
src/app/features/lobby/RoomItem.tsx
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Chip,
|
||||
Icon,
|
||||
Icons,
|
||||
Line,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
as,
|
||||
color,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
|
||||
import { millify } from '../../plugins/millify';
|
||||
import {
|
||||
HierarchyRoomSummaryLoader,
|
||||
LocalRoomSummaryLoader,
|
||||
} from '../../components/RoomSummaryLoader';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||
import { onEnterOrSpace } from '../../utils/keyboard';
|
||||
import { Membership, RoomType } from '../../../types/matrix/room';
|
||||
import * as css from './RoomItem.css';
|
||||
import * as styleCss from './style.css';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { ErrorCode } from '../../cs-errorcode';
|
||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
|
||||
import { ItemDraggableTarget, useDraggableItem } from './DnD';
|
||||
|
||||
type RoomJoinButtonProps = {
|
||||
roomId: string;
|
||||
via?: string[];
|
||||
};
|
||||
function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
||||
useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
|
||||
);
|
||||
|
||||
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
|
||||
|
||||
return (
|
||||
<Box shrink="No" gap="200" alignItems="Center">
|
||||
{joinState.status === AsyncStatus.Error && (
|
||||
<TooltipProvider
|
||||
tooltip={
|
||||
<Tooltip variant="Critical" style={{ maxWidth: toRem(200) }}>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text style={{ wordBreak: 'break-word' }} size="T400">
|
||||
{joinState.error.data?.error || joinState.error.message}
|
||||
</Text>
|
||||
<Text size="T200">{joinState.error.name}</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Icon
|
||||
ref={triggerRef}
|
||||
style={{ color: color.Critical.Main, cursor: 'pointer' }}
|
||||
src={Icons.Warning}
|
||||
size="400"
|
||||
filled
|
||||
tabIndex={0}
|
||||
aria-label={joinState.error.data?.error || joinState.error.message}
|
||||
/>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="400"
|
||||
radii="Pill"
|
||||
before={
|
||||
canJoin ? <Icon src={Icons.Plus} size="50" /> : <Spinner variant="Secondary" size="100" />
|
||||
}
|
||||
onClick={join}
|
||||
disabled={!canJoin}
|
||||
>
|
||||
<Text size="B300">Join</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomProfileLoading() {
|
||||
return (
|
||||
<Box grow="Yes" gap="300">
|
||||
<Avatar className={styleCss.AvatarPlaceholder} />
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Box className={styleCss.LinePlaceholder} shrink="No" style={{ maxWidth: toRem(80) }} />
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Box className={styleCss.LinePlaceholder} shrink="No" style={{ maxWidth: toRem(40) }} />
|
||||
<Box
|
||||
className={styleCss.LinePlaceholder}
|
||||
shrink="No"
|
||||
style={{
|
||||
maxWidth: toRem(120),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type RoomProfileErrorProps = {
|
||||
roomId: string;
|
||||
error: Error;
|
||||
suggested?: boolean;
|
||||
via?: string[];
|
||||
};
|
||||
function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorProps) {
|
||||
const privateRoom = error.name === ErrorCode.M_FORBIDDEN;
|
||||
|
||||
return (
|
||||
<Box grow="Yes" gap="300">
|
||||
<Avatar>
|
||||
<RoomAvatar
|
||||
roomId={roomId}
|
||||
src={undefined}
|
||||
alt={roomId}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
size="300"
|
||||
joinRule={privateRoom ? JoinRule.Invite : JoinRule.Restricted}
|
||||
filled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box grow="Yes" direction="Column" className={css.ErrorNameContainer}>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="H5" truncate>
|
||||
Unknown
|
||||
</Text>
|
||||
{suggested && (
|
||||
<Box shrink="No" alignItems="Center">
|
||||
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
|
||||
<Text size="L400">Suggested</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
{privateRoom && (
|
||||
<>
|
||||
<Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||
<Text size="L400">Private Room</Text>
|
||||
</Badge>
|
||||
<Line
|
||||
variant="SurfaceVariant"
|
||||
style={{ height: toRem(12) }}
|
||||
direction="Vertical"
|
||||
size="400"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Text size="T200" truncate>
|
||||
{roomId}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{!privateRoom && <RoomJoinButton roomId={roomId} via={via} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type RoomProfileProps = {
|
||||
roomId: string;
|
||||
name: string;
|
||||
topic?: string;
|
||||
avatarUrl?: string;
|
||||
suggested?: boolean;
|
||||
memberCount?: number;
|
||||
joinRule?: JoinRule;
|
||||
options?: ReactNode;
|
||||
};
|
||||
function RoomProfile({
|
||||
roomId,
|
||||
name,
|
||||
topic,
|
||||
avatarUrl,
|
||||
suggested,
|
||||
memberCount,
|
||||
joinRule,
|
||||
options,
|
||||
}: RoomProfileProps) {
|
||||
return (
|
||||
<Box grow="Yes" gap="300">
|
||||
<Avatar>
|
||||
<RoomAvatar
|
||||
roomId={roomId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon size="300" joinRule={joinRule ?? JoinRule.Restricted} filled />
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="H5" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
{suggested && (
|
||||
<Box shrink="No" alignItems="Center">
|
||||
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
|
||||
<Text size="L400">Suggested</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box gap="200" alignItems="Center">
|
||||
{memberCount && (
|
||||
<Box shrink="No" gap="200">
|
||||
<Text size="T200" priority="300">{`${millify(memberCount)} Members`}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{memberCount && topic && (
|
||||
<Line
|
||||
variant="SurfaceVariant"
|
||||
style={{ height: toRem(12) }}
|
||||
direction="Vertical"
|
||||
size="400"
|
||||
/>
|
||||
)}
|
||||
{topic && (
|
||||
<UseStateProvider initial={false}>
|
||||
{(view, setView) => (
|
||||
<>
|
||||
<Text
|
||||
className={css.RoomProfileTopic}
|
||||
size="T200"
|
||||
priority="300"
|
||||
truncate
|
||||
onClick={() => setView(true)}
|
||||
onKeyDown={onEnterOrSpace(() => setView(true))}
|
||||
tabIndex={0}
|
||||
>
|
||||
{topic}
|
||||
</Text>
|
||||
<Overlay open={view} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
clickOutsideDeactivates: true,
|
||||
onDeactivate: () => setView(false),
|
||||
}}
|
||||
>
|
||||
<RoomTopicViewer
|
||||
name={name}
|
||||
topic={topic}
|
||||
requestClose={() => setView(false)}
|
||||
/>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
</>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{options}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CallbackOnFoundSpace({
|
||||
roomId,
|
||||
onSpaceFound,
|
||||
}: {
|
||||
roomId: string;
|
||||
onSpaceFound: (roomId: string) => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
onSpaceFound(roomId);
|
||||
}, [roomId, onSpaceFound]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type RoomItemCardProps = {
|
||||
item: HierarchyItem;
|
||||
onSpaceFound: (roomId: string) => void;
|
||||
dm?: boolean;
|
||||
firstChild?: boolean;
|
||||
lastChild?: boolean;
|
||||
onOpen: MouseEventHandler<HTMLButtonElement>;
|
||||
options?: ReactNode;
|
||||
before?: ReactNode;
|
||||
after?: ReactNode;
|
||||
onDragging: (item?: HierarchyItem) => void;
|
||||
canReorder: boolean;
|
||||
getRoom: (roomId: string) => Room | undefined;
|
||||
};
|
||||
export const RoomItemCard = as<'div', RoomItemCardProps>(
|
||||
(
|
||||
{
|
||||
item,
|
||||
onSpaceFound,
|
||||
dm,
|
||||
firstChild,
|
||||
lastChild,
|
||||
onOpen,
|
||||
options,
|
||||
before,
|
||||
after,
|
||||
onDragging,
|
||||
canReorder,
|
||||
getRoom,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
const { roomId, content } = item;
|
||||
const room = getRoom(roomId);
|
||||
const targetRef = useRef<HTMLDivElement>(null);
|
||||
const targetHandleRef = useRef<HTMLDivElement>(null);
|
||||
useDraggableItem(item, targetRef, onDragging, targetHandleRef);
|
||||
|
||||
const joined = room?.getMyMembership() === Membership.Join;
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
className={css.RoomItemCard}
|
||||
firstChild={firstChild}
|
||||
lastChild={lastChild}
|
||||
variant="SurfaceVariant"
|
||||
gap="300"
|
||||
alignItems="Center"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{before}
|
||||
<Box ref={canReorder ? targetRef : null} grow="Yes">
|
||||
{canReorder && <ItemDraggableTarget ref={targetHandleRef} />}
|
||||
{room ? (
|
||||
<LocalRoomSummaryLoader room={room}>
|
||||
{(localSummary) => (
|
||||
<RoomProfile
|
||||
roomId={roomId}
|
||||
name={localSummary.name}
|
||||
topic={localSummary.topic}
|
||||
avatarUrl={
|
||||
dm ? getDirectRoomAvatarUrl(mx, room, 96) : getRoomAvatarUrl(mx, room, 96)
|
||||
}
|
||||
memberCount={localSummary.memberCount}
|
||||
suggested={content.suggested}
|
||||
joinRule={localSummary.joinRule}
|
||||
options={
|
||||
joined ? (
|
||||
<Box shrink="No" gap="100" alignItems="Center">
|
||||
<Chip
|
||||
data-room-id={roomId}
|
||||
onClick={onOpen}
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
size="400"
|
||||
radii="Pill"
|
||||
aria-label="Open Room"
|
||||
>
|
||||
<Icon size="50" src={Icons.ArrowRight} />
|
||||
</Chip>
|
||||
</Box>
|
||||
) : (
|
||||
<RoomJoinButton roomId={roomId} via={content.via} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</LocalRoomSummaryLoader>
|
||||
) : (
|
||||
<HierarchyRoomSummaryLoader roomId={roomId}>
|
||||
{(summaryState) => (
|
||||
<>
|
||||
{summaryState.status === AsyncStatus.Loading && <RoomProfileLoading />}
|
||||
{summaryState.status === AsyncStatus.Error && (
|
||||
<RoomProfileError
|
||||
roomId={roomId}
|
||||
error={summaryState.error}
|
||||
suggested={content.suggested}
|
||||
via={content.via}
|
||||
/>
|
||||
)}
|
||||
{summaryState.status === AsyncStatus.Success && (
|
||||
<>
|
||||
{summaryState.data.room_type === RoomType.Space && (
|
||||
<CallbackOnFoundSpace
|
||||
roomId={summaryState.data.room_id}
|
||||
onSpaceFound={onSpaceFound}
|
||||
/>
|
||||
)}
|
||||
<RoomProfile
|
||||
roomId={roomId}
|
||||
name={summaryState.data.name || summaryState.data.canonical_alias || roomId}
|
||||
topic={summaryState.data.topic}
|
||||
avatarUrl={
|
||||
summaryState.data?.avatar_url
|
||||
? mx.mxcUrlToHttp(summaryState.data.avatar_url, 96, 96, 'crop') ??
|
||||
undefined
|
||||
: undefined
|
||||
}
|
||||
memberCount={summaryState.data.num_joined_members}
|
||||
suggested={content.suggested}
|
||||
joinRule={summaryState.data.join_rule}
|
||||
options={<RoomJoinButton roomId={roomId} via={content.via} />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</HierarchyRoomSummaryLoader>
|
||||
)}
|
||||
</Box>
|
||||
{options}
|
||||
{after}
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
);
|
||||
39
src/app/features/lobby/SpaceItem.css.ts
Normal file
39
src/app/features/lobby/SpaceItem.css.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
import { recipe } from '@vanilla-extract/recipes';
|
||||
|
||||
export const SpaceItemCard = recipe({
|
||||
base: {
|
||||
paddingBottom: config.space.S100,
|
||||
borderBottom: `${config.borderWidth.B300} solid transparent`,
|
||||
position: 'relative',
|
||||
selectors: {
|
||||
'&[data-dragging=true]': {
|
||||
opacity: config.opacity.Disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
outlined: {
|
||||
true: {
|
||||
borderBottomColor: color.Surface.ContainerLine,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
export const HeaderChip = style({
|
||||
paddingLeft: config.space.S200,
|
||||
selectors: {
|
||||
[`&[data-ui-before="true"]`]: {
|
||||
paddingLeft: config.space.S100,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const HeaderChipPlaceholder = style([
|
||||
{
|
||||
borderRadius: config.radii.R400,
|
||||
paddingLeft: config.space.S100,
|
||||
paddingRight: config.space.S300,
|
||||
height: toRem(32),
|
||||
},
|
||||
]);
|
||||
493
src/app/features/lobby/SpaceItem.tsx
Normal file
493
src/app/features/lobby/SpaceItem.tsx
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
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 FocusTrap from 'focus-trap-react';
|
||||
import classNames from 'classnames';
|
||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { RoomAvatar } from '../../components/room-avatar';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import {
|
||||
HierarchyRoomSummaryLoader,
|
||||
LocalRoomSummaryLoader,
|
||||
} from '../../components/RoomSummaryLoader';
|
||||
import { getRoomAvatarUrl } from '../../utils/room';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import * as css from './SpaceItem.css';
|
||||
import * as styleCss from './style.css';
|
||||
import { ErrorCode } from '../../cs-errorcode';
|
||||
import { useDraggableItem } from './DnD';
|
||||
import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation';
|
||||
|
||||
function SpaceProfileLoading() {
|
||||
return (
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Box grow="Yes" gap="200" alignItems="Center" className={css.HeaderChipPlaceholder}>
|
||||
<Avatar className={styleCss.AvatarPlaceholder} size="200" radii="300" />
|
||||
<Box
|
||||
className={styleCss.LinePlaceholder}
|
||||
shrink="No"
|
||||
style={{ width: '100vw', maxWidth: toRem(120) }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type UnknownPrivateSpaceProfileProps = {
|
||||
roomId: string;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
suggested?: boolean;
|
||||
};
|
||||
function UnknownPrivateSpaceProfile({
|
||||
roomId,
|
||||
name,
|
||||
avatarUrl,
|
||||
suggested,
|
||||
}: UnknownPrivateSpaceProfileProps) {
|
||||
return (
|
||||
<Chip
|
||||
as="span"
|
||||
className={css.HeaderChip}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
before={
|
||||
<Avatar size="200" radii="300">
|
||||
<RoomAvatar
|
||||
roomId={roomId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(name)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Text size="H4" truncate>
|
||||
{name || 'Unknown'}
|
||||
</Text>
|
||||
|
||||
<Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||
<Text size="L400">Private Space</Text>
|
||||
</Badge>
|
||||
{suggested && (
|
||||
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
|
||||
<Text size="L400">Suggested</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
type UnknownSpaceProfileProps = {
|
||||
roomId: string;
|
||||
via?: string[];
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
suggested?: boolean;
|
||||
};
|
||||
function UnknownSpaceProfile({
|
||||
roomId,
|
||||
via,
|
||||
name,
|
||||
avatarUrl,
|
||||
suggested,
|
||||
}: UnknownSpaceProfileProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
||||
useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
|
||||
);
|
||||
|
||||
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
|
||||
return (
|
||||
<Chip
|
||||
className={css.HeaderChip}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
onClick={join}
|
||||
disabled={!canJoin}
|
||||
before={
|
||||
<Avatar size="200" radii="300">
|
||||
<RoomAvatar
|
||||
roomId={roomId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(name)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
after={
|
||||
canJoin ? <Icon src={Icons.Plus} size="50" /> : <Spinner variant="Secondary" size="200" />
|
||||
}
|
||||
>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Text size="H4" truncate>
|
||||
{name || 'Unknown'}
|
||||
</Text>
|
||||
{suggested && (
|
||||
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
|
||||
<Text size="L400">Suggested</Text>
|
||||
</Badge>
|
||||
)}
|
||||
{joinState.status === AsyncStatus.Error && (
|
||||
<Badge variant="Critical" fill="Soft" radii="Pill" outlined>
|
||||
<Text size="L400" truncate>
|
||||
{joinState.error.name}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
type SpaceProfileProps = {
|
||||
roomId: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
suggested?: boolean;
|
||||
closed: boolean;
|
||||
categoryId: string;
|
||||
handleClose?: MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
function SpaceProfile({
|
||||
roomId,
|
||||
name,
|
||||
avatarUrl,
|
||||
suggested,
|
||||
closed,
|
||||
categoryId,
|
||||
handleClose,
|
||||
}: SpaceProfileProps) {
|
||||
return (
|
||||
<Chip
|
||||
data-category-id={categoryId}
|
||||
onClick={handleClose}
|
||||
className={css.HeaderChip}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
before={
|
||||
<Avatar size="200" radii="300">
|
||||
<RoomAvatar
|
||||
roomId={roomId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(name)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
after={<Icon src={closed ? Icons.ChevronRight : Icons.ChevronBottom} size="50" />}
|
||||
>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Text size="H4" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
{suggested && (
|
||||
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
|
||||
<Text size="L400">Suggested</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
type RootSpaceProfileProps = {
|
||||
closed: boolean;
|
||||
categoryId: string;
|
||||
handleClose?: MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileProps) {
|
||||
return (
|
||||
<Chip
|
||||
data-category-id={categoryId}
|
||||
onClick={handleClose}
|
||||
className={css.HeaderChip}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
after={<Icon src={closed ? Icons.ChevronRight : Icons.ChevronBottom} size="50" />}
|
||||
>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Text size="H4" truncate>
|
||||
Rooms
|
||||
</Text>
|
||||
</Box>
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
function AddRoomButton({ item }: { item: HierarchyItem }) {
|
||||
const [cords, setCords] = useState<RectCords>();
|
||||
|
||||
const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleCreateRoom = () => {
|
||||
openCreateRoom(false, item.roomId as any);
|
||||
setCords(undefined);
|
||||
};
|
||||
|
||||
const handleAddExisting = () => {
|
||||
openSpaceAddExisting(item.roomId);
|
||||
setCords(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
}}
|
||||
>
|
||||
<Menu style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Primary"
|
||||
fill="None"
|
||||
onClick={handleCreateRoom}
|
||||
>
|
||||
<Text size="T300">New Room</Text>
|
||||
</MenuItem>
|
||||
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
|
||||
<Text size="T300">Existing Room</Text>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
before={<Icon src={Icons.Plus} size="50" />}
|
||||
onClick={handleAddRoom}
|
||||
aria-pressed={!!cords}
|
||||
>
|
||||
<Text size="B300">Add Room</Text>
|
||||
</Chip>
|
||||
</PopOut>
|
||||
);
|
||||
}
|
||||
|
||||
function AddSpaceButton({ item }: { item: HierarchyItem }) {
|
||||
const [cords, setCords] = useState<RectCords>();
|
||||
|
||||
const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleCreateSpace = () => {
|
||||
openCreateRoom(true, item.roomId as any);
|
||||
setCords(undefined);
|
||||
};
|
||||
|
||||
const handleAddExisting = () => {
|
||||
openSpaceAddExisting(item.roomId, true);
|
||||
setCords(undefined);
|
||||
};
|
||||
return (
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
}}
|
||||
>
|
||||
<Menu style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Primary"
|
||||
fill="None"
|
||||
onClick={handleCreateSpace}
|
||||
>
|
||||
<Text size="T300">New Space</Text>
|
||||
</MenuItem>
|
||||
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
|
||||
<Text size="T300">Existing Space</Text>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
before={<Icon src={Icons.Plus} size="50" />}
|
||||
onClick={handleAddSpace}
|
||||
aria-pressed={!!cords}
|
||||
>
|
||||
<Text size="B300">Add Space</Text>
|
||||
</Chip>
|
||||
</PopOut>
|
||||
);
|
||||
}
|
||||
|
||||
type SpaceItemCardProps = {
|
||||
item: HierarchyItem;
|
||||
joined?: boolean;
|
||||
categoryId: string;
|
||||
closed: boolean;
|
||||
handleClose?: MouseEventHandler<HTMLButtonElement>;
|
||||
options?: ReactNode;
|
||||
before?: ReactNode;
|
||||
after?: ReactNode;
|
||||
canEditChild: boolean;
|
||||
canReorder: boolean;
|
||||
onDragging: (item?: HierarchyItem) => void;
|
||||
getRoom: (roomId: string) => Room | undefined;
|
||||
};
|
||||
export const SpaceItemCard = as<'div', SpaceItemCardProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
joined,
|
||||
closed,
|
||||
categoryId,
|
||||
item,
|
||||
handleClose,
|
||||
options,
|
||||
before,
|
||||
after,
|
||||
canEditChild,
|
||||
canReorder,
|
||||
onDragging,
|
||||
getRoom,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
const { roomId, content } = item;
|
||||
const space = getRoom(roomId);
|
||||
const targetRef = useRef<HTMLDivElement>(null);
|
||||
useDraggableItem(item, targetRef, onDragging);
|
||||
|
||||
return (
|
||||
<Box
|
||||
shrink="No"
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
className={classNames(css.SpaceItemCard({ outlined: !joined || closed }), className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{before}
|
||||
<Box grow="Yes" gap="100" alignItems="Inherit" justifyContent="SpaceBetween">
|
||||
<Box ref={canReorder ? targetRef : null}>
|
||||
{space ? (
|
||||
<LocalRoomSummaryLoader room={space}>
|
||||
{(localSummary) =>
|
||||
item.parentId ? (
|
||||
<SpaceProfile
|
||||
roomId={roomId}
|
||||
name={localSummary.name}
|
||||
avatarUrl={getRoomAvatarUrl(mx, space, 96)}
|
||||
suggested={content.suggested}
|
||||
closed={closed}
|
||||
categoryId={categoryId}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
) : (
|
||||
<RootSpaceProfile
|
||||
closed={closed}
|
||||
categoryId={categoryId}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</LocalRoomSummaryLoader>
|
||||
) : (
|
||||
<HierarchyRoomSummaryLoader roomId={roomId}>
|
||||
{(summaryState) => (
|
||||
<>
|
||||
{summaryState.status === AsyncStatus.Loading && <SpaceProfileLoading />}
|
||||
{summaryState.status === AsyncStatus.Error &&
|
||||
(summaryState.error.name === ErrorCode.M_FORBIDDEN ? (
|
||||
<UnknownPrivateSpaceProfile roomId={roomId} suggested={content.suggested} />
|
||||
) : (
|
||||
<UnknownSpaceProfile
|
||||
roomId={roomId}
|
||||
via={item.content.via}
|
||||
suggested={content.suggested}
|
||||
/>
|
||||
))}
|
||||
{summaryState.status === AsyncStatus.Success && (
|
||||
<UnknownSpaceProfile
|
||||
roomId={roomId}
|
||||
via={item.content.via}
|
||||
name={summaryState.data.name || summaryState.data.canonical_alias || roomId}
|
||||
avatarUrl={
|
||||
summaryState.data?.avatar_url
|
||||
? mx.mxcUrlToHttp(summaryState.data.avatar_url, 96, 96, 'crop') ??
|
||||
undefined
|
||||
: undefined
|
||||
}
|
||||
suggested={content.suggested}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</HierarchyRoomSummaryLoader>
|
||||
)}
|
||||
</Box>
|
||||
{canEditChild && (
|
||||
<Box alignItems="Inherit" gap="200">
|
||||
<AddRoomButton item={item} />
|
||||
{item.parentId === undefined && <AddSpaceButton item={item} />}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{options}
|
||||
{after}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
1
src/app/features/lobby/index.ts
Normal file
1
src/app/features/lobby/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Lobby';
|
||||
15
src/app/features/lobby/style.css.ts
Normal file
15
src/app/features/lobby/style.css.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config } from 'folds';
|
||||
|
||||
export const AvatarPlaceholder = style({
|
||||
backgroundColor: color.Secondary.Container,
|
||||
});
|
||||
export const LinePlaceholder = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: '100%',
|
||||
height: config.lineHeight.T200,
|
||||
borderRadius: config.radii.R300,
|
||||
backgroundColor: color.Secondary.Container,
|
||||
},
|
||||
]);
|
||||
329
src/app/features/message-search/MessageSearch.tsx
Normal file
329
src/app/features/message-search/MessageSearch.tsx
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import React, { RefObject, useEffect, useMemo, useRef } from 'react';
|
||||
import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem } from 'folds';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { SearchOrderBy } from 'matrix-js-sdk';
|
||||
import { PageHero, PageHeroSection } from '../../components/page';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { _SearchPathSearchParams } from '../../pages/paths';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
|
||||
import { useRooms } from '../../state/hooks/roomList';
|
||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
|
||||
import { SearchResultGroup } from './SearchResultGroup';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { SearchFilters } from './SearchFilters';
|
||||
import { VirtualTile } from '../../components/virtualizer';
|
||||
|
||||
const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams =>
|
||||
useMemo(
|
||||
() => ({
|
||||
global: searchParams.get('global') ?? undefined,
|
||||
term: searchParams.get('term') ?? undefined,
|
||||
order: searchParams.get('order') ?? undefined,
|
||||
rooms: searchParams.get('rooms') ?? undefined,
|
||||
senders: searchParams.get('senders') ?? undefined,
|
||||
}),
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
type MessageSearchProps = {
|
||||
defaultRoomsFilterName: string;
|
||||
allowGlobal?: boolean;
|
||||
rooms: string[];
|
||||
senders?: string[];
|
||||
scrollRef: RefObject<HTMLDivElement>;
|
||||
};
|
||||
export function MessageSearch({
|
||||
defaultRoomsFilterName,
|
||||
allowGlobal,
|
||||
rooms,
|
||||
senders,
|
||||
scrollRef,
|
||||
}: MessageSearchProps) {
|
||||
const mx = useMatrixClient();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const allRooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const searchPathSearchParams = useSearchPathSearchParams(searchParams);
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const searchParamRooms = useMemo(() => {
|
||||
if (searchPathSearchParams.rooms) {
|
||||
const joinedRoomIds = decodeSearchParamValueArray(searchPathSearchParams.rooms).filter(
|
||||
(rId) => allRooms.includes(rId)
|
||||
);
|
||||
return joinedRoomIds;
|
||||
}
|
||||
return undefined;
|
||||
}, [allRooms, searchPathSearchParams.rooms]);
|
||||
const searchParamsSenders = useMemo(() => {
|
||||
if (searchPathSearchParams.senders) {
|
||||
return decodeSearchParamValueArray(searchPathSearchParams.senders);
|
||||
}
|
||||
return undefined;
|
||||
}, [searchPathSearchParams.senders]);
|
||||
|
||||
const msgSearchParams: MessageSearchParams = useMemo(() => {
|
||||
const isGlobal = searchPathSearchParams.global === 'true';
|
||||
const defaultRooms = isGlobal ? undefined : rooms;
|
||||
|
||||
return {
|
||||
term: searchPathSearchParams.term,
|
||||
order: searchPathSearchParams.order ?? SearchOrderBy.Recent,
|
||||
rooms: searchParamRooms ?? defaultRooms,
|
||||
senders: searchParamsSenders ?? senders,
|
||||
};
|
||||
}, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]);
|
||||
|
||||
const searchMessages = useMessageSearch(msgSearchParams);
|
||||
|
||||
const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
|
||||
enabled: !!msgSearchParams.term,
|
||||
queryKey: [
|
||||
'search',
|
||||
msgSearchParams.term,
|
||||
msgSearchParams.order,
|
||||
msgSearchParams.rooms,
|
||||
msgSearchParams.senders,
|
||||
],
|
||||
queryFn: ({ pageParam }) => searchMessages(pageParam),
|
||||
initialPageParam: '',
|
||||
getNextPageParam: (lastPage) => lastPage.nextToken,
|
||||
});
|
||||
|
||||
const groups = useMemo(() => data?.pages.flatMap((result) => result.groups) ?? [], [data]);
|
||||
const highlights = useMemo(() => {
|
||||
const mixed = data?.pages.flatMap((result) => result.highlights);
|
||||
return Array.from(new Set(mixed));
|
||||
}, [data]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: groups.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 40,
|
||||
overscan: 1,
|
||||
});
|
||||
const vItems = virtualizer.getVirtualItems();
|
||||
|
||||
const handleSearch = (term: string) => {
|
||||
setSearchParams((prevParams) => {
|
||||
const newParams = new URLSearchParams(prevParams);
|
||||
newParams.delete('term');
|
||||
newParams.append('term', term);
|
||||
return newParams;
|
||||
});
|
||||
};
|
||||
const handleSearchClear = () => {
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.value = '';
|
||||
}
|
||||
setSearchParams((prevParams) => {
|
||||
const newParams = new URLSearchParams(prevParams);
|
||||
newParams.delete('term');
|
||||
return newParams;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectedRoomsChange = (selectedRooms?: string[]) => {
|
||||
setSearchParams((prevParams) => {
|
||||
const newParams = new URLSearchParams(prevParams);
|
||||
newParams.delete('rooms');
|
||||
if (selectedRooms && selectedRooms.length > 0) {
|
||||
newParams.append('rooms', encodeSearchParamValueArray(selectedRooms));
|
||||
}
|
||||
return newParams;
|
||||
});
|
||||
};
|
||||
const handleGlobalChange = (global?: boolean) => {
|
||||
setSearchParams((prevParams) => {
|
||||
const newParams = new URLSearchParams(prevParams);
|
||||
newParams.delete('global');
|
||||
if (global) {
|
||||
newParams.append('global', 'true');
|
||||
}
|
||||
return newParams;
|
||||
});
|
||||
};
|
||||
|
||||
const handleOrderChange = (order?: string) => {
|
||||
setSearchParams((prevParams) => {
|
||||
const newParams = new URLSearchParams(prevParams);
|
||||
newParams.delete('order');
|
||||
if (order) {
|
||||
newParams.append('order', order);
|
||||
}
|
||||
return newParams;
|
||||
});
|
||||
};
|
||||
|
||||
const lastVItem = vItems[vItems.length - 1];
|
||||
const lastVItemIndex: number | undefined = lastVItem?.index;
|
||||
const lastGroupIndex = groups.length - 1;
|
||||
useEffect(() => {
|
||||
if (
|
||||
lastGroupIndex > -1 &&
|
||||
lastGroupIndex === lastVItemIndex &&
|
||||
!isFetchingNextPage &&
|
||||
hasNextPage
|
||||
) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [lastVItemIndex, lastGroupIndex, fetchNextPage, isFetchingNextPage, hasNextPage]);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="700">
|
||||
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
|
||||
<IconButton
|
||||
onClick={() => virtualizer.scrollToOffset(0)}
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
outlined
|
||||
size="300"
|
||||
aria-label="Scroll to Top"
|
||||
>
|
||||
<Icon src={Icons.ChevronTop} size="300" />
|
||||
</IconButton>
|
||||
</ScrollTopContainer>
|
||||
<Box ref={scrollTopAnchorRef} direction="Column" gap="300">
|
||||
<SearchInput
|
||||
active={!!msgSearchParams.term}
|
||||
loading={status === 'pending'}
|
||||
searchInputRef={searchInputRef}
|
||||
onSearch={handleSearch}
|
||||
onReset={handleSearchClear}
|
||||
/>
|
||||
<SearchFilters
|
||||
defaultRoomsFilterName={defaultRoomsFilterName}
|
||||
allowGlobal={allowGlobal}
|
||||
roomList={searchPathSearchParams.global === 'true' ? allRooms : rooms}
|
||||
selectedRooms={searchParamRooms}
|
||||
onSelectedRoomsChange={handleSelectedRoomsChange}
|
||||
global={searchPathSearchParams.global === 'true'}
|
||||
onGlobalChange={handleGlobalChange}
|
||||
order={msgSearchParams.order}
|
||||
onOrderChange={handleOrderChange}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{!msgSearchParams.term && status === 'pending' && (
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
||||
style={{
|
||||
padding: config.space.S400,
|
||||
borderRadius: config.radii.R400,
|
||||
minHeight: toRem(450),
|
||||
}}
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="200"
|
||||
>
|
||||
<PageHeroSection>
|
||||
<PageHero
|
||||
icon={<Icon size="600" src={Icons.Message} />}
|
||||
title="Search Messages"
|
||||
subTitle="Find helpful messages in your community by searching with related keywords."
|
||||
/>
|
||||
</PageHeroSection>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{msgSearchParams.term && groups.length === 0 && status === 'success' && (
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'Warning' })}
|
||||
style={{ padding: config.space.S300, borderRadius: config.radii.R400 }}
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
>
|
||||
<Icon size="200" src={Icons.Info} />
|
||||
<Text>
|
||||
No results found for <b>{`"${msgSearchParams.term}"`}</b>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{((msgSearchParams.term && status === 'pending') ||
|
||||
(groups.length > 0 && vItems.length === 0)) && (
|
||||
<Box direction="Column" gap="100">
|
||||
{[...Array(8).keys()].map((key) => (
|
||||
<SequenceCard variant="SurfaceVariant" key={key} style={{ minHeight: toRem(80) }} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{vItems.length > 0 && (
|
||||
<Box direction="Column" gap="300">
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="H5">{`Results for "${msgSearchParams.term}"`}</Text>
|
||||
<Line size="300" variant="Surface" />
|
||||
</Box>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{vItems.map((vItem) => {
|
||||
const group = groups[vItem.index];
|
||||
if (!group) return null;
|
||||
const groupRoom = mx.getRoom(group.roomId);
|
||||
if (!groupRoom) return null;
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
style={{ paddingBottom: config.space.S500 }}
|
||||
ref={virtualizer.measureElement}
|
||||
key={vItem.index}
|
||||
>
|
||||
<SearchResultGroup
|
||||
room={groupRoom}
|
||||
highlights={highlights}
|
||||
items={group.items}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={urlPreview}
|
||||
onOpen={navigateRoom}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isFetchingNextPage && (
|
||||
<Box justifyContent="Center" alignItems="Center">
|
||||
<Spinner size="600" variant="Secondary" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'Critical' })}
|
||||
style={{
|
||||
padding: config.space.S300,
|
||||
borderRadius: config.radii.R400,
|
||||
}}
|
||||
direction="Column"
|
||||
gap="200"
|
||||
>
|
||||
<Text size="L400">{error.name}</Text>
|
||||
<Text size="T300">{error.message}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
413
src/app/features/message-search/SearchFilters.tsx
Normal file
413
src/app/features/message-search/SearchFilters.tsx
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
import React, {
|
||||
ChangeEventHandler,
|
||||
MouseEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
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 { SearchOrderBy } from 'matrix-js-sdk';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { joinRuleToIconSrc } from '../../utils/room';
|
||||
import { factoryRoomIdByAtoZ } from '../../utils/sort';
|
||||
import {
|
||||
SearchItemStrGetter,
|
||||
UseAsyncSearchOptions,
|
||||
useAsyncSearch,
|
||||
} from '../../hooks/useAsyncSearch';
|
||||
import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
|
||||
import { VirtualTile } from '../../components/virtualizer';
|
||||
|
||||
type OrderButtonProps = {
|
||||
order?: string;
|
||||
onChange: (order?: string) => void;
|
||||
};
|
||||
function OrderButton({ order, onChange }: OrderButtonProps) {
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const rankOrder = order === SearchOrderBy.Rank;
|
||||
|
||||
const setOrder = (o?: string) => {
|
||||
setMenuAnchor(undefined);
|
||||
onChange(o);
|
||||
};
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
return (
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
align="End"
|
||||
position="Bottom"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
}}
|
||||
>
|
||||
<Menu variant="Surface">
|
||||
<Header size="300" variant="Surface" style={{ padding: `0 ${config.space.S300}` }}>
|
||||
<Text size="L400">Sort by</Text>
|
||||
</Header>
|
||||
<Line variant="Surface" size="300" />
|
||||
<div style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={() => setOrder()}
|
||||
variant="Surface"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-pressed={!rankOrder}
|
||||
>
|
||||
<Text size="T300">Recent</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => setOrder(SearchOrderBy.Rank)}
|
||||
variant="Surface"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-pressed={rankOrder}
|
||||
>
|
||||
<Text size="T300">Relevance</Text>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
after={<Icon size="50" src={Icons.Sort} />}
|
||||
onClick={handleOpenMenu}
|
||||
>
|
||||
{rankOrder ? <Text size="T200">Relevance</Text> : <Text size="T200">Recent</Text>}
|
||||
</Chip>
|
||||
</PopOut>
|
||||
);
|
||||
}
|
||||
|
||||
const SEARCH_OPTS: UseAsyncSearchOptions = {
|
||||
limit: 20,
|
||||
matchOptions: {
|
||||
contain: true,
|
||||
},
|
||||
};
|
||||
const SEARCH_DEBOUNCE_OPTS: DebounceOptions = {
|
||||
wait: 200,
|
||||
};
|
||||
|
||||
type SelectRoomButtonProps = {
|
||||
roomList: string[];
|
||||
selectedRooms?: string[];
|
||||
onChange: (rooms?: string[]) => void;
|
||||
};
|
||||
function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) {
|
||||
const mx = useMatrixClient();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [localSelected, setLocalSelected] = useState(selectedRooms);
|
||||
|
||||
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
|
||||
(rId) => mx.getRoom(rId)?.name ?? rId,
|
||||
[mx]
|
||||
);
|
||||
|
||||
const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
|
||||
roomList,
|
||||
getRoomNameStr,
|
||||
SEARCH_OPTS
|
||||
);
|
||||
const rooms = Array.from(searchResult?.items ?? roomList).sort(factoryRoomIdByAtoZ(mx));
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rooms.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 32,
|
||||
overscan: 5,
|
||||
});
|
||||
const vItems = virtualizer.getVirtualItems();
|
||||
|
||||
const searchRoom = useDebounce(_searchRoom, SEARCH_DEBOUNCE_OPTS);
|
||||
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
const value = evt.currentTarget.value.trim();
|
||||
if (!value) {
|
||||
resetSearch();
|
||||
return;
|
||||
}
|
||||
searchRoom(value);
|
||||
};
|
||||
|
||||
const handleRoomClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const roomId = evt.currentTarget.getAttribute('data-room-id');
|
||||
if (!roomId) return;
|
||||
if (localSelected?.includes(roomId)) {
|
||||
setLocalSelected(localSelected?.filter((rId) => rId !== roomId));
|
||||
return;
|
||||
}
|
||||
const addedRooms = [...(localSelected ?? [])];
|
||||
addedRooms.push(roomId);
|
||||
setLocalSelected(addedRooms);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setMenuAnchor(undefined);
|
||||
onChange(localSelected);
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
setMenuAnchor(undefined);
|
||||
onChange(undefined);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSelected(selectedRooms);
|
||||
resetSearch();
|
||||
}, [menuAnchor, selectedRooms, resetSearch]);
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
return (
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
align="Center"
|
||||
position="Bottom"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
}}
|
||||
>
|
||||
<Menu variant="Surface" style={{ width: toRem(250) }}>
|
||||
<Box direction="Column" style={{ maxHeight: toRem(450), maxWidth: toRem(300) }}>
|
||||
<Box
|
||||
shrink="No"
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{ padding: config.space.S200, paddingBottom: 0 }}
|
||||
>
|
||||
<Text size="L400">Search</Text>
|
||||
<Input
|
||||
onChange={handleSearchChange}
|
||||
size="300"
|
||||
radii="300"
|
||||
after={
|
||||
searchResult && searchResult.items.length > 0 ? (
|
||||
<Badge variant="Secondary" size="400" radii="Pill">
|
||||
<Text size="L400">{searchResult.items.length}</Text>
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Scroll ref={scrollRef} size="300" hideTrack>
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{
|
||||
padding: config.space.S200,
|
||||
paddingRight: 0,
|
||||
}}
|
||||
>
|
||||
{!searchResult && <Text size="L400">Rooms</Text>}
|
||||
{searchResult && <Text size="L400">{`Rooms for "${searchResult.query}"`}</Text>}
|
||||
{searchResult && searchResult.items.length === 0 && (
|
||||
<Text style={{ padding: config.space.S400 }} size="T300" align="Center">
|
||||
No match found!
|
||||
</Text>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{vItems.map((vItem) => {
|
||||
const roomId = rooms[vItem.index];
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const selected = localSelected?.includes(roomId);
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
style={{ paddingBottom: config.space.S100 }}
|
||||
ref={virtualizer.measureElement}
|
||||
key={vItem.index}
|
||||
>
|
||||
<MenuItem
|
||||
data-room-id={roomId}
|
||||
onClick={handleRoomClick}
|
||||
variant={selected ? 'Success' : 'Surface'}
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-pressed={selected}
|
||||
before={
|
||||
<Icon
|
||||
size="50"
|
||||
src={
|
||||
joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text truncate size="T300">
|
||||
{room.name}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
</Scroll>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box shrink="No" direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||
<Button size="300" variant="Secondary" radii="300" onClick={handleSave}>
|
||||
{localSelected && localSelected.length > 0 ? (
|
||||
<Text size="B300">Save ({localSelected.length})</Text>
|
||||
) : (
|
||||
<Text size="B300">Save</Text>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
onClick={handleDeselectAll}
|
||||
disabled={!localSelected || localSelected.length === 0}
|
||||
>
|
||||
<Text size="B300">Deselect All</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
onClick={handleOpenMenu}
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
before={<Icon size="100" src={Icons.PlusCircle} />}
|
||||
>
|
||||
<Text size="T200">Select Rooms</Text>
|
||||
</Chip>
|
||||
</PopOut>
|
||||
);
|
||||
}
|
||||
|
||||
type SearchFiltersProps = {
|
||||
defaultRoomsFilterName: string;
|
||||
allowGlobal?: boolean;
|
||||
roomList: string[];
|
||||
selectedRooms?: string[];
|
||||
onSelectedRoomsChange: (selectedRooms?: string[]) => void;
|
||||
global?: boolean;
|
||||
onGlobalChange: (global?: boolean) => void;
|
||||
order?: string;
|
||||
onOrderChange: (order?: string) => void;
|
||||
};
|
||||
export function SearchFilters({
|
||||
defaultRoomsFilterName,
|
||||
allowGlobal,
|
||||
roomList,
|
||||
selectedRooms,
|
||||
onSelectedRoomsChange,
|
||||
global,
|
||||
order,
|
||||
onGlobalChange,
|
||||
onOrderChange,
|
||||
}: SearchFiltersProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Filter</Text>
|
||||
<Box gap="200" wrap="Wrap">
|
||||
<Chip
|
||||
variant={!global ? 'Success' : 'Surface'}
|
||||
aria-pressed={!global}
|
||||
before={!global && <Icon size="100" src={Icons.Check} />}
|
||||
outlined
|
||||
onClick={() => onGlobalChange()}
|
||||
>
|
||||
<Text size="T200">{defaultRoomsFilterName}</Text>
|
||||
</Chip>
|
||||
{allowGlobal && (
|
||||
<Chip
|
||||
variant={global ? 'Success' : 'Surface'}
|
||||
aria-pressed={global}
|
||||
before={global && <Icon size="100" src={Icons.Check} />}
|
||||
outlined
|
||||
onClick={() => onGlobalChange(true)}
|
||||
>
|
||||
<Text size="T200">Global</Text>
|
||||
</Chip>
|
||||
)}
|
||||
<Line
|
||||
style={{ margin: `${config.space.S100} 0` }}
|
||||
direction="Vertical"
|
||||
variant="Surface"
|
||||
size="300"
|
||||
/>
|
||||
{selectedRooms?.map((roomId) => {
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
|
||||
return (
|
||||
<Chip
|
||||
key={roomId}
|
||||
variant="Success"
|
||||
onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))}
|
||||
radii="Pill"
|
||||
before={
|
||||
<Icon
|
||||
size="50"
|
||||
src={joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash}
|
||||
/>
|
||||
}
|
||||
after={<Icon size="50" src={Icons.Cross} />}
|
||||
>
|
||||
<Text size="T200">{room.name}</Text>
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
<SelectRoomButton
|
||||
roomList={roomList}
|
||||
selectedRooms={selectedRooms}
|
||||
onChange={onSelectedRoomsChange}
|
||||
/>
|
||||
<Box grow="Yes" data-spacing-node />
|
||||
<OrderButton order={order} onChange={onOrderChange} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
66
src/app/features/message-search/SearchInput.tsx
Normal file
66
src/app/features/message-search/SearchInput.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import React, { FormEventHandler, RefObject } from 'react';
|
||||
import { Box, Text, Input, Icon, Icons, Spinner, Chip, config } from 'folds';
|
||||
|
||||
type SearchProps = {
|
||||
active?: boolean;
|
||||
loading?: boolean;
|
||||
searchInputRef: RefObject<HTMLInputElement>;
|
||||
onSearch: (term: string) => void;
|
||||
onReset: () => void;
|
||||
};
|
||||
export function SearchInput({ active, loading, searchInputRef, onSearch, onReset }: SearchProps) {
|
||||
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
const { searchInput } = evt.target as HTMLFormElement & {
|
||||
searchInput: HTMLInputElement;
|
||||
};
|
||||
|
||||
const searchTerm = searchInput.value.trim() || undefined;
|
||||
if (searchTerm) {
|
||||
onSearch(searchTerm);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}>
|
||||
<span data-spacing-node />
|
||||
<Text size="L400">Search</Text>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
style={{ paddingRight: config.space.S300 }}
|
||||
name="searchInput"
|
||||
size="500"
|
||||
variant="Background"
|
||||
placeholder="Search for keyword"
|
||||
autoComplete="off"
|
||||
before={
|
||||
active && loading ? (
|
||||
<Spinner variant="Secondary" size="200" />
|
||||
) : (
|
||||
<Icon size="200" src={Icons.Search} />
|
||||
)
|
||||
}
|
||||
after={
|
||||
active ? (
|
||||
<Chip
|
||||
key="resetButton"
|
||||
type="reset"
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
radii="Pill"
|
||||
outlined
|
||||
after={<Icon size="50" src={Icons.Cross} />}
|
||||
onClick={onReset}
|
||||
>
|
||||
<Text size="B300">Clear</Text>
|
||||
</Chip>
|
||||
) : (
|
||||
<Chip type="submit" variant="Primary" size="400" radii="Pill" outlined>
|
||||
<Text size="B300">Enter</Text>
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
262
src/app/features/message-search/SearchResultGroup.tsx
Normal file
262
src/app/features/message-search/SearchResultGroup.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
/* eslint-disable react/destructuring-assignment */
|
||||
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 { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import {
|
||||
getReactCustomHtmlParser,
|
||||
makeHighlightRegex,
|
||||
} from '../../plugins/react-custom-html-parser';
|
||||
import { getMxIdLocalPart, isRoomId, isUserId } from '../../utils/matrix';
|
||||
import { openJoinAlias, openProfileViewer } from '../../../client/action/navigation';
|
||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||
import {
|
||||
AvatarBase,
|
||||
ImageContent,
|
||||
MSticker,
|
||||
ModernLayout,
|
||||
RedactedContent,
|
||||
Reply,
|
||||
Time,
|
||||
Username,
|
||||
} from '../../components/message';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
import { Image } from '../../components/media';
|
||||
import { ImageViewer } from '../../components/image-viewer';
|
||||
import * as customHtmlCss from '../../styles/CustomHtml.css';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '../../utils/room';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { ResultItem } from './useMessageSearch';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
|
||||
type SearchResultGroupProps = {
|
||||
room: Room;
|
||||
highlights: string[];
|
||||
items: ResultItem[];
|
||||
mediaAutoLoad?: boolean;
|
||||
urlPreview?: boolean;
|
||||
onOpen: (roomId: string, eventId: string) => void;
|
||||
};
|
||||
export function SearchResultGroup({
|
||||
room,
|
||||
highlights,
|
||||
items,
|
||||
mediaAutoLoad,
|
||||
urlPreview,
|
||||
onOpen,
|
||||
}: SearchResultGroupProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
|
||||
|
||||
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
|
||||
() =>
|
||||
getReactCustomHtmlParser(mx, room, {
|
||||
highlightRegex,
|
||||
handleSpoilerClick: (evt) => {
|
||||
const target = evt.currentTarget;
|
||||
if (target.getAttribute('aria-pressed') === 'true') {
|
||||
evt.stopPropagation();
|
||||
target.setAttribute('aria-pressed', 'false');
|
||||
target.style.cursor = 'initial';
|
||||
}
|
||||
},
|
||||
handleMentionClick: (evt) => {
|
||||
const target = evt.currentTarget;
|
||||
const mentionId = target.getAttribute('data-mention-id');
|
||||
if (typeof mentionId !== 'string') return;
|
||||
if (isUserId(mentionId)) {
|
||||
openProfileViewer(mentionId, room.roomId);
|
||||
return;
|
||||
}
|
||||
if (isRoomId(mentionId) && mx.getRoom(mentionId)) {
|
||||
if (mx.getRoom(mentionId)?.isSpaceRoom()) navigateSpace(mentionId);
|
||||
else navigateRoom(mentionId);
|
||||
return;
|
||||
}
|
||||
openJoinAlias(mentionId);
|
||||
},
|
||||
}),
|
||||
[mx, room, highlightRegex, navigateRoom, navigateSpace]
|
||||
);
|
||||
|
||||
const renderMatrixEvent = useMatrixEventRenderer<[IEventWithRoomId, string, GetContentCallback]>(
|
||||
{
|
||||
[MessageEvent.RoomMessage]: (event, displayName, getContent) => {
|
||||
if (event.unsigned?.redacted_because) {
|
||||
return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<RenderMessageContent
|
||||
displayName={displayName}
|
||||
msgType={event.content.msgtype ?? ''}
|
||||
ts={event.origin_server_ts}
|
||||
getContent={getContent}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={urlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
highlightRegex={highlightRegex}
|
||||
outlineAttachment
|
||||
/>
|
||||
);
|
||||
},
|
||||
[MessageEvent.Reaction]: (event, displayName, getContent) => {
|
||||
if (event.unsigned?.redacted_because) {
|
||||
return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
|
||||
}
|
||||
return (
|
||||
<MSticker
|
||||
content={getContent()}
|
||||
renderImageContent={(props) => (
|
||||
<ImageContent
|
||||
{...props}
|
||||
autoPlay={mediaAutoLoad}
|
||||
renderImage={(p) => <Image {...p} loading="lazy" />}
|
||||
renderViewer={(p) => <ImageViewer {...p} />}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[StateEvent.RoomTombstone]: (event) => {
|
||||
const { content } = event;
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="T400" priority="300">
|
||||
Room Tombstone. {content.body}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
(event) => {
|
||||
if (event.unsigned?.redacted_because) {
|
||||
return <RedactedContent reason={event.unsigned?.redacted_because.content.reason} />;
|
||||
}
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="T400" priority="300">
|
||||
<code className={customHtmlCss.Code}>{event.type}</code>
|
||||
{' event'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const handleOpenClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const eventId = evt.currentTarget.getAttribute('data-event-id');
|
||||
if (!eventId) return;
|
||||
onOpen(room.roomId, eventId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="200">
|
||||
<Header size="300">
|
||||
<Box gap="200" grow="Yes">
|
||||
<Avatar size="200" radii="300">
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={getRoomAvatarUrl(mx, room, 96)}
|
||||
alt={room.name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon size="50" joinRule={room.getJoinRule() ?? JoinRule.Restricted} filled />
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="H4" truncate>
|
||||
{room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box direction="Column" gap="100">
|
||||
{items.map((item) => {
|
||||
const { event } = item;
|
||||
|
||||
const displayName =
|
||||
getMemberDisplayName(room, event.sender) ??
|
||||
getMxIdLocalPart(event.sender) ??
|
||||
event.sender;
|
||||
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
|
||||
|
||||
const mainEventId =
|
||||
event.content['m.relates_to']?.rel_type === RelationType.Replace
|
||||
? event.content['m.relates_to'].event_id
|
||||
: event.event_id;
|
||||
|
||||
const getContent = (() =>
|
||||
event.content['m.new_content'] ?? event.content) as GetContentCallback;
|
||||
|
||||
const replyEventId = event.content['m.relates_to']?.['m.in_reply_to']?.event_id;
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
key={event.event_id}
|
||||
style={{ padding: config.space.S400 }}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
>
|
||||
<ModernLayout
|
||||
before={
|
||||
<AvatarBase>
|
||||
<Avatar size="300">
|
||||
<UserAvatar
|
||||
userId={event.sender}
|
||||
src={
|
||||
senderAvatarMxc
|
||||
? mx.mxcUrlToHttp(senderAvatarMxc, 48, 48, 'crop') ?? undefined
|
||||
: undefined
|
||||
}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</AvatarBase>
|
||||
}
|
||||
>
|
||||
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
|
||||
<Box gap="200" alignItems="Baseline">
|
||||
<Username style={{ color: colorMXID(event.sender) }}>
|
||||
<Text as="span" truncate>
|
||||
<b>{displayName}</b>
|
||||
</Text>
|
||||
</Username>
|
||||
<Time ts={event.origin_server_ts} />
|
||||
</Box>
|
||||
<Box shrink="No" gap="200" alignItems="Center">
|
||||
<Chip
|
||||
data-event-id={mainEventId}
|
||||
onClick={handleOpenClick}
|
||||
variant="Secondary"
|
||||
radii="400"
|
||||
>
|
||||
<Text size="T200">Open</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
{replyEventId && (
|
||||
<Reply
|
||||
as="button"
|
||||
mx={mx}
|
||||
room={room}
|
||||
eventId={replyEventId}
|
||||
data-event-id={replyEventId}
|
||||
onClick={handleOpenClick}
|
||||
/>
|
||||
)}
|
||||
{renderMatrixEvent(event.type, false, event, displayName, getContent)}
|
||||
</ModernLayout>
|
||||
</SequenceCard>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/features/message-search/index.ts
Normal file
1
src/app/features/message-search/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './MessageSearch';
|
||||
115
src/app/features/message-search/useMessageSearch.ts
Normal file
115
src/app/features/message-search/useMessageSearch.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import {
|
||||
IEventWithRoomId,
|
||||
IResultContext,
|
||||
ISearchRequestBody,
|
||||
ISearchResponse,
|
||||
ISearchResult,
|
||||
SearchOrderBy,
|
||||
} from 'matrix-js-sdk';
|
||||
import { useCallback } from 'react';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
||||
export type ResultItem = {
|
||||
rank: number;
|
||||
event: IEventWithRoomId;
|
||||
context: IResultContext;
|
||||
};
|
||||
|
||||
export type ResultGroup = {
|
||||
roomId: string;
|
||||
items: ResultItem[];
|
||||
};
|
||||
|
||||
export type SearchResult = {
|
||||
nextToken?: string;
|
||||
highlights: string[];
|
||||
groups: ResultGroup[];
|
||||
};
|
||||
|
||||
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||
const groups: ResultGroup[] = [];
|
||||
|
||||
results.forEach((item) => {
|
||||
const roomId = item.result.room_id;
|
||||
const resultItem: ResultItem = {
|
||||
rank: item.rank,
|
||||
event: item.result,
|
||||
context: item.context,
|
||||
};
|
||||
|
||||
const lastAddedGroup: ResultGroup | undefined = groups[groups.length - 1];
|
||||
if (lastAddedGroup && roomId === lastAddedGroup.roomId) {
|
||||
lastAddedGroup.items.push(resultItem);
|
||||
return;
|
||||
}
|
||||
groups.push({
|
||||
roomId,
|
||||
items: [resultItem],
|
||||
});
|
||||
});
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
const parseSearchResult = (result: ISearchResponse): SearchResult => {
|
||||
const roomEvents = result.search_categories.room_events;
|
||||
|
||||
const searchResult: SearchResult = {
|
||||
nextToken: roomEvents?.next_batch,
|
||||
highlights: roomEvents?.highlights ?? [],
|
||||
groups: groupSearchResult(roomEvents?.results ?? []),
|
||||
};
|
||||
|
||||
return searchResult;
|
||||
};
|
||||
|
||||
export type MessageSearchParams = {
|
||||
term?: string;
|
||||
order?: string;
|
||||
rooms?: string[];
|
||||
senders?: string[];
|
||||
};
|
||||
export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
const mx = useMatrixClient();
|
||||
const { term, order, rooms, senders } = params;
|
||||
|
||||
const searchMessages = useCallback(
|
||||
async (nextBatch?: string) => {
|
||||
if (!term)
|
||||
return {
|
||||
highlights: [],
|
||||
groups: [],
|
||||
};
|
||||
const limit = 20;
|
||||
|
||||
const requestBody: ISearchRequestBody = {
|
||||
search_categories: {
|
||||
room_events: {
|
||||
event_context: {
|
||||
before_limit: 0,
|
||||
after_limit: 0,
|
||||
include_profile: false,
|
||||
},
|
||||
filter: {
|
||||
limit,
|
||||
rooms,
|
||||
senders,
|
||||
},
|
||||
include_state: false,
|
||||
order_by: order as SearchOrderBy.Recent,
|
||||
search_term: term,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const r = await mx.search({
|
||||
body: requestBody,
|
||||
next_batch: nextBatch === '' ? undefined : nextBatch,
|
||||
});
|
||||
return parseSearchResult(r);
|
||||
},
|
||||
[mx, term, order, rooms, senders]
|
||||
);
|
||||
|
||||
return searchMessages;
|
||||
};
|
||||
27
src/app/features/room-nav/RoomNavCategoryButton.tsx
Normal file
27
src/app/features/room-nav/RoomNavCategoryButton.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import { as, Chip, Icon, Icons, Text } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './styles.css';
|
||||
|
||||
export const RoomNavCategoryButton = as<'button', { closed?: boolean }>(
|
||||
({ className, closed, children, ...props }, ref) => (
|
||||
<Chip
|
||||
className={classNames(css.CategoryButton, className)}
|
||||
variant="Background"
|
||||
radii="Pill"
|
||||
before={
|
||||
<Icon
|
||||
className={css.CategoryButtonIcon}
|
||||
size="50"
|
||||
src={closed ? Icons.ChevronRight : Icons.ChevronBottom}
|
||||
/>
|
||||
}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Text size="O400" priority="400" truncate>
|
||||
{children}
|
||||
</Text>
|
||||
</Chip>
|
||||
)
|
||||
);
|
||||
297
src/app/features/room-nav/RoomNavItem.tsx
Normal file
297
src/app/features/room-nav/RoomNavItem.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import React, { MouseEventHandler, forwardRef, useState } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Text,
|
||||
Menu,
|
||||
MenuItem,
|
||||
config,
|
||||
PopOut,
|
||||
toRem,
|
||||
Line,
|
||||
RectCords,
|
||||
Badge,
|
||||
} from 'folds';
|
||||
import { useFocusWithin, useHover } from 'react-aria';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
||||
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
|
||||
import { copyToClipboard } from '../../utils/dom';
|
||||
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pages/pathUtils';
|
||||
import { markAsRead } from '../../../client/action/notifications';
|
||||
import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
||||
import { TypingIndicator } from '../../components/typing-indicator';
|
||||
|
||||
type RoomNavItemMenuProps = {
|
||||
room: Room;
|
||||
linkPath: string;
|
||||
requestClose: () => void;
|
||||
};
|
||||
const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
({ room, linkPath, requestClose }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const { hashRouter } = useClientConfig();
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
|
||||
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
markAsRead(room.roomId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const handleInvite = () => {
|
||||
openInviteUser(room.roomId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), linkPath));
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const handleRoomSettings = () => {
|
||||
toggleRoomSettings(room.roomId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
disabled={!unread}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mark as Read
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleInvite}
|
||||
variant="Primary"
|
||||
fill="None"
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.UserPlus} />}
|
||||
radii="300"
|
||||
disabled={!canInvite}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Invite
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleCopyLink}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Link} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Copy Link
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleRoomSettings}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Setting} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Room Settings
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<UseStateProvider initial={false}>
|
||||
{(promptLeave, setPromptLeave) => (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => setPromptLeave(true)}
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
|
||||
radii="300"
|
||||
aria-pressed={promptLeave}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Leave Room
|
||||
</Text>
|
||||
</MenuItem>
|
||||
{promptLeave && (
|
||||
<LeaveRoomPrompt
|
||||
roomId={room.roomId}
|
||||
onDone={requestClose}
|
||||
onCancel={() => setPromptLeave(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
type RoomNavItemProps = {
|
||||
room: Room;
|
||||
selected: boolean;
|
||||
linkPath: string;
|
||||
muted?: boolean;
|
||||
showAvatar?: boolean;
|
||||
direct?: boolean;
|
||||
};
|
||||
export function RoomNavItem({
|
||||
room,
|
||||
selected,
|
||||
showAvatar,
|
||||
direct,
|
||||
muted,
|
||||
linkPath,
|
||||
}: RoomNavItemProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [hover, setHover] = useState(false);
|
||||
const { hoverProps } = useHover({ onHoverChange: setHover });
|
||||
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
const typingMember = useRoomTypingMember(room.roomId);
|
||||
|
||||
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
setMenuAnchor({
|
||||
x: evt.clientX,
|
||||
y: evt.clientY,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const optionsVisible = hover || !!menuAnchor;
|
||||
|
||||
return (
|
||||
<NavItem
|
||||
variant="Background"
|
||||
radii="400"
|
||||
highlight={unread !== undefined}
|
||||
aria-selected={selected}
|
||||
data-hover={!!menuAnchor}
|
||||
onContextMenu={handleContextMenu}
|
||||
{...hoverProps}
|
||||
{...focusWithinProps}
|
||||
>
|
||||
<NavLink to={linkPath}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
{showAvatar ? (
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={
|
||||
direct ? getDirectRoomAvatarUrl(mx, room, 96) : getRoomAvatarUrl(mx, room, 96)
|
||||
}
|
||||
alt={room.name}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(room.name)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<RoomIcon
|
||||
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
||||
filled={selected}
|
||||
size="100"
|
||||
joinRule={room.getJoinRule()}
|
||||
/>
|
||||
)}
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
||||
{room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||
<TypingIndicator size="300" disableAnimation />
|
||||
</Badge>
|
||||
)}
|
||||
{!optionsVisible && unread && (
|
||||
<UnreadBadgeCenter>
|
||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||
</UnreadBadgeCenter>
|
||||
)}
|
||||
{muted && !optionsVisible && <Icon size="50" src={Icons.BellMute} />}
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavLink>
|
||||
{optionsVisible && (
|
||||
<NavItemOptions>
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
offset={menuAnchor?.width === 0 ? 0 : undefined}
|
||||
alignOffset={menuAnchor?.width === 0 ? 0 : -5}
|
||||
position="Bottom"
|
||||
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
}}
|
||||
>
|
||||
<RoomNavItemMenu
|
||||
room={room}
|
||||
linkPath={linkPath}
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
onClick={handleOpenMenu}
|
||||
aria-pressed={!!menuAnchor}
|
||||
variant="Background"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="50" src={Icons.VerticalDots} />
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
</NavItemOptions>
|
||||
)}
|
||||
</NavItem>
|
||||
);
|
||||
}
|
||||
2
src/app/features/room-nav/index.ts
Normal file
2
src/app/features/room-nav/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './RoomNavItem';
|
||||
export * from './RoomNavCategoryButton';
|
||||
9
src/app/features/room-nav/styles.css.ts
Normal file
9
src/app/features/room-nav/styles.css.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
|
||||
export const CategoryButton = style({
|
||||
flexGrow: 1,
|
||||
});
|
||||
export const CategoryButtonIcon = style({
|
||||
opacity: config.opacity.P400,
|
||||
});
|
||||
109
src/app/features/room/CommandAutocomplete.tsx
Normal file
109
src/app/features/room/CommandAutocomplete.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Editor } from 'slate';
|
||||
import { Box, MenuItem, Text } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Command, useCommands } from '../../hooks/useCommands';
|
||||
import {
|
||||
AutocompleteMenu,
|
||||
AutocompleteQuery,
|
||||
createCommandElement,
|
||||
moveCursor,
|
||||
replaceWithElement,
|
||||
} from '../../components/editor';
|
||||
import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearch';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { onTabPress } from '../../utils/keyboard';
|
||||
|
||||
type CommandAutoCompleteHandler = (commandName: string) => void;
|
||||
|
||||
type CommandAutocompleteProps = {
|
||||
room: Room;
|
||||
editor: Editor;
|
||||
query: AutocompleteQuery<string>;
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||
matchOptions: {
|
||||
contain: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function CommandAutocomplete({
|
||||
room,
|
||||
editor,
|
||||
query,
|
||||
requestClose,
|
||||
}: CommandAutocompleteProps) {
|
||||
const mx = useMatrixClient();
|
||||
const commands = useCommands(mx, room);
|
||||
const commandNames = useMemo(() => Object.keys(commands) as Command[], [commands]);
|
||||
|
||||
const [result, search, resetSearch] = useAsyncSearch(
|
||||
commandNames,
|
||||
useCallback((commandName: string) => commandName, []),
|
||||
SEARCH_OPTIONS
|
||||
);
|
||||
|
||||
const autoCompleteNames = result ? result.items : commandNames;
|
||||
|
||||
useEffect(() => {
|
||||
if (query.text) search(query.text);
|
||||
else resetSearch();
|
||||
}, [query.text, search, resetSearch]);
|
||||
|
||||
const handleAutocomplete: CommandAutoCompleteHandler = (commandName) => {
|
||||
const cmdEl = createCommandElement(commandName);
|
||||
replaceWithElement(editor, query.range, cmdEl);
|
||||
moveCursor(editor, true);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
useKeyDown(window, (evt: KeyboardEvent) => {
|
||||
onTabPress(evt, () => {
|
||||
if (autoCompleteNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
const cmdName = autoCompleteNames[0];
|
||||
handleAutocomplete(cmdName);
|
||||
});
|
||||
});
|
||||
|
||||
return autoCompleteNames.length === 0 ? null : (
|
||||
<AutocompleteMenu
|
||||
headerContent={
|
||||
<Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
|
||||
<Text size="L400">Commands</Text>
|
||||
<Text size="T200" priority="300" truncate>
|
||||
Begin your message with command
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
requestClose={requestClose}
|
||||
>
|
||||
{autoCompleteNames.map((commandName) => (
|
||||
<MenuItem
|
||||
key={commandName}
|
||||
as="button"
|
||||
radii="300"
|
||||
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
|
||||
onTabPress(evt, () => handleAutocomplete(commandName))
|
||||
}
|
||||
onClick={() => handleAutocomplete(commandName)}
|
||||
>
|
||||
<Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
|
||||
<Box shrink="No">
|
||||
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||
{`/${commandName}`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text truncate priority="300" size="T200">
|
||||
{commands[commandName].description}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</AutocompleteMenu>
|
||||
);
|
||||
}
|
||||
64
src/app/features/room/MembersDrawer.css.ts
Normal file
64
src/app/features/room/MembersDrawer.css.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
export const MembersDrawer = style({
|
||||
width: toRem(266),
|
||||
backgroundColor: color.Background.Container,
|
||||
color: color.Background.OnContainer,
|
||||
});
|
||||
|
||||
export const MembersDrawerHeader = style({
|
||||
flexShrink: 0,
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
});
|
||||
|
||||
export const MemberDrawerContentBase = style({
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const MemberDrawerContent = style({
|
||||
padding: `${config.space.S200} 0`,
|
||||
});
|
||||
|
||||
const ScrollBtnAnime = keyframes({
|
||||
'0%': {
|
||||
transform: `translate(-50%, -100%) scale(0)`,
|
||||
},
|
||||
'100%': {
|
||||
transform: `translate(-50%, 0) scale(1)`,
|
||||
},
|
||||
});
|
||||
|
||||
export const DrawerScrollTop = style({
|
||||
position: 'absolute',
|
||||
top: config.space.S200,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
animation: `${ScrollBtnAnime} 100ms`,
|
||||
});
|
||||
|
||||
export const DrawerGroup = style({
|
||||
paddingLeft: config.space.S200,
|
||||
});
|
||||
|
||||
export const MembersGroup = style({
|
||||
paddingLeft: config.space.S200,
|
||||
});
|
||||
export const MembersGroupLabel = style({
|
||||
padding: config.space.S200,
|
||||
selectors: {
|
||||
'&:not(:first-child)': {
|
||||
paddingTop: config.space.S500,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const DrawerVirtualItem = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
});
|
||||
546
src/app/features/room/MembersDrawer.tsx
Normal file
546
src/app/features/room/MembersDrawer.tsx
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
import React, {
|
||||
ChangeEventHandler,
|
||||
MouseEventHandler,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Chip,
|
||||
ContainerColor,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Room, RoomMember } from 'matrix-js-sdk';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { openProfileViewer } from '../../../client/action/navigation';
|
||||
import * as css from './MembersDrawer.css';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { Membership } from '../../../types/matrix/room';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import {
|
||||
SearchItemStrGetter,
|
||||
UseAsyncSearchOptions,
|
||||
useAsyncSearch,
|
||||
} from '../../hooks/useAsyncSearch';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { usePowerLevelTags, PowerLevelTag } from '../../hooks/usePowerLevelTags';
|
||||
import { TypingIndicator } from '../../components/typing-indicator';
|
||||
import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { useSetSetting, useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { millify } from '../../plugins/millify';
|
||||
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
||||
|
||||
export const MembershipFilters = {
|
||||
filterJoined: (m: RoomMember) => m.membership === Membership.Join,
|
||||
filterInvited: (m: RoomMember) => m.membership === Membership.Invite,
|
||||
filterLeaved: (m: RoomMember) =>
|
||||
m.membership === Membership.Leave &&
|
||||
m.events.member?.getStateKey() === m.events.member?.getSender(),
|
||||
filterKicked: (m: RoomMember) =>
|
||||
m.membership === Membership.Leave &&
|
||||
m.events.member?.getStateKey() !== m.events.member?.getSender(),
|
||||
filterBanned: (m: RoomMember) => m.membership === Membership.Ban,
|
||||
};
|
||||
|
||||
export type MembershipFilterFn = (m: RoomMember) => boolean;
|
||||
|
||||
export type MembershipFilter = {
|
||||
name: string;
|
||||
filterFn: MembershipFilterFn;
|
||||
color: ContainerColor;
|
||||
};
|
||||
|
||||
const useMembershipFilterMenu = (): MembershipFilter[] =>
|
||||
useMemo(
|
||||
() => [
|
||||
{
|
||||
name: 'Joined',
|
||||
filterFn: MembershipFilters.filterJoined,
|
||||
color: 'Background',
|
||||
},
|
||||
{
|
||||
name: 'Invited',
|
||||
filterFn: MembershipFilters.filterInvited,
|
||||
color: 'Success',
|
||||
},
|
||||
{
|
||||
name: 'Left',
|
||||
filterFn: MembershipFilters.filterLeaved,
|
||||
color: 'Secondary',
|
||||
},
|
||||
{
|
||||
name: 'Kicked',
|
||||
filterFn: MembershipFilters.filterKicked,
|
||||
color: 'Warning',
|
||||
},
|
||||
{
|
||||
name: 'Banned',
|
||||
filterFn: MembershipFilters.filterBanned,
|
||||
color: 'Critical',
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
export const SortFilters = {
|
||||
filterAscending: (a: RoomMember, b: RoomMember) =>
|
||||
a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1,
|
||||
filterDescending: (a: RoomMember, b: RoomMember) =>
|
||||
a.name.toLowerCase() > b.name.toLowerCase() ? -1 : 1,
|
||||
filterNewestFirst: (a: RoomMember, b: RoomMember) =>
|
||||
(b.events.member?.getTs() ?? 0) - (a.events.member?.getTs() ?? 0),
|
||||
filterOldest: (a: RoomMember, b: RoomMember) =>
|
||||
(a.events.member?.getTs() ?? 0) - (b.events.member?.getTs() ?? 0),
|
||||
};
|
||||
|
||||
export type SortFilterFn = (a: RoomMember, b: RoomMember) => number;
|
||||
|
||||
export type SortFilter = {
|
||||
name: string;
|
||||
filterFn: SortFilterFn;
|
||||
};
|
||||
|
||||
const useSortFilterMenu = (): SortFilter[] =>
|
||||
useMemo(
|
||||
() => [
|
||||
{
|
||||
name: 'A to Z',
|
||||
filterFn: SortFilters.filterAscending,
|
||||
},
|
||||
{
|
||||
name: 'Z to A',
|
||||
filterFn: SortFilters.filterDescending,
|
||||
},
|
||||
{
|
||||
name: 'Newest',
|
||||
filterFn: SortFilters.filterNewestFirst,
|
||||
},
|
||||
{
|
||||
name: 'Oldest',
|
||||
filterFn: SortFilters.filterOldest,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
export type MembersFilterOptions = {
|
||||
membershipFilter: MembershipFilter;
|
||||
sortFilter: SortFilter;
|
||||
};
|
||||
|
||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||
limit: 100,
|
||||
matchOptions: {
|
||||
contain: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
|
||||
const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
|
||||
getMemberSearchStr(m, query, mxIdToName);
|
||||
|
||||
type MembersDrawerProps = {
|
||||
room: Room;
|
||||
};
|
||||
export function MembersDrawer({ room }: MembersDrawerProps) {
|
||||
const mx = useMatrixClient();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const members = useRoomMembers(mx, room.roomId);
|
||||
const getPowerLevelTag = usePowerLevelTags();
|
||||
const fetchingMembers = members.length < room.getJoinedMemberCount();
|
||||
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
||||
|
||||
const membershipFilterMenu = useMembershipFilterMenu();
|
||||
const sortFilterMenu = useSortFilterMenu();
|
||||
const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
|
||||
const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
|
||||
|
||||
const membershipFilter = membershipFilterMenu[membershipFilterIndex] ?? membershipFilterMenu[0];
|
||||
const sortFilter = sortFilterMenu[sortFilterIndex] ?? sortFilterMenu[0];
|
||||
|
||||
const typingMembers = useRoomTypingMember(room.roomId);
|
||||
|
||||
const filteredMembers = useMemo(
|
||||
() =>
|
||||
members
|
||||
.filter(membershipFilter.filterFn)
|
||||
.sort(sortFilter.filterFn)
|
||||
.sort((a, b) => b.powerLevel - a.powerLevel),
|
||||
[members, membershipFilter, sortFilter]
|
||||
);
|
||||
|
||||
const [result, search, resetSearch] = useAsyncSearch(
|
||||
filteredMembers,
|
||||
getRoomMemberStr,
|
||||
SEARCH_OPTIONS
|
||||
);
|
||||
if (!result && searchInputRef.current?.value) search(searchInputRef.current.value);
|
||||
|
||||
const processMembers = result ? result.items : filteredMembers;
|
||||
|
||||
const PLTagOrRoomMember = useMemo(() => {
|
||||
let prevTag: PowerLevelTag | undefined;
|
||||
const tagOrMember: Array<PowerLevelTag | RoomMember> = [];
|
||||
processMembers.forEach((m) => {
|
||||
const plTag = getPowerLevelTag(m.powerLevel);
|
||||
if (plTag !== prevTag) {
|
||||
prevTag = plTag;
|
||||
tagOrMember.push(plTag);
|
||||
}
|
||||
tagOrMember.push(m);
|
||||
});
|
||||
return tagOrMember;
|
||||
}, [processMembers, getPowerLevelTag]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: PLTagOrRoomMember.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 40,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (evt.target.value) search(evt.target.value);
|
||||
else resetSearch();
|
||||
},
|
||||
[search, resetSearch]
|
||||
),
|
||||
{ wait: 200 }
|
||||
);
|
||||
|
||||
const getName = (member: RoomMember) =>
|
||||
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
|
||||
|
||||
const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const btn = evt.currentTarget as HTMLButtonElement;
|
||||
const userId = btn.getAttribute('data-user-id');
|
||||
openProfileViewer(userId, room.roomId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={css.MembersDrawer} shrink="No" direction="Column">
|
||||
<Header className={css.MembersDrawerHeader} variant="Background" size="600">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text title={`${room.getJoinedMemberCount()} Members`} size="H5" truncate>
|
||||
{`${millify(room.getJoinedMemberCount())} Members`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center">
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Close</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
variant="Background"
|
||||
onClick={() => setPeopleDrawer(false)}
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</Box>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box className={css.MemberDrawerContentBase} grow="Yes">
|
||||
<Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover" hideTrack>
|
||||
<Box className={css.MemberDrawerContent} direction="Column" gap="200">
|
||||
<Box ref={scrollTopAnchorRef} className={css.DrawerGroup} direction="Column" gap="200">
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<UseStateProvider initial={undefined}>
|
||||
{(anchor: RectCords | undefined, setAnchor) => (
|
||||
<PopOut
|
||||
anchor={anchor}
|
||||
position="Bottom"
|
||||
align="Start"
|
||||
offset={4}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
}}
|
||||
>
|
||||
<Menu style={{ padding: config.space.S100 }}>
|
||||
{membershipFilterMenu.map((menuItem, index) => (
|
||||
<MenuItem
|
||||
key={menuItem.name}
|
||||
variant={
|
||||
menuItem.name === membershipFilter.name
|
||||
? menuItem.color
|
||||
: 'Surface'
|
||||
}
|
||||
aria-pressed={menuItem.name === membershipFilter.name}
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
setMembershipFilterIndex(index);
|
||||
setAnchor(undefined);
|
||||
}}
|
||||
>
|
||||
<Text size="T300">{menuItem.name}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
onClick={
|
||||
((evt) =>
|
||||
setAnchor(
|
||||
evt.currentTarget.getBoundingClientRect()
|
||||
)) as MouseEventHandler<HTMLButtonElement>
|
||||
}
|
||||
variant={membershipFilter.color}
|
||||
size="400"
|
||||
radii="300"
|
||||
before={<Icon src={Icons.Filter} size="50" />}
|
||||
>
|
||||
<Text size="T200">{membershipFilter.name}</Text>
|
||||
</Chip>
|
||||
</PopOut>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
<UseStateProvider initial={undefined}>
|
||||
{(anchor: RectCords | undefined, setAnchor) => (
|
||||
<PopOut
|
||||
anchor={anchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={4}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
}}
|
||||
>
|
||||
<Menu style={{ padding: config.space.S100 }}>
|
||||
{sortFilterMenu.map((menuItem, index) => (
|
||||
<MenuItem
|
||||
key={menuItem.name}
|
||||
variant="Surface"
|
||||
aria-pressed={menuItem.name === sortFilter.name}
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
setSortFilterIndex(index);
|
||||
setAnchor(undefined);
|
||||
}}
|
||||
>
|
||||
<Text size="T300">{menuItem.name}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
onClick={
|
||||
((evt) =>
|
||||
setAnchor(
|
||||
evt.currentTarget.getBoundingClientRect()
|
||||
)) as MouseEventHandler<HTMLButtonElement>
|
||||
}
|
||||
variant="Background"
|
||||
size="400"
|
||||
radii="300"
|
||||
after={<Icon src={Icons.Sort} size="50" />}
|
||||
>
|
||||
<Text size="T200">{sortFilter.name}</Text>
|
||||
</Chip>
|
||||
</PopOut>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
onChange={handleSearchChange}
|
||||
style={{ paddingRight: config.space.S200 }}
|
||||
placeholder="Type name..."
|
||||
variant="Surface"
|
||||
size="400"
|
||||
radii="400"
|
||||
before={<Icon size="50" src={Icons.Search} />}
|
||||
after={
|
||||
result && (
|
||||
<Chip
|
||||
variant={result.items.length > 0 ? 'Success' : 'Critical'}
|
||||
size="400"
|
||||
radii="Pill"
|
||||
aria-pressed
|
||||
onClick={() => {
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.value = '';
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
resetSearch();
|
||||
}}
|
||||
after={<Icon size="50" src={Icons.Cross} />}
|
||||
>
|
||||
<Text size="B300">{`${result.items.length || 'No'} ${
|
||||
result.items.length === 1 ? 'Result' : 'Results'
|
||||
}`}</Text>
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
|
||||
<IconButton
|
||||
onClick={() => virtualizer.scrollToOffset(0)}
|
||||
variant="Surface"
|
||||
radii="Pill"
|
||||
outlined
|
||||
size="300"
|
||||
aria-label="Scroll to Top"
|
||||
>
|
||||
<Icon src={Icons.ChevronTop} size="300" />
|
||||
</IconButton>
|
||||
</ScrollTopContainer>
|
||||
|
||||
{!fetchingMembers && !result && processMembers.length === 0 && (
|
||||
<Text style={{ padding: config.space.S300 }} align="Center">
|
||||
{`No "${membershipFilter.name}" Members`}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Box className={css.MembersGroup} direction="Column" gap="100">
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const tagOrMember = PLTagOrRoomMember[vItem.index];
|
||||
if (!('userId' in tagOrMember)) {
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
transform: `translateY(${vItem.start}px)`,
|
||||
}}
|
||||
data-index={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
key={`${room.roomId}-${vItem.index}`}
|
||||
className={classNames(css.MembersGroupLabel, css.DrawerVirtualItem)}
|
||||
size="L400"
|
||||
>
|
||||
{tagOrMember.name}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const member = tagOrMember;
|
||||
const name = getName(member);
|
||||
const avatarUrl = member.getAvatarUrl(
|
||||
mx.baseUrl,
|
||||
100,
|
||||
100,
|
||||
'crop',
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
style={{
|
||||
padding: `0 ${config.space.S200}`,
|
||||
transform: `translateY(${vItem.start}px)`,
|
||||
}}
|
||||
data-index={vItem.index}
|
||||
data-user-id={member.userId}
|
||||
ref={virtualizer.measureElement}
|
||||
key={`${room.roomId}-${member.userId}`}
|
||||
className={css.DrawerVirtualItem}
|
||||
variant="Background"
|
||||
radii="400"
|
||||
onClick={handleMemberClick}
|
||||
before={
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={member.userId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
after={
|
||||
typingMembers.find((receipt) => receipt.userId === member.userId) && (
|
||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||
<TypingIndicator size="300" />
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T400" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{fetchingMembers && (
|
||||
<Box justifyContent="Center">
|
||||
<Spinner />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
33
src/app/features/room/Room.tsx
Normal file
33
src/app/features/room/Room.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
import { Box, Line } from 'folds';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { RoomView } from './RoomView';
|
||||
import { MembersDrawer } from './MembersDrawer';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
|
||||
export function Room() {
|
||||
const { eventId } = useParams();
|
||||
const room = useRoom();
|
||||
|
||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const screenSize = useScreenSizeContext();
|
||||
const powerLevels = usePowerLevels(room);
|
||||
|
||||
return (
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
<Box grow="Yes">
|
||||
<RoomView room={room} eventId={eventId} />
|
||||
{screenSize === ScreenSize.Desktop && isDrawer && (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<MembersDrawer key={room.roomId} room={room} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</PowerLevelsContextProvider>
|
||||
);
|
||||
}
|
||||
609
src/app/features/room/RoomInput.tsx
Normal file
609
src/app/features/room/RoomInput.tsx
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
import React, {
|
||||
KeyboardEventHandler,
|
||||
RefObject,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { EventType, IContent, MsgType, 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,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import {
|
||||
CustomEditor,
|
||||
Toolbar,
|
||||
toMatrixCustomHTML,
|
||||
toPlainText,
|
||||
AUTOCOMPLETE_PREFIXES,
|
||||
AutocompletePrefix,
|
||||
AutocompleteQuery,
|
||||
getAutocompleteQuery,
|
||||
getPrevWorldRange,
|
||||
resetEditor,
|
||||
RoomMentionAutocomplete,
|
||||
UserMentionAutocomplete,
|
||||
EmoticonAutocomplete,
|
||||
createEmoticonElement,
|
||||
moveCursor,
|
||||
resetEditorHistory,
|
||||
customHtmlEqualsPlainText,
|
||||
trimCustomHtml,
|
||||
isEmptyEditor,
|
||||
getBeginCommand,
|
||||
trimCommand,
|
||||
} from '../../components/editor';
|
||||
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
||||
import { useFileDropZone } from '../../hooks/useFileDrop';
|
||||
import {
|
||||
TUploadItem,
|
||||
roomIdToMsgDraftAtomFamily,
|
||||
roomIdToReplyDraftAtomFamily,
|
||||
roomIdToUploadItemsAtomFamily,
|
||||
roomUploadAtomFamily,
|
||||
} from '../../state/room/roomInputDrafts';
|
||||
import { UploadCardRenderer } from '../../components/upload-card';
|
||||
import {
|
||||
UploadBoard,
|
||||
UploadBoardContent,
|
||||
UploadBoardHeader,
|
||||
UploadBoardImperativeHandlers,
|
||||
} from '../../components/upload-board';
|
||||
import {
|
||||
Upload,
|
||||
UploadStatus,
|
||||
UploadSuccess,
|
||||
createUploadFamilyObserverAtom,
|
||||
} from '../../state/upload';
|
||||
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
||||
import { safeFile } from '../../utils/mimeTypes';
|
||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import {
|
||||
getAudioMsgContent,
|
||||
getFileMsgContent,
|
||||
getImageMsgContent,
|
||||
getVideoMsgContent,
|
||||
} from './msgContent';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import {
|
||||
getMemberDisplayName,
|
||||
parseReplyBody,
|
||||
parseReplyFormattedBody,
|
||||
trimReplyFromBody,
|
||||
trimReplyFromFormattedBody,
|
||||
} from '../../utils/room';
|
||||
import { sanitizeText } from '../../utils/sanitize';
|
||||
import { CommandAutocomplete } from './CommandAutocomplete';
|
||||
import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
|
||||
import { mobileOrTablet } from '../../utils/user-agent';
|
||||
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
||||
import { ReplyLayout } from '../../components/message';
|
||||
|
||||
interface RoomInputProps {
|
||||
editor: Editor;
|
||||
fileDropContainerRef: RefObject<HTMLElement>;
|
||||
roomId: string;
|
||||
room: Room;
|
||||
}
|
||||
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
({ editor, fileDropContainerRef, roomId, room }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
const commands = useCommands(mx, room);
|
||||
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
||||
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
|
||||
const [uploadBoard, setUploadBoard] = useState(true);
|
||||
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
|
||||
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
|
||||
roomUploadAtomFamily,
|
||||
selectedFiles.map((f) => f.file)
|
||||
);
|
||||
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
|
||||
|
||||
const imagePackRooms: Room[] = useMemo(() => {
|
||||
const allParentSpaces = [roomId, ...(initMatrix.roomList?.getAllParentSpaces(roomId) ?? [])];
|
||||
return allParentSpaces.reduce<Room[]>((list, rId) => {
|
||||
const r = mx.getRoom(rId);
|
||||
if (r) list.push(r);
|
||||
return list;
|
||||
}, []);
|
||||
}, [mx, roomId]);
|
||||
|
||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [autocompleteQuery, setAutocompleteQuery] =
|
||||
useState<AutocompleteQuery<AutocompletePrefix>>();
|
||||
|
||||
const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
setUploadBoard(true);
|
||||
const safeFiles = files.map(safeFile);
|
||||
const fileItems: TUploadItem[] = [];
|
||||
|
||||
if (mx.isRoomEncrypted(roomId)) {
|
||||
const encryptFiles = fulfilledPromiseSettledResult(
|
||||
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
|
||||
);
|
||||
encryptFiles.forEach((ef) => fileItems.push(ef));
|
||||
} else {
|
||||
safeFiles.forEach((f) =>
|
||||
fileItems.push({ file: f, originalFile: f, encInfo: undefined })
|
||||
);
|
||||
}
|
||||
setSelectedFiles({
|
||||
type: 'PUT',
|
||||
item: fileItems,
|
||||
});
|
||||
},
|
||||
[setSelectedFiles, roomId, mx]
|
||||
);
|
||||
const pickFile = useFilePicker(handleFiles, true);
|
||||
const handlePaste = useFilePasteHandler(handleFiles);
|
||||
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
|
||||
const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
|
||||
|
||||
useElementSizeObserver(
|
||||
useCallback(() => document.body, []),
|
||||
useCallback((width) => setHideStickerBtn(width < 500), [])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
Transforms.insertFragment(editor, msgDraft);
|
||||
}, [editor, msgDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||
return () => {
|
||||
if (!isEmptyEditor(editor)) {
|
||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||
setMsgDraft(parsedDraft);
|
||||
} else {
|
||||
setMsgDraft([]);
|
||||
}
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
};
|
||||
}, [roomId, editor, setMsgDraft]);
|
||||
|
||||
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));
|
||||
},
|
||||
[setSelectedFiles, selectedFiles]
|
||||
);
|
||||
|
||||
const handleCancelUpload = (uploads: Upload[]) => {
|
||||
uploads.forEach((upload) => {
|
||||
if (upload.status === UploadStatus.Loading) {
|
||||
mx.cancelUpload(upload.promise);
|
||||
}
|
||||
});
|
||||
handleRemoveUpload(uploads.map((upload) => upload.file));
|
||||
};
|
||||
|
||||
const handleSendUpload = async (uploads: UploadSuccess[]) => {
|
||||
const contentsPromises = uploads.map(async (upload) => {
|
||||
const fileItem = selectedFiles.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);
|
||||
});
|
||||
handleCancelUpload(uploads);
|
||||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||||
contents.forEach((content) => mx.sendMessage(roomId, content));
|
||||
};
|
||||
|
||||
const submit = useCallback(() => {
|
||||
uploadBoardHandlers.current?.handleSend();
|
||||
|
||||
const commandName = getBeginCommand(editor);
|
||||
|
||||
let plainText = toPlainText(editor.children).trim();
|
||||
let customHtml = trimCustomHtml(
|
||||
toMatrixCustomHTML(editor.children, {
|
||||
allowTextFormatting: true,
|
||||
allowBlockMarkdown: isMarkdown,
|
||||
allowInlineMarkdown: isMarkdown,
|
||||
})
|
||||
);
|
||||
let msgType = MsgType.Text;
|
||||
|
||||
if (commandName) {
|
||||
plainText = trimCommand(commandName, plainText);
|
||||
customHtml = trimCommand(commandName, customHtml);
|
||||
}
|
||||
if (commandName === Command.Me) {
|
||||
msgType = MsgType.Emote;
|
||||
} else if (commandName === Command.Notice) {
|
||||
msgType = MsgType.Notice;
|
||||
} else if (commandName === Command.Shrug) {
|
||||
plainText = `${SHRUG} ${plainText}`;
|
||||
customHtml = `${SHRUG} ${customHtml}`;
|
||||
} else if (commandName) {
|
||||
const commandContent = commands[commandName as Command];
|
||||
if (commandContent) {
|
||||
commandContent.exe(plainText);
|
||||
}
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
sendTypingStatus(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (plainText === '') return;
|
||||
|
||||
let body = plainText;
|
||||
let formattedBody = customHtml;
|
||||
if (replyDraft) {
|
||||
body = parseReplyBody(replyDraft.userId, trimReplyFromBody(replyDraft.body)) + body;
|
||||
formattedBody =
|
||||
parseReplyFormattedBody(
|
||||
roomId,
|
||||
replyDraft.userId,
|
||||
replyDraft.eventId,
|
||||
replyDraft.formattedBody
|
||||
? trimReplyFromFormattedBody(replyDraft.formattedBody)
|
||||
: sanitizeText(replyDraft.body)
|
||||
) + formattedBody;
|
||||
}
|
||||
|
||||
const content: IContent = {
|
||||
msgtype: msgType,
|
||||
body,
|
||||
};
|
||||
if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
|
||||
content.format = 'org.matrix.custom.html';
|
||||
content.formatted_body = formattedBody;
|
||||
}
|
||||
if (replyDraft) {
|
||||
content['m.relates_to'] = {
|
||||
'm.in_reply_to': {
|
||||
event_id: replyDraft.eventId,
|
||||
},
|
||||
};
|
||||
}
|
||||
mx.sendMessage(roomId, content);
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
setReplyDraft(undefined);
|
||||
sendTypingStatus(false);
|
||||
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
|
||||
evt.preventDefault();
|
||||
submit();
|
||||
}
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
evt.preventDefault();
|
||||
setReplyDraft(undefined);
|
||||
}
|
||||
},
|
||||
[submit, setReplyDraft, enterForNewline]
|
||||
);
|
||||
|
||||
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
sendTypingStatus(!isEmptyEditor(editor));
|
||||
|
||||
const prevWordRange = getPrevWorldRange(editor);
|
||||
const query = prevWordRange
|
||||
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
|
||||
: undefined;
|
||||
setAutocompleteQuery(query);
|
||||
},
|
||||
[editor, sendTypingStatus]
|
||||
);
|
||||
|
||||
const handleCloseAutocomplete = useCallback(() => {
|
||||
setAutocompleteQuery(undefined);
|
||||
ReactEditor.focus(editor);
|
||||
}, [editor]);
|
||||
|
||||
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
||||
editor.insertNode(createEmoticonElement(key, shortcode));
|
||||
moveCursor(editor);
|
||||
};
|
||||
|
||||
const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => {
|
||||
const stickerUrl = mx.mxcUrlToHttp(mxc);
|
||||
if (!stickerUrl) return;
|
||||
|
||||
const info = await getImageInfo(
|
||||
await loadImageElement(stickerUrl),
|
||||
await getImageUrlBlob(stickerUrl)
|
||||
);
|
||||
|
||||
mx.sendEvent(roomId, EventType.Sticker, {
|
||||
body: label,
|
||||
url: mxc,
|
||||
info,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{selectedFiles.length > 0 && (
|
||||
<UploadBoard
|
||||
header={
|
||||
<UploadBoardHeader
|
||||
open={uploadBoard}
|
||||
onToggle={() => setUploadBoard(!uploadBoard)}
|
||||
uploadFamilyObserverAtom={uploadFamilyObserverAtom}
|
||||
onSend={handleSendUpload}
|
||||
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}
|
||||
file={fileItem.file}
|
||||
isEncrypted={!!fileItem.encInfo}
|
||||
uploadAtom={roomUploadAtomFamily(fileItem.file)}
|
||||
onRemove={handleRemoveUpload}
|
||||
/>
|
||||
))}
|
||||
</UploadBoardContent>
|
||||
</Scroll>
|
||||
)}
|
||||
</UploadBoard>
|
||||
)}
|
||||
<Overlay
|
||||
open={dropZoneVisible}
|
||||
backdrop={<OverlayBackdrop />}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
<OverlayCenter>
|
||||
<Dialog variant="Primary">
|
||||
<Box
|
||||
direction="Column"
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
gap="500"
|
||||
style={{ padding: toRem(60) }}
|
||||
>
|
||||
<Icon size="600" src={Icons.File} />
|
||||
<Text size="H4" align="Center">
|
||||
{`Drop Files in "${room?.name || 'Room'}"`}
|
||||
</Text>
|
||||
<Text align="Center">Drag and drop files here or click for selection dialog</Text>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
|
||||
<RoomMentionAutocomplete
|
||||
roomId={roomId}
|
||||
editor={editor}
|
||||
query={autocompleteQuery}
|
||||
requestClose={handleCloseAutocomplete}
|
||||
/>
|
||||
)}
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
|
||||
<UserMentionAutocomplete
|
||||
room={room}
|
||||
editor={editor}
|
||||
query={autocompleteQuery}
|
||||
requestClose={handleCloseAutocomplete}
|
||||
/>
|
||||
)}
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
|
||||
<EmoticonAutocomplete
|
||||
imagePackRooms={imagePackRooms}
|
||||
editor={editor}
|
||||
query={autocompleteQuery}
|
||||
requestClose={handleCloseAutocomplete}
|
||||
/>
|
||||
)}
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.Command && (
|
||||
<CommandAutocomplete
|
||||
room={room}
|
||||
editor={editor}
|
||||
query={autocompleteQuery}
|
||||
requestClose={handleCloseAutocomplete}
|
||||
/>
|
||||
)}
|
||||
<CustomEditor
|
||||
editableName="RoomInput"
|
||||
editor={editor}
|
||||
placeholder="Send a message..."
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onPaste={handlePaste}
|
||||
top={
|
||||
replyDraft && (
|
||||
<div>
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="300"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => setReplyDraft(undefined)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon src={Icons.Cross} size="50" />
|
||||
</IconButton>
|
||||
<ReplyLayout
|
||||
userColor={colorMXID(replyDraft.userId)}
|
||||
username={
|
||||
<Text size="T300" truncate>
|
||||
<b>
|
||||
{getMemberDisplayName(room, replyDraft.userId) ??
|
||||
getMxIdLocalPart(replyDraft.userId) ??
|
||||
replyDraft.userId}
|
||||
</b>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Text size="T300" truncate>
|
||||
{trimReplyFromBody(replyDraft.body)}
|
||||
</Text>
|
||||
</ReplyLayout>
|
||||
</Box>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
before={
|
||||
<IconButton
|
||||
onClick={() => pickFile('*')}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon src={Icons.PlusCircle} />
|
||||
</IconButton>
|
||||
}
|
||||
after={
|
||||
<>
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => setToolbar(!toolbar)}
|
||||
>
|
||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||
</IconButton>
|
||||
<UseStateProvider initial={undefined}>
|
||||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
||||
<PopOut
|
||||
offset={16}
|
||||
alignOffset={-44}
|
||||
position="Top"
|
||||
align="End"
|
||||
anchor={
|
||||
emojiBoardTab === undefined
|
||||
? undefined
|
||||
: emojiBtnRef.current?.getBoundingClientRect() ?? undefined
|
||||
}
|
||||
content={
|
||||
<EmojiBoard
|
||||
tab={emojiBoardTab}
|
||||
onTabChange={setEmojiBoardTab}
|
||||
imagePackRooms={imagePackRooms}
|
||||
returnFocusOnDeactivate={false}
|
||||
onEmojiSelect={handleEmoticonSelect}
|
||||
onCustomEmojiSelect={handleEmoticonSelect}
|
||||
onStickerSelect={handleStickerSelect}
|
||||
requestClose={() => {
|
||||
setEmojiBoardTab(undefined);
|
||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{!hideStickerBtn && (
|
||||
<IconButton
|
||||
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon
|
||||
src={Icons.Sticker}
|
||||
filled={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
ref={emojiBtnRef}
|
||||
aria-pressed={
|
||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||
}
|
||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon
|
||||
src={Icons.Smile}
|
||||
filled={
|
||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
|
||||
<Icon src={Icons.Send} />
|
||||
</IconButton>
|
||||
</>
|
||||
}
|
||||
bottom={
|
||||
toolbar && (
|
||||
<div>
|
||||
<Line variant="SurfaceVariant" size="300" />
|
||||
<Toolbar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
10
src/app/features/room/RoomInputPlaceholder.css.ts
Normal file
10
src/app/features/room/RoomInputPlaceholder.css.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
export const RoomInputPlaceholder = style({
|
||||
minHeight: toRem(48),
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: config.radii.R400,
|
||||
});
|
||||
11
src/app/features/room/RoomInputPlaceholder.tsx
Normal file
11
src/app/features/room/RoomInputPlaceholder.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import React, { ComponentProps } from 'react';
|
||||
import { Box, as } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import * as css from './RoomInputPlaceholder.css';
|
||||
|
||||
export const RoomInputPlaceholder = as<'div', ComponentProps<typeof Box>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<Box className={classNames(css.RoomInputPlaceholder, className)} {...props} ref={ref} />
|
||||
)
|
||||
);
|
||||
30
src/app/features/room/RoomTimeline.css.ts
Normal file
30
src/app/features/room/RoomTimeline.css.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
|
||||
import { DefaultReset, config } from 'folds';
|
||||
|
||||
export const TimelineFloat = recipe({
|
||||
base: [
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
minWidth: 'max-content',
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
position: {
|
||||
Top: {
|
||||
top: config.space.S400,
|
||||
},
|
||||
Bottom: {
|
||||
bottom: config.space.S400,
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
position: 'Top',
|
||||
},
|
||||
});
|
||||
|
||||
export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
|
||||
1614
src/app/features/room/RoomTimeline.tsx
Normal file
1614
src/app/features/room/RoomTimeline.tsx
Normal file
File diff suppressed because it is too large
Load diff
7
src/app/features/room/RoomTombstone.css.ts
Normal file
7
src/app/features/room/RoomTombstone.css.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
|
||||
export const RoomTombstone = style({
|
||||
padding: config.space.S200,
|
||||
paddingLeft: config.space.S400,
|
||||
});
|
||||
67
src/app/features/room/RoomTombstone.tsx
Normal file
67
src/app/features/room/RoomTombstone.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { Box, Button, Spinner, Text, color } from 'folds';
|
||||
|
||||
import * as css from './RoomTombstone.css';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { genRoomVia } from '../../../util/matrixUtil';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { Membership } from '../../../types/matrix/room';
|
||||
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
|
||||
type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string };
|
||||
export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const [joinState, handleJoin] = useAsyncCallback(
|
||||
useCallback(() => {
|
||||
const currentRoom = mx.getRoom(roomId);
|
||||
const via = currentRoom ? genRoomVia(currentRoom) : [];
|
||||
return mx.joinRoom(replacementRoomId, {
|
||||
viaServers: via,
|
||||
});
|
||||
}, [mx, roomId, replacementRoomId])
|
||||
);
|
||||
const replacementRoom = mx.getRoom(replacementRoomId);
|
||||
|
||||
const handleOpen = () => {
|
||||
if (replacementRoom) navigateRoom(replacementRoom.roomId);
|
||||
if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId);
|
||||
};
|
||||
|
||||
return (
|
||||
<RoomInputPlaceholder alignItems="Center" gap="600" className={css.RoomTombstone}>
|
||||
<Box direction="Column" grow="Yes">
|
||||
<Text size="T400">{body || 'This room has been replaced and is no longer active.'}</Text>
|
||||
{joinState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T200">
|
||||
{(joinState.error as any)?.message ?? 'Failed to join replacement room!'}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{replacementRoom?.getMyMembership() === Membership.Join ||
|
||||
joinState.status === AsyncStatus.Success ? (
|
||||
<Button onClick={handleOpen} size="300" variant="Success" fill="Solid" radii="300">
|
||||
<Text size="B300">Open New Room</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleJoin}
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
before={
|
||||
joinState.status === AsyncStatus.Loading && (
|
||||
<Spinner size="100" variant="Primary" fill="Solid" />
|
||||
)
|
||||
}
|
||||
disabled={joinState.status === AsyncStatus.Loading}
|
||||
>
|
||||
<Text size="B300">Join New Room</Text>
|
||||
</Button>
|
||||
)}
|
||||
</RoomInputPlaceholder>
|
||||
);
|
||||
}
|
||||
84
src/app/features/room/RoomView.tsx
Normal file
84
src/app/features/room/RoomView.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { Box, Text, config } from 'folds';
|
||||
import { EventType, Room } from 'matrix-js-sdk';
|
||||
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useEditor } from '../../components/editor';
|
||||
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
||||
import { RoomTimeline } from './RoomTimeline';
|
||||
import { RoomViewTyping } from './RoomViewTyping';
|
||||
import { RoomTombstone } from './RoomTombstone';
|
||||
import { RoomInput } from './RoomInput';
|
||||
import { RoomViewFollowing } from './RoomViewFollowing';
|
||||
import { Page } from '../../components/page';
|
||||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
|
||||
export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||
const roomInputRef = useRef(null);
|
||||
const roomViewRef = useRef(null);
|
||||
|
||||
const { roomId } = room;
|
||||
const editor = useEditor();
|
||||
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const { getPowerLevel, canSendEvent } = usePowerLevelsAPI(powerLevels);
|
||||
const myUserId = mx.getUserId();
|
||||
const canMessage = myUserId
|
||||
? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId))
|
||||
: false;
|
||||
|
||||
return (
|
||||
<Page ref={roomViewRef}>
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
room={room}
|
||||
eventId={eventId}
|
||||
roomInputRef={roomInputRef}
|
||||
editor={editor}
|
||||
/>
|
||||
<RoomViewTyping room={room} />
|
||||
</Box>
|
||||
<Box shrink="No" direction="Column">
|
||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
||||
{tombstoneEvent ? (
|
||||
<RoomTombstone
|
||||
roomId={roomId}
|
||||
body={tombstoneEvent.getContent().body}
|
||||
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{canMessage && (
|
||||
<RoomInput
|
||||
room={room}
|
||||
editor={editor}
|
||||
roomId={roomId}
|
||||
fileDropContainerRef={roomViewRef}
|
||||
ref={roomInputRef}
|
||||
/>
|
||||
)}
|
||||
{!canMessage && (
|
||||
<RoomInputPlaceholder
|
||||
style={{ padding: config.space.S200 }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text align="Center">You do not have permission to post in this room</Text>
|
||||
</RoomInputPlaceholder>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<RoomViewFollowing room={room} />
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
31
src/app/features/room/RoomViewFollowing.css.ts
Normal file
31
src/app/features/room/RoomViewFollowing.css.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { recipe } from '@vanilla-extract/recipes';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
export const RoomViewFollowing = recipe({
|
||||
base: [
|
||||
DefaultReset,
|
||||
{
|
||||
minHeight: toRem(28),
|
||||
padding: `0 ${config.space.S400}`,
|
||||
width: '100%',
|
||||
backgroundColor: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
outline: 'none',
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
clickable: {
|
||||
true: {
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&:hover, &:focus-visible': {
|
||||
color: color.Primary.Main,
|
||||
},
|
||||
'&:active': {
|
||||
color: color.Primary.Main,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
139
src/app/features/room/RoomViewFollowing.tsx
Normal file
139
src/app/features/room/RoomViewFollowing.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Icon,
|
||||
Icons,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
as,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import classNames from 'classnames';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import * as css from './RoomViewFollowing.css';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
|
||||
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
|
||||
import { EventReaders } from '../../components/event-readers';
|
||||
|
||||
export type RoomViewFollowingProps = {
|
||||
room: Room;
|
||||
};
|
||||
export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
|
||||
({ className, room, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const latestEvent = useRoomLatestRenderedEvent(room);
|
||||
const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId());
|
||||
const names = latestEventReaders
|
||||
.filter((readerId) => readerId !== mx.getUserId())
|
||||
.map(
|
||||
(readerId) => getMemberDisplayName(room, readerId) ?? getMxIdLocalPart(readerId) ?? readerId
|
||||
);
|
||||
|
||||
const eventId = latestEvent?.getId();
|
||||
|
||||
return (
|
||||
<>
|
||||
{eventId && (
|
||||
<Overlay open={open} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setOpen(false),
|
||||
clickOutsideDeactivates: true,
|
||||
}}
|
||||
>
|
||||
<Modal variant="Surface" size="300">
|
||||
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
<Box
|
||||
as={names.length > 0 ? 'button' : 'div'}
|
||||
onClick={names.length > 0 ? () => setOpen(true) : undefined}
|
||||
className={classNames(css.RoomViewFollowing({ clickable: names.length > 0 }), className)}
|
||||
alignItems="Center"
|
||||
justifyContent="End"
|
||||
gap="200"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{names.length > 0 && (
|
||||
<>
|
||||
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
|
||||
<Text size="T300" truncate>
|
||||
{names.length === 1 && (
|
||||
<>
|
||||
<b>{names[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' is following the conversation.'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{names.length === 2 && (
|
||||
<>
|
||||
<b>{names[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{names[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' are following the conversation.'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{names.length === 3 && (
|
||||
<>
|
||||
<b>{names[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{names[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{names[2]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' are following the conversation.'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{names.length > 3 && (
|
||||
<>
|
||||
<b>{names[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{names[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{names[2]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{names.length - 3} others</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' are following the conversation.'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
10
src/app/features/room/RoomViewHeader.css.ts
Normal file
10
src/app/features/room/RoomViewHeader.css.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
|
||||
export const HeaderTopic = style({
|
||||
':hover': {
|
||||
cursor: 'pointer',
|
||||
opacity: config.opacity.P500,
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
});
|
||||
348
src/app/features/room/RoomViewHeader.tsx
Normal file
348
src/app/features/room/RoomViewHeader.tsx
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
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,
|
||||
} from 'folds';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { JoinRule, Room } from 'matrix-js-sdk';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { PageHeader } from '../../components/page';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useSetSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import {
|
||||
getHomeSearchPath,
|
||||
getOriginBaseUrl,
|
||||
getSpaceSearchPath,
|
||||
joinPathComponent,
|
||||
withOriginBaseUrl,
|
||||
withSearchParam,
|
||||
} from '../../pages/pathUtils';
|
||||
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
||||
import { _SearchPathSearchParams } from '../../pages/paths';
|
||||
import * as css from './RoomViewHeader.css';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { markAsRead } from '../../../client/action/notifications';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation';
|
||||
import { copyToClipboard } from '../../utils/dom';
|
||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
||||
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
|
||||
type RoomMenuProps = {
|
||||
room: Room;
|
||||
linkPath: string;
|
||||
requestClose: () => void;
|
||||
};
|
||||
const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
|
||||
({ room, linkPath, requestClose }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const { hashRouter } = useClientConfig();
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
|
||||
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
markAsRead(room.roomId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const handleInvite = () => {
|
||||
openInviteUser(room.roomId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
copyToClipboard(withOriginBaseUrl(getOriginBaseUrl(hashRouter), linkPath));
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const handleRoomSettings = () => {
|
||||
toggleRoomSettings(room.roomId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
disabled={!unread}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mark as Read
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleInvite}
|
||||
variant="Primary"
|
||||
fill="None"
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.UserPlus} />}
|
||||
radii="300"
|
||||
disabled={!canInvite}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Invite
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleCopyLink}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Link} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Copy Link
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleRoomSettings}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Setting} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Room Settings
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<UseStateProvider initial={false}>
|
||||
{(promptLeave, setPromptLeave) => (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => setPromptLeave(true)}
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
|
||||
radii="300"
|
||||
aria-pressed={promptLeave}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Leave Room
|
||||
</Text>
|
||||
</MenuItem>
|
||||
{promptLeave && (
|
||||
<LeaveRoomPrompt
|
||||
roomId={room.roomId}
|
||||
onDone={requestClose}
|
||||
onCancel={() => setPromptLeave(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export function RoomViewHeader() {
|
||||
const navigate = useNavigate();
|
||||
const mx = useMatrixClient();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const room = useRoom();
|
||||
const space = useSpaceOptionally();
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
|
||||
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
||||
const ecryptedRoom = !!encryptionEvent;
|
||||
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
|
||||
const name = useRoomName(room);
|
||||
const topic = useRoomTopic(room);
|
||||
const avatarUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc, 96, 96, 'crop') ?? undefined : undefined;
|
||||
|
||||
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const location = useLocation();
|
||||
const currentPath = joinPathComponent(location);
|
||||
|
||||
const handleSearchClick = () => {
|
||||
const searchParams: _SearchPathSearchParams = {
|
||||
rooms: room.roomId,
|
||||
};
|
||||
const path = space
|
||||
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
|
||||
: getHomeSearchPath();
|
||||
navigate(withSearchParam(path, searchParams));
|
||||
};
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
return (
|
||||
<PageHeader>
|
||||
<Box grow="Yes" gap="300">
|
||||
<Box grow="Yes" alignItems="Center" gap="300">
|
||||
<Avatar size="300">
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon size="200" joinRule={room.getJoinRule() ?? JoinRule.Restricted} filled />
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box direction="Column">
|
||||
<Text size={topic ? 'H5' : 'H3'} truncate>
|
||||
{name}
|
||||
</Text>
|
||||
{topic && (
|
||||
<UseStateProvider initial={false}>
|
||||
{(viewTopic, setViewTopic) => (
|
||||
<>
|
||||
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
clickOutsideDeactivates: true,
|
||||
onDeactivate: () => setViewTopic(false),
|
||||
}}
|
||||
>
|
||||
<RoomTopicViewer
|
||||
name={name}
|
||||
topic={topic}
|
||||
requestClose={() => setViewTopic(false)}
|
||||
/>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
<Text
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => setViewTopic(true)}
|
||||
className={css.HeaderTopic}
|
||||
size="T200"
|
||||
priority="300"
|
||||
truncate
|
||||
>
|
||||
{topic}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
{!ecryptedRoom && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Search</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} onClick={handleSearchClick}>
|
||||
<Icon size="400" src={Icons.Search} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Members</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
|
||||
<Icon size="400" src={Icons.User} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>More Options</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
|
||||
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
}}
|
||||
>
|
||||
<RoomMenu
|
||||
room={room}
|
||||
linkPath={currentPath}
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
27
src/app/features/room/RoomViewTyping.css.ts
Normal file
27
src/app/features/room/RoomViewTyping.css.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config } from 'folds';
|
||||
|
||||
const SlideUpAnime = keyframes({
|
||||
from: {
|
||||
transform: 'translateY(100%)',
|
||||
},
|
||||
to: {
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
});
|
||||
|
||||
export const RoomViewTyping = style([
|
||||
DefaultReset,
|
||||
{
|
||||
padding: `0 ${config.space.S500}`,
|
||||
width: '100%',
|
||||
backgroundColor: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
animation: `${SlideUpAnime} 100ms ease-in-out`,
|
||||
},
|
||||
]);
|
||||
export const TypingText = style({
|
||||
flexGrow: 1,
|
||||
});
|
||||
121
src/app/features/room/RoomViewTyping.tsx
Normal file
121
src/app/features/room/RoomViewTyping.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import React from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import classNames from 'classnames';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { roomIdToTypingMembersAtom } from '../../state/typingMembers';
|
||||
import { TypingIndicator } from '../../components/typing-indicator';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import * as css from './RoomViewTyping.css';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
||||
|
||||
export type RoomViewTypingProps = {
|
||||
room: Room;
|
||||
};
|
||||
export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
||||
({ className, room, ...props }, ref) => {
|
||||
const setTypingMembers = useSetAtom(roomIdToTypingMembersAtom);
|
||||
const mx = useMatrixClient();
|
||||
const typingMembers = useRoomTypingMember(room.roomId);
|
||||
|
||||
const typingNames = typingMembers
|
||||
.filter((receipt) => receipt.userId !== mx.getUserId())
|
||||
.map(
|
||||
(receipt) => getMemberDisplayName(room, receipt.userId) ?? getMxIdLocalPart(receipt.userId)
|
||||
)
|
||||
.reverse();
|
||||
|
||||
if (typingNames.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDropAll = () => {
|
||||
// some homeserver does not timeout typing status
|
||||
// we have given option so user can drop their typing status
|
||||
typingMembers.forEach((receipt) =>
|
||||
setTypingMembers({
|
||||
type: 'DELETE',
|
||||
roomId: room.roomId,
|
||||
userId: receipt.userId,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Box
|
||||
className={classNames(css.RoomViewTyping, className)}
|
||||
alignItems="Center"
|
||||
gap="400"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<TypingIndicator />
|
||||
<Text className={css.TypingText} size="T300" truncate>
|
||||
{typingNames.length === 1 && (
|
||||
<>
|
||||
<b>{typingNames[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' is typing...'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{typingNames.length === 2 && (
|
||||
<>
|
||||
<b>{typingNames[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{typingNames[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' are typing...'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{typingNames.length === 3 && (
|
||||
<>
|
||||
<b>{typingNames[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{typingNames[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{typingNames[2]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' are typing...'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{typingNames.length > 3 && (
|
||||
<>
|
||||
<b>{typingNames[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{typingNames[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{typingNames[2]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{typingNames.length - 3} others</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' are typing...'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
|
||||
<Icon size="50" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
1
src/app/features/room/index.ts
Normal file
1
src/app/features/room/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Room';
|
||||
23
src/app/features/room/message/EncryptedContent.tsx
Normal file
23
src/app/features/room/message/EncryptedContent.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from 'matrix-js-sdk';
|
||||
import React, { ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
type EncryptedContentProps = {
|
||||
mEvent: MatrixEvent;
|
||||
children: () => ReactNode;
|
||||
};
|
||||
|
||||
export function EncryptedContent({ mEvent, children }: EncryptedContentProps) {
|
||||
const [, toggleDecrypted] = useState(!mEvent.isBeingDecrypted());
|
||||
|
||||
useEffect(() => {
|
||||
const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = () => {
|
||||
toggleDecrypted((s) => !s);
|
||||
};
|
||||
mEvent.on(MatrixEventEvent.Decrypted, handleDecrypted);
|
||||
return () => {
|
||||
mEvent.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
|
||||
};
|
||||
}, [mEvent]);
|
||||
|
||||
return <>{children()}</>;
|
||||
}
|
||||
1148
src/app/features/room/message/Message.tsx
Normal file
1148
src/app/features/room/message/Message.tsx
Normal file
File diff suppressed because it is too large
Load diff
331
src/app/features/room/message/MessageEditor.tsx
Normal file
331
src/app/features/room/message/MessageEditor.tsx
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
import React, {
|
||||
KeyboardEventHandler,
|
||||
MouseEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Line,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Spinner,
|
||||
Text,
|
||||
as,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Editor, Transforms } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { IContent, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import {
|
||||
AUTOCOMPLETE_PREFIXES,
|
||||
AutocompletePrefix,
|
||||
AutocompleteQuery,
|
||||
CustomEditor,
|
||||
EmoticonAutocomplete,
|
||||
RoomMentionAutocomplete,
|
||||
Toolbar,
|
||||
UserMentionAutocomplete,
|
||||
createEmoticonElement,
|
||||
customHtmlEqualsPlainText,
|
||||
getAutocompleteQuery,
|
||||
getPrevWorldRange,
|
||||
htmlToEditorInput,
|
||||
moveCursor,
|
||||
plainToEditorInput,
|
||||
toMatrixCustomHTML,
|
||||
toPlainText,
|
||||
trimCustomHtml,
|
||||
useEditor,
|
||||
} from '../../../components/editor';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||
import { EmojiBoard } from '../../../components/emoji-board';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||
import { mobileOrTablet } from '../../../utils/user-agent';
|
||||
|
||||
type MessageEditorProps = {
|
||||
roomId: string;
|
||||
room: Room;
|
||||
mEvent: MatrixEvent;
|
||||
imagePackRooms?: Room[];
|
||||
onCancel: () => void;
|
||||
};
|
||||
export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const editor = useEditor();
|
||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
const [toolbar, setToolbar] = useState(globalToolbar);
|
||||
|
||||
const [autocompleteQuery, setAutocompleteQuery] =
|
||||
useState<AutocompleteQuery<AutocompletePrefix>>();
|
||||
|
||||
const getPrevBodyAndFormattedBody = useCallback((): [
|
||||
string | undefined,
|
||||
string | undefined
|
||||
] => {
|
||||
const evtId = mEvent.getId()!;
|
||||
const evtTimeline = room.getTimelineForEvent(evtId);
|
||||
const editedEvent =
|
||||
evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
|
||||
|
||||
const { body, formatted_body: customHtml }: Record<string, unknown> =
|
||||
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
|
||||
|
||||
return [
|
||||
typeof body === 'string' ? body : undefined,
|
||||
typeof customHtml === 'string' ? customHtml : undefined,
|
||||
];
|
||||
}, [room, mEvent]);
|
||||
|
||||
const [saveState, save] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const plainText = toPlainText(editor.children).trim();
|
||||
const customHtml = trimCustomHtml(
|
||||
toMatrixCustomHTML(editor.children, {
|
||||
allowTextFormatting: true,
|
||||
allowBlockMarkdown: isMarkdown,
|
||||
allowInlineMarkdown: isMarkdown,
|
||||
})
|
||||
);
|
||||
|
||||
const [prevBody, prevCustomHtml] = getPrevBodyAndFormattedBody();
|
||||
|
||||
if (plainText === '') return undefined;
|
||||
if (prevBody) {
|
||||
if (prevCustomHtml && trimReplyFromFormattedBody(prevCustomHtml) === customHtml) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
!prevCustomHtml &&
|
||||
prevBody === plainText &&
|
||||
customHtmlEqualsPlainText(customHtml, plainText)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const newContent: IContent = {
|
||||
msgtype: mEvent.getContent().msgtype,
|
||||
body: plainText,
|
||||
};
|
||||
|
||||
if (!customHtmlEqualsPlainText(customHtml, plainText)) {
|
||||
newContent.format = 'org.matrix.custom.html';
|
||||
newContent.formatted_body = customHtml;
|
||||
}
|
||||
|
||||
const content: IContent = {
|
||||
...newContent,
|
||||
body: `* ${plainText}`,
|
||||
'm.new_content': newContent,
|
||||
'm.relates_to': {
|
||||
event_id: mEvent.getId(),
|
||||
rel_type: RelationType.Replace,
|
||||
},
|
||||
};
|
||||
|
||||
return mx.sendMessage(roomId, content);
|
||||
}, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody])
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (saveState.status !== AsyncStatus.Loading) {
|
||||
save();
|
||||
}
|
||||
}, [saveState, save]);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
|
||||
evt.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
evt.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[onCancel, handleSave, enterForNewline]
|
||||
);
|
||||
|
||||
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const prevWordRange = getPrevWorldRange(editor);
|
||||
const query = prevWordRange
|
||||
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
|
||||
: undefined;
|
||||
setAutocompleteQuery(query);
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
const handleCloseAutocomplete = useCallback(() => {
|
||||
ReactEditor.focus(editor);
|
||||
setAutocompleteQuery(undefined);
|
||||
}, [editor]);
|
||||
|
||||
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
||||
editor.insertNode(createEmoticonElement(key, shortcode));
|
||||
moveCursor(editor);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const [body, customHtml] = getPrevBodyAndFormattedBody();
|
||||
|
||||
const initialValue =
|
||||
typeof customHtml === 'string'
|
||||
? htmlToEditorInput(customHtml)
|
||||
: plainToEditorInput(typeof body === 'string' ? body : '');
|
||||
|
||||
Transforms.select(editor, {
|
||||
anchor: Editor.start(editor, []),
|
||||
focus: Editor.end(editor, []),
|
||||
});
|
||||
|
||||
editor.insertFragment(initialValue);
|
||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||
}, [editor, getPrevBodyAndFormattedBody]);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveState.status === AsyncStatus.Success) {
|
||||
onCancel();
|
||||
}
|
||||
}, [saveState, onCancel]);
|
||||
|
||||
return (
|
||||
<div {...props} ref={ref}>
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
|
||||
<RoomMentionAutocomplete
|
||||
roomId={roomId}
|
||||
editor={editor}
|
||||
query={autocompleteQuery}
|
||||
requestClose={handleCloseAutocomplete}
|
||||
/>
|
||||
)}
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
|
||||
<UserMentionAutocomplete
|
||||
room={room}
|
||||
editor={editor}
|
||||
query={autocompleteQuery}
|
||||
requestClose={handleCloseAutocomplete}
|
||||
/>
|
||||
)}
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
|
||||
<EmoticonAutocomplete
|
||||
imagePackRooms={imagePackRooms || []}
|
||||
editor={editor}
|
||||
query={autocompleteQuery}
|
||||
requestClose={handleCloseAutocomplete}
|
||||
/>
|
||||
)}
|
||||
<CustomEditor
|
||||
editor={editor}
|
||||
placeholder="Edit message..."
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
bottom={
|
||||
<>
|
||||
<Box
|
||||
style={{ padding: config.space.S200, paddingTop: 0 }}
|
||||
alignItems="End"
|
||||
justifyContent="SpaceBetween"
|
||||
gap="100"
|
||||
>
|
||||
<Box gap="Inherit">
|
||||
<Chip
|
||||
onClick={handleSave}
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
disabled={saveState.status === AsyncStatus.Loading}
|
||||
outlined
|
||||
before={
|
||||
saveState.status === AsyncStatus.Loading ? (
|
||||
<Spinner variant="Primary" fill="Soft" size="100" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Text size="B300">Save</Text>
|
||||
</Chip>
|
||||
<Chip onClick={onCancel} variant="SurfaceVariant" radii="Pill">
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
<Box gap="Inherit">
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => setToolbar(!toolbar)}
|
||||
>
|
||||
<Icon size="400" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||
</IconButton>
|
||||
<UseStateProvider initial={undefined}>
|
||||
{(anchor: RectCords | undefined, setAnchor) => (
|
||||
<PopOut
|
||||
anchor={anchor}
|
||||
alignOffset={-8}
|
||||
position="Top"
|
||||
align="End"
|
||||
content={
|
||||
<EmojiBoard
|
||||
imagePackRooms={imagePackRooms ?? []}
|
||||
returnFocusOnDeactivate={false}
|
||||
onEmojiSelect={handleEmoticonSelect}
|
||||
onCustomEmojiSelect={handleEmoticonSelect}
|
||||
requestClose={() => {
|
||||
setAnchor(undefined);
|
||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
aria-pressed={anchor !== undefined}
|
||||
onClick={
|
||||
((evt) =>
|
||||
setAnchor(
|
||||
evt.currentTarget.getBoundingClientRect()
|
||||
)) as MouseEventHandler<HTMLButtonElement>
|
||||
}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="400" src={Icons.Smile} filled={anchor !== undefined} />
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
</Box>
|
||||
</Box>
|
||||
{toolbar && (
|
||||
<div>
|
||||
<Line variant="SurfaceVariant" size="300" />
|
||||
<Toolbar />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
125
src/app/features/room/message/Reactions.tsx
Normal file
125
src/app/features/room/message/Reactions.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import React, { MouseEventHandler, useCallback, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
as,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { type Relations } from 'matrix-js-sdk/lib/models/relations';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { factoryEventSentBy } from '../../../utils/matrix';
|
||||
import { Reaction, ReactionTooltipMsg } from '../../../components/message';
|
||||
import { useRelations } from '../../../hooks/useRelations';
|
||||
import * as css from './styles.css';
|
||||
import { ReactionViewer } from '../reaction-viewer';
|
||||
|
||||
export type ReactionsProps = {
|
||||
room: Room;
|
||||
mEventId: string;
|
||||
canSendReaction?: boolean;
|
||||
relations: Relations;
|
||||
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
|
||||
};
|
||||
export const Reactions = as<'div', ReactionsProps>(
|
||||
({ className, room, relations, mEventId, canSendReaction, onReactionToggle, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const [viewer, setViewer] = useState<boolean | string>(false);
|
||||
const myUserId = mx.getUserId();
|
||||
const reactions = useRelations(
|
||||
relations,
|
||||
useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
|
||||
);
|
||||
|
||||
const handleViewReaction: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
const key = evt.currentTarget.getAttribute('data-reaction-key');
|
||||
if (!key) setViewer(true);
|
||||
else setViewer(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.ReactionsContainer, className)}
|
||||
gap="200"
|
||||
wrap="Wrap"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{reactions.map(([key, events]) => {
|
||||
const rEvents = Array.from(events);
|
||||
if (rEvents.length === 0 || typeof key !== 'string') return null;
|
||||
const myREvent = myUserId ? rEvents.find(factoryEventSentBy(myUserId)) : undefined;
|
||||
const isPressed = !!myREvent?.getRelation();
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
key={key}
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip style={{ maxWidth: toRem(200) }}>
|
||||
<Text className={css.ReactionsTooltipText} size="T300">
|
||||
<ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
|
||||
</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(targetRef) => (
|
||||
<Reaction
|
||||
ref={targetRef}
|
||||
data-reaction-key={key}
|
||||
aria-pressed={isPressed}
|
||||
key={key}
|
||||
mx={mx}
|
||||
reaction={key}
|
||||
count={events.size}
|
||||
onClick={canSendReaction ? () => onReactionToggle(mEventId, key) : undefined}
|
||||
onContextMenu={handleViewReaction}
|
||||
aria-disabled={!canSendReaction}
|
||||
/>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
{reactions.length > 0 && (
|
||||
<Overlay
|
||||
onContextMenu={(evt: any) => {
|
||||
evt.stopPropagation();
|
||||
}}
|
||||
open={!!viewer}
|
||||
backdrop={<OverlayBackdrop />}
|
||||
>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setViewer(false),
|
||||
clickOutsideDeactivates: true,
|
||||
}}
|
||||
>
|
||||
<Modal variant="Surface" size="300">
|
||||
<ReactionViewer
|
||||
room={room}
|
||||
initialKey={typeof viewer === 'string' ? viewer : undefined}
|
||||
relations={relations}
|
||||
requestClose={() => setViewer(false)}
|
||||
/>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
3
src/app/features/room/message/index.ts
Normal file
3
src/app/features/room/message/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './Reactions';
|
||||
export * from './Message';
|
||||
export * from './EncryptedContent';
|
||||
50
src/app/features/room/message/styles.css.ts
Normal file
50
src/app/features/room/message/styles.css.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, config, toRem } from 'folds';
|
||||
|
||||
export const MessageBase = style({
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const MessageOptionsBase = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
top: toRem(-30),
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
},
|
||||
]);
|
||||
export const MessageOptionsBar = style([
|
||||
DefaultReset,
|
||||
{
|
||||
padding: config.space.S100,
|
||||
},
|
||||
]);
|
||||
|
||||
export const MessageAvatar = style({
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
export const MessageQuickReaction = style({
|
||||
minWidth: toRem(32),
|
||||
});
|
||||
|
||||
export const MessageMenuGroup = style({
|
||||
padding: config.space.S100,
|
||||
});
|
||||
|
||||
export const MessageMenuItemText = style({
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
export const ReactionsContainer = style({
|
||||
selectors: {
|
||||
'&:empty': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ReactionsTooltipText = style({
|
||||
wordBreak: 'break-word',
|
||||
});
|
||||
162
src/app/features/room/msgContent.ts
Normal file
162
src/app/features/room/msgContent.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
|
||||
import to from 'await-to-js';
|
||||
import { IThumbnailContent, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../types/matrix/common';
|
||||
import {
|
||||
getImageFileUrl,
|
||||
getThumbnail,
|
||||
getThumbnailDimensions,
|
||||
getVideoFileUrl,
|
||||
loadImageElement,
|
||||
loadVideoElement,
|
||||
} from '../../utils/dom';
|
||||
import { encryptFile, getImageInfo, getThumbnailContent, getVideoInfo } from '../../utils/matrix';
|
||||
import { TUploadItem } from '../../state/room/roomInputDrafts';
|
||||
import { encodeBlurHash } from '../../utils/blurHash';
|
||||
import { scaleYDimension } from '../../utils/common';
|
||||
|
||||
const generateThumbnailContent = async (
|
||||
mx: MatrixClient,
|
||||
img: HTMLImageElement | HTMLVideoElement,
|
||||
dimensions: [number, number],
|
||||
encrypt: boolean
|
||||
): Promise<IThumbnailContent> => {
|
||||
const thumbnail = await getThumbnail(img, ...dimensions);
|
||||
if (!thumbnail) throw new Error('Can not create thumbnail!');
|
||||
const encThumbData = encrypt ? await encryptFile(thumbnail) : undefined;
|
||||
const thumbnailFile = encThumbData?.file ?? thumbnail;
|
||||
if (!thumbnailFile) throw new Error('Can not create thumbnail!');
|
||||
|
||||
const data = await mx.uploadContent(thumbnailFile);
|
||||
const thumbMxc = data?.content_uri;
|
||||
if (!thumbMxc) throw new Error('Failed when uploading thumbnail!');
|
||||
const thumbnailContent = getThumbnailContent({
|
||||
thumbnail: thumbnailFile,
|
||||
encInfo: encThumbData?.encInfo,
|
||||
mxc: thumbMxc,
|
||||
width: dimensions[0],
|
||||
height: dimensions[1],
|
||||
});
|
||||
return thumbnailContent;
|
||||
};
|
||||
|
||||
export const getImageMsgContent = async (
|
||||
mx: MatrixClient,
|
||||
item: TUploadItem,
|
||||
mxc: string
|
||||
): Promise<IContent> => {
|
||||
const { file, originalFile, encInfo } = item;
|
||||
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
|
||||
if (imgError) console.warn(imgError);
|
||||
|
||||
const content: IContent = {
|
||||
msgtype: MsgType.Image,
|
||||
body: file.name,
|
||||
};
|
||||
if (imgEl) {
|
||||
const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height));
|
||||
|
||||
content.info = {
|
||||
...getImageInfo(imgEl, file),
|
||||
[MATRIX_BLUR_HASH_PROPERTY_NAME]: blurHash,
|
||||
};
|
||||
}
|
||||
if (encInfo) {
|
||||
content.file = {
|
||||
...encInfo,
|
||||
url: mxc,
|
||||
};
|
||||
} else {
|
||||
content.url = mxc;
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
export const getVideoMsgContent = async (
|
||||
mx: MatrixClient,
|
||||
item: TUploadItem,
|
||||
mxc: string
|
||||
): Promise<IContent> => {
|
||||
const { file, originalFile, encInfo } = item;
|
||||
|
||||
const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
|
||||
if (videoError) console.warn(videoError);
|
||||
|
||||
const content: IContent = {
|
||||
msgtype: MsgType.Video,
|
||||
body: file.name,
|
||||
};
|
||||
if (videoEl) {
|
||||
const [thumbError, thumbContent] = await to(
|
||||
generateThumbnailContent(
|
||||
mx,
|
||||
videoEl,
|
||||
getThumbnailDimensions(videoEl.videoWidth, videoEl.videoHeight),
|
||||
!!encInfo
|
||||
)
|
||||
);
|
||||
if (thumbContent && thumbContent.thumbnail_info) {
|
||||
thumbContent.thumbnail_info[MATRIX_BLUR_HASH_PROPERTY_NAME] = encodeBlurHash(
|
||||
videoEl,
|
||||
512,
|
||||
scaleYDimension(videoEl.videoWidth, 512, videoEl.videoHeight)
|
||||
);
|
||||
}
|
||||
if (thumbError) console.warn(thumbError);
|
||||
content.info = {
|
||||
...getVideoInfo(videoEl, file),
|
||||
...thumbContent,
|
||||
};
|
||||
}
|
||||
if (encInfo) {
|
||||
content.file = {
|
||||
...encInfo,
|
||||
url: mxc,
|
||||
};
|
||||
} else {
|
||||
content.url = mxc;
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
export const getAudioMsgContent = (item: TUploadItem, mxc: string): IContent => {
|
||||
const { file, encInfo } = item;
|
||||
const content: IContent = {
|
||||
msgtype: MsgType.Audio,
|
||||
body: file.name,
|
||||
info: {
|
||||
mimetype: file.type,
|
||||
size: file.size,
|
||||
},
|
||||
};
|
||||
if (encInfo) {
|
||||
content.file = {
|
||||
...encInfo,
|
||||
url: mxc,
|
||||
};
|
||||
} else {
|
||||
content.url = mxc;
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
export const getFileMsgContent = (item: TUploadItem, mxc: string): IContent => {
|
||||
const { file, encInfo } = item;
|
||||
const content: IContent = {
|
||||
msgtype: MsgType.File,
|
||||
body: file.name,
|
||||
filename: file.name,
|
||||
info: {
|
||||
mimetype: file.type,
|
||||
size: file.size,
|
||||
},
|
||||
};
|
||||
if (encInfo) {
|
||||
content.file = {
|
||||
...encInfo,
|
||||
url: mxc,
|
||||
};
|
||||
} else {
|
||||
content.url = mxc;
|
||||
}
|
||||
return content;
|
||||
};
|
||||
31
src/app/features/room/reaction-viewer/ReactionViewer.css.ts
Normal file
31
src/app/features/room/reaction-viewer/ReactionViewer.css.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config } from 'folds';
|
||||
|
||||
export const ReactionViewer = style([
|
||||
DefaultReset,
|
||||
{
|
||||
height: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Sidebar = style({
|
||||
backgroundColor: color.Background.Container,
|
||||
color: color.Background.OnContainer,
|
||||
});
|
||||
export const SidebarContent = style({
|
||||
padding: config.space.S200,
|
||||
paddingRight: 0,
|
||||
});
|
||||
|
||||
export const Header = style({
|
||||
paddingLeft: config.space.S400,
|
||||
paddingRight: config.space.S300,
|
||||
|
||||
flexShrink: 0,
|
||||
gap: config.space.S200,
|
||||
});
|
||||
|
||||
export const Content = style({
|
||||
paddingLeft: config.space.S200,
|
||||
paddingBottom: config.space.S400,
|
||||
});
|
||||
154
src/app/features/room/reaction-viewer/ReactionViewer.tsx
Normal file
154
src/app/features/room/reaction-viewer/ReactionViewer.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
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 { MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
|
||||
import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
||||
import { getMemberDisplayName } from '../../../utils/room';
|
||||
import { eventWithShortcode, getMxIdLocalPart } from '../../../utils/matrix';
|
||||
import * as css from './ReactionViewer.css';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { openProfileViewer } from '../../../../client/action/navigation';
|
||||
import { useRelations } from '../../../hooks/useRelations';
|
||||
import { Reaction } from '../../../components/message';
|
||||
import { getHexcodeForEmoji, getShortcodeFor } from '../../../plugins/emoji';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
|
||||
export type ReactionViewerProps = {
|
||||
room: Room;
|
||||
initialKey?: string;
|
||||
relations: Relations;
|
||||
requestClose: () => void;
|
||||
};
|
||||
export const ReactionViewer = as<'div', ReactionViewerProps>(
|
||||
({ className, room, initialKey, relations, requestClose, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const reactions = useRelations(
|
||||
relations,
|
||||
useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
|
||||
);
|
||||
|
||||
const [selectedKey, setSelectedKey] = useState<string>(() => {
|
||||
if (initialKey) return initialKey;
|
||||
const defaultReaction = reactions.find((reaction) => typeof reaction[0] === 'string');
|
||||
return defaultReaction ? defaultReaction[0] : '';
|
||||
});
|
||||
|
||||
const getName = (member: RoomMember) =>
|
||||
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
|
||||
|
||||
const getReactionsForKey = (key: string): MatrixEvent[] => {
|
||||
const reactSet = reactions.find(([k]) => k === key)?.[1];
|
||||
if (!reactSet) return [];
|
||||
return Array.from(reactSet);
|
||||
};
|
||||
|
||||
const selectedReactions = getReactionsForKey(selectedKey);
|
||||
const selectedShortcode =
|
||||
selectedReactions.find(eventWithShortcode)?.getContent().shortcode ??
|
||||
getShortcodeFor(getHexcodeForEmoji(selectedKey)) ??
|
||||
selectedKey;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.ReactionViewer, className)}
|
||||
direction="Row"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Box shrink="No" className={css.Sidebar}>
|
||||
<Scroll visibility="Hover" hideTrack size="300">
|
||||
<Box className={css.SidebarContent} direction="Column" gap="200">
|
||||
{reactions.map(([key, evts]) => {
|
||||
if (typeof key !== 'string') return null;
|
||||
return (
|
||||
<Reaction
|
||||
key={key}
|
||||
mx={mx}
|
||||
reaction={key}
|
||||
count={evts.size}
|
||||
aria-selected={key === selectedKey}
|
||||
onClick={() => setSelectedKey(key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
<Line variant="Surface" direction="Vertical" size="300" />
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Header className={css.Header} variant="Surface" size="600">
|
||||
<Box grow="Yes">
|
||||
<Text size="H3" truncate>{`Reacted with :${selectedShortcode}:`}</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={requestClose}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
<Box grow="Yes">
|
||||
<Scroll visibility="Hover" hideTrack size="300">
|
||||
<Box className={css.Content} direction="Column">
|
||||
{selectedReactions.map((mEvent) => {
|
||||
const senderId = mEvent.getSender();
|
||||
if (!senderId) return null;
|
||||
const member = room.getMember(senderId);
|
||||
const name = (member ? getName(member) : getMxIdLocalPart(senderId)) ?? senderId;
|
||||
|
||||
const avatarUrl = member?.getAvatarUrl(
|
||||
mx.baseUrl,
|
||||
100,
|
||||
100,
|
||||
'crop',
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={senderId}
|
||||
style={{ padding: `0 ${config.space.S200}` }}
|
||||
radii="400"
|
||||
onClick={() => {
|
||||
requestClose();
|
||||
openProfileViewer(senderId, room.roomId);
|
||||
}}
|
||||
before={
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={senderId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T400" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
1
src/app/features/room/reaction-viewer/index.ts
Normal file
1
src/app/features/room/reaction-viewer/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './ReactionViewer';
|
||||
Loading…
Add table
Add a link
Reference in a new issue