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:
Ajay Bura 2024-05-31 19:49:46 +05:30 committed by GitHub
parent 2b7d825694
commit 4c76a7fd18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
290 changed files with 17447 additions and 3224 deletions

View 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>
);
}

View 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%',
});

View 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>
);
}

View 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>
);
}

View 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>
);
}
);

View 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,
});

View 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} />
)
);

View 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>;

File diff suppressed because it is too large Load diff

View 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,
});

View 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>
);
}

View 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>
);
}

View 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,
},
},
},
},
},
});

View 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>
</>
);
}
);

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

View 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>
);
}

View 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,
});

View 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>
);
}
);

View file

@ -0,0 +1 @@
export * from './Room';

View 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()}</>;
}

File diff suppressed because it is too large Load diff

View 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>
);
}
);

View 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>
);
}
);

View file

@ -0,0 +1,3 @@
export * from './Reactions';
export * from './Message';
export * from './EncryptedContent';

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

View 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;
};

View 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,
});

View 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>
);
}
);

View file

@ -0,0 +1 @@
export * from './ReactionViewer';