mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-14 19:20:28 +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
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