mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-17 20:50:29 +03:00
URL navigation in interface and other improvements (#1633)
* load room on url change * add direct room list * render space room list * fix css syntax error * update scroll virtualizer * render subspaces room list * improve sidebar notification badge perf * add nav category components * add space recursive direct component * use nav category component in home, direct and space room list * add empty home and direct list layout * fix unread room menu ref * add more navigation items in room, direct and space tab * add more navigation * fix unread room menu to links * fix space lobby and search link * add explore navigation section * add notifications navigation menu * redirect to initial path after login * include unsupported room in rooms * move router hooks in hooks/router folder * add featured explore - WIP * load featured room with room summary * fix room card topic line clamp * add react query * load room summary using react query * add join button in room card * add content component * use content component in featured community content * fix content width * add responsive room card grid * fix async callback error status * add room card error button * fix client drawer shrink * add room topic viewer * open room card topic in viewer * fix room topic close btn * add get orphan parent util * add room card error dialog * add view featured room or space btn * refactor orphanParent to orphanParents * WIP - explore server * show space hint in room card * add room type filters * add per page item limit popout * reset scroll on public rooms load * refactor explore ui * refactor public rooms component * reset search on server change * fix typo * add empty featured section info * display user server on top * make server room card view btn clickable * add user server as default redirect for explore path * make home empty btn clickable * add thirdparty instance filter in server explore * remove since param on instance change * add server button in explore menu * rename notifications path to inbox * update react-virtual * Add notification messages inbox - WIP * add scroll top container component * add useInterval hook * add visibility change callback prop to scroll top container component * auto refresh notifications every 10 seconds * make message related component reusable * refactor matrix event renderer hoook * render notification message content * refactor matrix event renderer hook * update sequence card styles * move room navigate hook in global hooks * add open message button in notifications * add mark room as read button in notification group * show error in notification messages * add more featured spaces * render reply in notification messages * make notification message reply clickable * add outline prop for attachments * make old settings dialog viewable * add open featured communities as default config option * add invite count notification badge in sidebar and inbox menu * add element size observer hook * improve element size observer hook props * improve screen size hook * fix room avatar util function * allow Text props in Time component * fix dm room util function * add invitations * add no invites and notification cards * fix inbox tab unread badge visible without invite count * update folds and change inbox icon * memo search param construction * add message search in home * fix default message search order * fix display edited message new content * highlight search text in search messages * fix message search loading * disable log in production * add use space context * add useRoom context * fix space room list * fix inbox tab active state * add hook to get space child room recursive * add search for space * add virtual tile component * virtualize home and directs room list * update nav category component * use virtual tile component in more places * fix message highlight when click on reply twice * virtualize space room list * fix space room list lag issue * update folds * add room nav item component in space room list * use room nav item in home and direct room list * make space categories closable and save it in local storage * show unread room when category is collapsed * make home and direct room list category closable * rename room nav item show avatar prop * fix explore server category text alignment * rename closedRoomCategories to closedNavCategories * add nav category handler hook * save and restore last navigation path on space select * filter space rooms category by activity when it is closed * save and restore home and direct nav path state * save and restore inbox active path on open * save and restore explore tab active path * remove notification badge unread menu * add join room or space before navigate screen * move room component to features folder and add new room header * update folds * add room header menu * fix home room list activity sorting * do not hide selected room item on category closed in home and direct tab * replace old select room/tab call with navigate hook * improve state event hooks * show room card summary for joined rooms * prevent room from opening in wrong tab * only show message sender id on hover in modern layout * revert state event hooks changes * add key prop to room provider components * add welcome page * prevent excessive redirects * fix sidebar style with no spaces * move room settings in popup window * remove invite option from room settings * fix open room list search * add leave room prompt * standardize room and user avatar * fix avatar text size * add new reply layout * rename space hierarchy hook * add room topic hook * add room name hook * add room avatar hook and add direct room avatar util * space lobby - WIP * hide invalid space child event from space hierarchy in lobby * move lobby to features * fix element size observer hook width and height * add lobby header and hero section * add hierarchy room item error and loading state * add first and last child prop in sequence card * redirect to lobby from index path * memo and retry hierarchy room summary error * fix hierarchy room item styles * rename lobby hierarchy item card to room item card * show direct room avatar in space lobby * add hierarchy space item * add space item unknown room join button * fix space hierarchy hook refresh after new space join * change user avatar color and fallback render to user icon * change room avatar fallback to room icon * rename room/user avatar renderInitial prop to renderFallback * add room join and view button in space lobby * make power level api more reusable * fix space hierarchy not updating on child update * add menu to suggest or remove space children * show reply arrow in place of reply bend in message * fix typeerror in search because of wrong js-sdk t.ds * do not refetch hierarchy room summary on window focus * make room/user avatar un-draggable * change welcome page support button copy * drag-and-drop ordering of lobby spaces/rooms - WIP * add ASCIILexicalTable algorithms * fix wrong power level check in lobby items options * fix lobby can drop checks * fix join button error crash * fix reply spacing * fix m direct updated with other account data * add option to open room/space settings from lobby * add option in lobby to add new or existing room/spaces * fix room nav item selected styles * add space children reorder mechanism * fix space child reorder bug * fix hierarchy item sort function * Apply reorder of lobby into room list * add and improve space lobby menu items * add existing spaces menu in lobby * change restricted room allow params when dragging outside space * move featured servers config from homeserver list * removed unused features from space settings * add canonical alias as name fallback in lobby item * fix unreliable unread count update bug * fix after login redirect * fix room card topic hover style * Add dnd and folders in sidebar spaces * fix orphan space not visible in sidebar * fix sso login has mix of icon and button * fix space children not visible in home upon leaving space * recalculate notification on updating any space child * fix user color saturation/lightness * add user color to user avatar * add background colors to room avatar * show 2 length initial in sidebar space avatar * improve link color * add nav button component * open legacy create room and create direct * improve page route structure * handle hash router in path utils * mobile friendly router and navigation * make room header member drawer icon mobile friendly * setup index redirect for inbox and explore server route * add leave space prompt * improve member drawer filter menu * add space context menu * add context menu in home * add leave button in lobby items * render user tab avatar on sidebar * force overwrite netlify - test * netlify test * fix reset-password path without server redirected to login * add message link copy button in message menu * reset unread on sync prepared * fix stuck typing notifications * show typing indication in room nav item * refactor closedNavCategories atom to use userId in store key * refactor closedLobbyCategoriesAtom to include userId in store key * refactor navToActivePathAtom to use userId in storage key * remove unused file * refactor openedSidebarFolderAtom to include userId in storage key * add context menu for sidebar space tab * fix eslint not working * add option to pin/unpin child spaces * add context menu for directs tab * add context menu for direct and home tab * show lock icon for non-public space in header * increase matrix max listener count * wrap lobby add space room in callback hook
This commit is contained in:
parent
2b7d825694
commit
4c76a7fd18
290 changed files with 17447 additions and 3224 deletions
|
|
@ -72,16 +72,16 @@ function Drawer() {
|
|||
<div className="rooms__wrapper">
|
||||
<ScrollView ref={scrollRef} autoHide>
|
||||
<div className="rooms-container">
|
||||
{
|
||||
selectedTab !== cons.tabs.DIRECTS
|
||||
? <Home spaceId={spaceId} />
|
||||
: <Directs size={roomList.directs.size} />
|
||||
}
|
||||
{selectedTab !== cons.tabs.DIRECTS ? (
|
||||
<Home spaceId={spaceId} />
|
||||
) : (
|
||||
<Directs size={roomList.directs.size} />
|
||||
)}
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
</div>
|
||||
{ systemState !== null && (
|
||||
{systemState !== null && (
|
||||
<div className="drawer__state">
|
||||
<Text>{systemState.status}</Text>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,125 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Icon, Icons, Badge, AvatarFallback, Text } from 'folds';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarStackSeparator,
|
||||
SidebarStack,
|
||||
SidebarAvatar,
|
||||
} from '../../components/sidebar';
|
||||
import { selectedTabAtom, SidebarTab } from '../../state/selectedTab';
|
||||
|
||||
export function Sidebar1() {
|
||||
const [selectedTab, setSelectedTab] = useAtom(selectedTabAtom);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarContent
|
||||
scrollable={
|
||||
<>
|
||||
<SidebarStack>
|
||||
<SidebarAvatar
|
||||
active={selectedTab === SidebarTab.Home}
|
||||
outlined
|
||||
tooltip="Home"
|
||||
avatarChildren={<Icon src={Icons.Home} filled />}
|
||||
onClick={() => setSelectedTab(SidebarTab.Home)}
|
||||
/>
|
||||
<SidebarAvatar
|
||||
active={selectedTab === SidebarTab.People}
|
||||
outlined
|
||||
tooltip="People"
|
||||
avatarChildren={<Icon src={Icons.User} />}
|
||||
onClick={() => setSelectedTab(SidebarTab.People)}
|
||||
/>
|
||||
</SidebarStack>
|
||||
<SidebarStackSeparator />
|
||||
<SidebarStack>
|
||||
<SidebarAvatar
|
||||
tooltip="Space A"
|
||||
notificationBadge={(badgeClassName) => (
|
||||
<Badge
|
||||
className={badgeClassName}
|
||||
size="200"
|
||||
variant="Secondary"
|
||||
fill="Solid"
|
||||
radii="Pill"
|
||||
/>
|
||||
)}
|
||||
avatarChildren={
|
||||
<AvatarFallback
|
||||
style={{
|
||||
backgroundColor: 'red',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Text size="T500">B</Text>
|
||||
</AvatarFallback>
|
||||
}
|
||||
/>
|
||||
<SidebarAvatar
|
||||
tooltip="Space B"
|
||||
hasCount
|
||||
notificationBadge={(badgeClassName) => (
|
||||
<Badge className={badgeClassName} radii="Pill" fill="Solid" variant="Secondary">
|
||||
<Text size="L400">64</Text>
|
||||
</Badge>
|
||||
)}
|
||||
avatarChildren={
|
||||
<AvatarFallback
|
||||
style={{
|
||||
backgroundColor: 'green',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Text size="T500">C</Text>
|
||||
</AvatarFallback>
|
||||
}
|
||||
/>
|
||||
</SidebarStack>
|
||||
<SidebarStackSeparator />
|
||||
<SidebarStack>
|
||||
<SidebarAvatar
|
||||
outlined
|
||||
tooltip="Explore Community"
|
||||
avatarChildren={<Icon src={Icons.Explore} />}
|
||||
/>
|
||||
<SidebarAvatar
|
||||
outlined
|
||||
tooltip="Create Space"
|
||||
avatarChildren={<Icon src={Icons.Plus} />}
|
||||
/>
|
||||
</SidebarStack>
|
||||
</>
|
||||
}
|
||||
sticky={
|
||||
<>
|
||||
<SidebarStackSeparator />
|
||||
<SidebarStack>
|
||||
<SidebarAvatar
|
||||
outlined
|
||||
tooltip="Search"
|
||||
avatarChildren={<Icon src={Icons.Search} />}
|
||||
/>
|
||||
<SidebarAvatar
|
||||
tooltip="User Settings"
|
||||
avatarChildren={
|
||||
<AvatarFallback
|
||||
style={{
|
||||
backgroundColor: 'blue',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Text size="T500">A</Text>
|
||||
</AvatarFallback>
|
||||
}
|
||||
/>
|
||||
</SidebarStack>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,14 +9,18 @@ import InviteUser from '../invite-user/InviteUser';
|
|||
import Settings from '../settings/Settings';
|
||||
import SpaceSettings from '../space-settings/SpaceSettings';
|
||||
import SpaceManage from '../space-manage/SpaceManage';
|
||||
import RoomSettings from '../room/RoomSettings';
|
||||
|
||||
function Windows() {
|
||||
const [isInviteList, changeInviteList] = useState(false);
|
||||
const [publicRooms, changePublicRooms] = useState({
|
||||
isOpen: false, searchTerm: undefined,
|
||||
isOpen: false,
|
||||
searchTerm: undefined,
|
||||
});
|
||||
const [inviteUser, changeInviteUser] = useState({
|
||||
isOpen: false, roomId: undefined, term: undefined,
|
||||
isOpen: false,
|
||||
roomId: undefined,
|
||||
term: undefined,
|
||||
});
|
||||
|
||||
function openInviteList() {
|
||||
|
|
@ -49,10 +53,7 @@ function Windows() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<InviteList
|
||||
isOpen={isInviteList}
|
||||
onRequestClose={() => changeInviteList(false)}
|
||||
/>
|
||||
<InviteList isOpen={isInviteList} onRequestClose={() => changeInviteList(false)} />
|
||||
<PublicRooms
|
||||
isOpen={publicRooms.isOpen}
|
||||
searchTerm={publicRooms.searchTerm}
|
||||
|
|
@ -66,6 +67,7 @@ function Windows() {
|
|||
/>
|
||||
<Settings />
|
||||
<SpaceSettings />
|
||||
<RoomSettings />
|
||||
<SpaceManage />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
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%',
|
||||
});
|
||||
|
|
@ -1,566 +0,0 @@
|
|||
import React, {
|
||||
ChangeEventHandler,
|
||||
MouseEventHandler,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Badge,
|
||||
Box,
|
||||
Chip,
|
||||
ContainerColor,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
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 millify from 'millify';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { openInviteUser, openProfileViewer } from '../../../client/action/navigation';
|
||||
import * as css from './MembersDrawer.css';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import {
|
||||
getIntersectionObserverEntry,
|
||||
useIntersectionObserver,
|
||||
} from '../../hooks/useIntersectionObserver';
|
||||
import { Membership } from '../../../types/matrix/room';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import {
|
||||
SearchItemStrGetter,
|
||||
UseAsyncSearchOptions,
|
||||
useAsyncSearch,
|
||||
} from '../../hooks/useAsyncSearch';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { usePowerLevelTags, PowerLevelTag } from '../../hooks/usePowerLevelTags';
|
||||
import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../../state/typingMembers';
|
||||
import { TypingIndicator } from '../../components/typing-indicator';
|
||||
import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
|
||||
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 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 [onTop, setOnTop] = useState(true);
|
||||
|
||||
const typingMembers = useAtomValue(
|
||||
useMemo(() => selectRoomTypingMembersAtom(room.roomId, roomIdToTypingMembersAtom), [room])
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
useIntersectionObserver(
|
||||
useCallback((intersectionEntries) => {
|
||||
if (!scrollTopAnchorRef.current) return;
|
||||
const entry = getIntersectionObserverEntry(scrollTopAnchorRef.current, intersectionEntries);
|
||||
if (entry) setOnTop(entry.isIntersecting);
|
||||
}, []),
|
||||
useCallback(() => ({ root: scrollRef.current }), []),
|
||||
useCallback(() => scrollTopAnchorRef.current, [])
|
||||
);
|
||||
|
||||
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} 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 size="H5" truncate>
|
||||
{`${millify(room.getJoinedMemberCount(), { precision: 1, locales: [] })} Members`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center">
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Invite Member</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
variant="Background"
|
||||
onClick={() => openInviteUser(room.roomId)}
|
||||
>
|
||||
<Icon src={Icons.UserPlus} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</Box>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box className={css.MemberDrawerContentBase} grow="Yes">
|
||||
<Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover">
|
||||
<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={false}>
|
||||
{(open, setOpen) => (
|
||||
<PopOut
|
||||
open={open}
|
||||
position="Bottom"
|
||||
align="Start"
|
||||
offset={4}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setOpen(false),
|
||||
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}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
setMembershipFilterIndex(index);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text>{menuItem.name}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
{(anchorRef) => (
|
||||
<Chip
|
||||
ref={anchorRef}
|
||||
onClick={() => setOpen(!open)}
|
||||
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={false}>
|
||||
{(open, setOpen) => (
|
||||
<PopOut
|
||||
open={open}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={4}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setOpen(false),
|
||||
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}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
setSortFilterIndex(index);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text>{menuItem.name}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
{(anchorRef) => (
|
||||
<Chip
|
||||
ref={anchorRef}
|
||||
onClick={() => setOpen(!open)}
|
||||
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>
|
||||
|
||||
{!onTop && (
|
||||
<Box className={css.DrawerScrollTop}>
|
||||
<IconButton
|
||||
onClick={() => virtualizer.scrollToOffset(0)}
|
||||
variant="Surface"
|
||||
radii="Pill"
|
||||
outlined
|
||||
size="300"
|
||||
aria-label="Scroll to Top"
|
||||
>
|
||||
<Icon src={Icons.ChevronTop} size="300" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!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">
|
||||
{avatarUrl ? (
|
||||
<AvatarImage src={avatarUrl} />
|
||||
) : (
|
||||
<AvatarFallback
|
||||
style={{
|
||||
background: colorMXID(member.userId),
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Text size="H6">{name[0]}</Text>
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
}
|
||||
after={
|
||||
typingMembers.find((tm) => tm.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>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
.room {
|
||||
@extend .cp-fx__row;
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
|
||||
&__content {
|
||||
@extend .cp-fx__item-one;
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
import React from 'react';
|
||||
import './Room.scss';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Line } from 'folds';
|
||||
|
||||
import RoomView from './RoomView';
|
||||
import RoomSettings from './RoomSettings';
|
||||
import { MembersDrawer } from './MembersDrawer';
|
||||
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import {
|
||||
roomIdToTypingMembersAtom,
|
||||
useBindRoomIdToTypingMembersAtom,
|
||||
} from '../../state/typingMembers';
|
||||
|
||||
export type RoomBaseViewProps = {
|
||||
room: Room;
|
||||
eventId?: string;
|
||||
};
|
||||
export function RoomBaseView({ room, eventId }: RoomBaseViewProps) {
|
||||
useBindRoomIdToTypingMembersAtom(room.client, roomIdToTypingMembersAtom);
|
||||
|
||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [screenSize] = useScreenSize();
|
||||
const powerLevelAPI = usePowerLevels(room);
|
||||
|
||||
return (
|
||||
<PowerLevelsContextProvider value={powerLevelAPI}>
|
||||
<div className="room">
|
||||
<div className="room__content">
|
||||
<RoomSettings roomId={room.roomId} />
|
||||
<RoomView room={room} eventId={eventId} />
|
||||
</div>
|
||||
|
||||
{screenSize === ScreenSize.Desktop && isDrawer && (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<MembersDrawer key={room.roomId} room={room} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PowerLevelsContextProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,595 +0,0 @@
|
|||
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 } 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/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 { MessageReply } from '../../molecules/message/Message';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import {
|
||||
parseReplyBody,
|
||||
parseReplyFormattedBody,
|
||||
trimReplyFromBody,
|
||||
trimReplyFromFormattedBody,
|
||||
} from '../../utils/room';
|
||||
import { sanitizeText } from '../../utils/sanitize';
|
||||
import { useScreenSize } from '../../hooks/useScreenSize';
|
||||
import { CommandAutocomplete } from './CommandAutocomplete';
|
||||
import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
|
||||
import { mobileOrTablet } from '../../utils/user-agent';
|
||||
|
||||
interface RoomInputProps {
|
||||
editor: Editor;
|
||||
roomViewRef: RefObject<HTMLElement>;
|
||||
roomId: string;
|
||||
room: Room;
|
||||
}
|
||||
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
({ editor, roomViewRef, roomId, room }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
const commands = useCommands(mx, room);
|
||||
|
||||
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(roomViewRef, handleFiles);
|
||||
|
||||
const [, screenWidth] = useScreenSize();
|
||||
const hideStickerBtn = screenWidth < 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();
|
||||
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();
|
||||
}
|
||||
},
|
||||
[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()}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon src={Icons.Cross} size="50" />
|
||||
</IconButton>
|
||||
<MessageReply
|
||||
color={colorMXID(replyDraft.userId)}
|
||||
name={room?.getMember(replyDraft.userId)?.name ?? replyDraft.userId}
|
||||
body={replyDraft.body}
|
||||
/>
|
||||
</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"
|
||||
open={!!emojiBoardTab}
|
||||
content={
|
||||
<EmojiBoard
|
||||
tab={emojiBoardTab}
|
||||
onTabChange={setEmojiBoardTab}
|
||||
imagePackRooms={imagePackRooms}
|
||||
returnFocusOnDeactivate={false}
|
||||
onEmojiSelect={handleEmoticonSelect}
|
||||
onCustomEmojiSelect={handleEmoticonSelect}
|
||||
onStickerSelect={handleStickerSelect}
|
||||
requestClose={() => {
|
||||
setEmojiBoardTab(undefined);
|
||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(anchorRef) => (
|
||||
<>
|
||||
{!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={anchorRef}
|
||||
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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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,
|
||||
});
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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} />
|
||||
)
|
||||
);
|
||||
|
|
@ -2,22 +2,15 @@ import React, { useState, useEffect } from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import './RoomSettings.scss';
|
||||
|
||||
import { blurOnBubbling } from '../../atoms/button/script';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import Tabs from '../../atoms/tabs/Tabs';
|
||||
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
|
||||
import RoomProfile from '../../molecules/room-profile/RoomProfile';
|
||||
import RoomSearch from '../../molecules/room-search/RoomSearch';
|
||||
import RoomNotification from '../../molecules/room-notification/RoomNotification';
|
||||
import RoomVisibility from '../../molecules/room-visibility/RoomVisibility';
|
||||
import RoomAliases from '../../molecules/room-aliases/RoomAliases';
|
||||
|
|
@ -30,67 +23,59 @@ import RoomEmojis from '../../molecules/room-emojis/RoomEmojis';
|
|||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
||||
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
|
||||
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||
import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
|
||||
import LockIC from '../../../../public/res/ic/outlined/lock.svg';
|
||||
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
||||
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
||||
import ChevronTopIC from '../../../../public/res/ic/outlined/chevron-top.svg';
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||
import PopupWindow from '../../molecules/popup-window/PopupWindow';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
|
||||
const tabText = {
|
||||
GENERAL: 'General',
|
||||
SEARCH: 'Search',
|
||||
MEMBERS: 'Members',
|
||||
EMOJIS: 'Emojis',
|
||||
PERMISSIONS: 'Permissions',
|
||||
SECURITY: 'Security',
|
||||
};
|
||||
|
||||
const tabItems = [{
|
||||
iconSrc: SettingsIC,
|
||||
text: tabText.GENERAL,
|
||||
disabled: false,
|
||||
}, {
|
||||
iconSrc: SearchIC,
|
||||
text: tabText.SEARCH,
|
||||
disabled: false,
|
||||
}, {
|
||||
iconSrc: UserIC,
|
||||
text: tabText.MEMBERS,
|
||||
disabled: false,
|
||||
}, {
|
||||
iconSrc: EmojiIC,
|
||||
text: tabText.EMOJIS,
|
||||
disabled: false,
|
||||
}, {
|
||||
iconSrc: ShieldUserIC,
|
||||
text: tabText.PERMISSIONS,
|
||||
disabled: false,
|
||||
}, {
|
||||
iconSrc: LockIC,
|
||||
text: tabText.SECURITY,
|
||||
disabled: false,
|
||||
}];
|
||||
const tabItems = [
|
||||
{
|
||||
iconSrc: SettingsIC,
|
||||
text: tabText.GENERAL,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
iconSrc: UserIC,
|
||||
text: tabText.MEMBERS,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
iconSrc: EmojiIC,
|
||||
text: tabText.EMOJIS,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
iconSrc: ShieldUserIC,
|
||||
text: tabText.PERMISSIONS,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
iconSrc: LockIC,
|
||||
text: tabText.SECURITY,
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
function GeneralSettings({ roomId }) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(roomId);
|
||||
const canInvite = room.canInvite(mx.getUserId());
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="room-settings__card">
|
||||
<MenuHeader>Options</MenuHeader>
|
||||
<MenuItem
|
||||
disabled={!canInvite}
|
||||
onClick={() => openInviteUser(roomId)}
|
||||
iconSrc={AddUserIC}
|
||||
>
|
||||
Invite
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
variant="danger"
|
||||
onClick={async () => {
|
||||
|
|
@ -98,7 +83,7 @@ function GeneralSettings({ roomId }) {
|
|||
'Leave room',
|
||||
`Are you sure that you want to leave "${room.name}" room?`,
|
||||
'Leave',
|
||||
'danger',
|
||||
'danger'
|
||||
);
|
||||
if (!isConfirmed) return;
|
||||
roomActions.leave(roomId);
|
||||
|
|
@ -146,54 +131,52 @@ SecuritySettings.propTypes = {
|
|||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function RoomSettings({ roomId }) {
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
function useWindowToggle(setSelectedTab) {
|
||||
const [window, setWindow] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const openRoomSettings = (roomId, tab) => {
|
||||
setWindow({ roomId, tabText });
|
||||
const tabItem = tabItems.find((item) => item.text === tab);
|
||||
if (tabItem) setSelectedTab(tabItem);
|
||||
};
|
||||
navigation.on(cons.events.navigation.ROOM_SETTINGS_TOGGLED, openRoomSettings);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.ROOM_SETTINGS_TOGGLED, openRoomSettings);
|
||||
};
|
||||
}, [setSelectedTab]);
|
||||
|
||||
const requestClose = () => setWindow(null);
|
||||
|
||||
return [window, requestClose];
|
||||
}
|
||||
|
||||
function RoomSettings() {
|
||||
const [selectedTab, setSelectedTab] = useState(tabItems[0]);
|
||||
const [window, requestClose] = useWindowToggle(setSelectedTab);
|
||||
const isOpen = window !== null;
|
||||
const roomId = window?.roomId;
|
||||
const room = initMatrix.matrixClient.getRoom(roomId);
|
||||
|
||||
const handleTabChange = (tabItem) => {
|
||||
setSelectedTab(tabItem);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const settingsToggle = (isVisible, tab) => {
|
||||
if (!mounted) return;
|
||||
if (isVisible) {
|
||||
const tabItem = tabItems.find((item) => item.text === tab);
|
||||
if (tabItem) setSelectedTab(tabItem);
|
||||
forceUpdate();
|
||||
} else setTimeout(() => forceUpdate(), 200);
|
||||
};
|
||||
navigation.on(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
|
||||
return () => {
|
||||
mounted = false;
|
||||
navigation.removeListener(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!navigation.isRoomSettings) return null;
|
||||
|
||||
return (
|
||||
<div className="room-settings">
|
||||
<ScrollView autoHide>
|
||||
<PopupWindow
|
||||
isOpen={isOpen}
|
||||
className="room-settings"
|
||||
title={
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
{isOpen && room.name}
|
||||
<span style={{ color: 'var(--tc-surface-low)' }}> — room settings</span>
|
||||
</Text>
|
||||
}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||
onRequestClose={requestClose}
|
||||
>
|
||||
{isOpen && (
|
||||
<div className="room-settings__content">
|
||||
<Header>
|
||||
<button
|
||||
className="room-settings__header-btn"
|
||||
onClick={() => toggleRoomSettings()}
|
||||
type="button"
|
||||
onMouseUp={(e) => blurOnBubbling(e, '.room-settings__header-btn')}
|
||||
>
|
||||
<TitleWrapper>
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
{`${room.name}`}
|
||||
<span style={{ color: 'var(--tc-surface-low)' }}> — room settings</span>
|
||||
</Text>
|
||||
</TitleWrapper>
|
||||
<RawIcon size="small" src={ChevronTopIC} />
|
||||
</button>
|
||||
</Header>
|
||||
<RoomProfile roomId={roomId} />
|
||||
<Tabs
|
||||
items={tabItems}
|
||||
|
|
@ -202,21 +185,16 @@ function RoomSettings({ roomId }) {
|
|||
/>
|
||||
<div className="room-settings__cards-wrapper">
|
||||
{selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
|
||||
{selectedTab.text === tabText.SEARCH && <RoomSearch roomId={roomId} />}
|
||||
{selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
|
||||
{selectedTab.text === tabText.EMOJIS && <RoomEmojis roomId={roomId} />}
|
||||
{selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
|
||||
{selectedTab.text === tabText.SECURITY && <SecuritySettings roomId={roomId} />}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
)}
|
||||
</PopupWindow>
|
||||
);
|
||||
}
|
||||
|
||||
RoomSettings.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default RoomSettings;
|
||||
export { tabText };
|
||||
|
|
|
|||
|
|
@ -2,59 +2,18 @@
|
|||
@use '../../partials/flex';
|
||||
|
||||
.room-settings {
|
||||
height: 100%;
|
||||
& .scrollbar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
& .header {
|
||||
padding: 0 var(--sp-extra-tight);
|
||||
}
|
||||
|
||||
&__header-btn {
|
||||
min-width: 0;
|
||||
@extend .cp-fx__row--s-c;
|
||||
@include dir.side(margin, 0, auto);
|
||||
padding: var(--sp-ultra-tight) var(--sp-extra-tight);
|
||||
border-radius: calc(var(--bo-radius) / 2);
|
||||
cursor: pointer;
|
||||
|
||||
@media (hover:hover) {
|
||||
&:hover {
|
||||
background-color: var(--bg-surface-hover);
|
||||
box-shadow: var(--bs-surface-outline);
|
||||
}
|
||||
}
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: var(--bg-surface-active);
|
||||
box-shadow: var(--bs-surface-outline);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding-bottom: calc(2 * var(--sp-extra-loose));
|
||||
|
||||
& .room-profile {
|
||||
margin: var(--sp-extra-loose);
|
||||
}
|
||||
}
|
||||
|
||||
& .tabs {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
width: 100%;
|
||||
& .pw {
|
||||
background-color: var(--bg-surface-low);
|
||||
box-shadow: 0 -4px 0 var(--bg-surface-low),
|
||||
inset 0 -1px 0 var(--bg-surface-border);
|
||||
|
||||
&__content {
|
||||
padding: 0 var(--sp-normal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
& .room-profile {
|
||||
padding: var(--sp-loose) var(--sp-extra-loose);
|
||||
}
|
||||
|
||||
& .tabs__content {
|
||||
padding: 0 var(--sp-normal);
|
||||
}
|
||||
|
||||
&__cards-wrapper {
|
||||
padding: 0 var(--sp-normal);
|
||||
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
||||
|
|
@ -75,7 +34,7 @@
|
|||
|
||||
.room-settings .room-permissions__card,
|
||||
.room-settings .room-search__form,
|
||||
.room-settings .room-search__result-item ,
|
||||
.room-settings .room-search__result-item,
|
||||
.room-settings .room-members {
|
||||
@extend .room-settings__card;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
|
||||
import { DefaultReset, config } from 'folds';
|
||||
|
||||
export const TimelineFloat = recipe({
|
||||
base: [
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
minWidth: 'max-content',
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
position: {
|
||||
Top: {
|
||||
top: config.space.S400,
|
||||
},
|
||||
Bottom: {
|
||||
bottom: config.space.S400,
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
position: 'Top',
|
||||
},
|
||||
});
|
||||
|
||||
export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +0,0 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
|
||||
export const RoomTombstone = style({
|
||||
padding: config.space.S200,
|
||||
paddingLeft: config.space.S400,
|
||||
});
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { Box, Button, Spinner, Text, color } from 'folds';
|
||||
|
||||
import { selectRoom } from '../../../client/action/navigation';
|
||||
|
||||
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';
|
||||
|
||||
type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string };
|
||||
export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
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) selectRoom(replacementRoom.roomId);
|
||||
if (joinState.status === AsyncStatus.Success) selectRoom(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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomView.scss';
|
||||
import { Text, config } from 'folds';
|
||||
import { EventType } from 'matrix-js-sdk';
|
||||
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
|
||||
import RoomViewHeader from './RoomViewHeader';
|
||||
import { RoomInput } from './RoomInput';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { RoomTombstone } from './RoomTombstone';
|
||||
import { usePowerLevelsAPI } from '../../hooks/usePowerLevels';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
||||
import { RoomTimeline } from './RoomTimeline';
|
||||
import { RoomViewTyping } from './RoomViewTyping';
|
||||
import { RoomViewFollowing } from './RoomViewFollowing';
|
||||
import { useEditor } from '../../components/editor';
|
||||
|
||||
function RoomView({ room, eventId }) {
|
||||
const roomInputRef = useRef(null);
|
||||
const roomViewRef = useRef(null);
|
||||
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const { roomId } = room;
|
||||
const editor = useEditor();
|
||||
|
||||
const mx = useMatrixClient();
|
||||
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
|
||||
const { getPowerLevel, canSendEvent } = usePowerLevelsAPI();
|
||||
const myUserId = mx.getUserId();
|
||||
const canMessage = myUserId
|
||||
? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId))
|
||||
: false;
|
||||
|
||||
useEffect(() => {
|
||||
const settingsToggle = (isVisible) => {
|
||||
const roomView = roomViewRef.current;
|
||||
roomView.classList.toggle('room-view--dropped');
|
||||
|
||||
const roomViewContent = roomView.children[1];
|
||||
if (isVisible) {
|
||||
setTimeout(() => {
|
||||
if (!navigation.isRoomSettings) return;
|
||||
roomViewContent.style.visibility = 'hidden';
|
||||
}, 200);
|
||||
} else roomViewContent.style.visibility = 'visible';
|
||||
};
|
||||
navigation.on(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.ROOM_SETTINGS_TOGGLED, settingsToggle);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="room-view" ref={roomViewRef}>
|
||||
<RoomViewHeader roomId={roomId} />
|
||||
<div className="room-view__content-wrapper">
|
||||
<div className="room-view__scrollable">
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
room={room}
|
||||
eventId={eventId}
|
||||
roomInputRef={roomInputRef}
|
||||
editor={editor}
|
||||
/>
|
||||
<RoomViewTyping room={room} />
|
||||
</div>
|
||||
<div className="room-view__sticky">
|
||||
<div className="room-view__editor">
|
||||
{tombstoneEvent ? (
|
||||
<RoomTombstone
|
||||
roomId={roomId}
|
||||
body={tombstoneEvent.getContent().body}
|
||||
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{canMessage && (
|
||||
<RoomInput
|
||||
room={room}
|
||||
editor={editor}
|
||||
roomId={roomId}
|
||||
roomViewRef={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} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
RoomView.defaultProps = {
|
||||
eventId: null,
|
||||
};
|
||||
RoomView.propTypes = {
|
||||
room: PropTypes.shape({}).isRequired,
|
||||
eventId: PropTypes.string,
|
||||
};
|
||||
|
||||
export default RoomView;
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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,
|
||||
});
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } 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';
|
||||
|
||||
export type RoomViewTypingProps = {
|
||||
room: Room;
|
||||
};
|
||||
export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
||||
({ className, room, ...props }, ref) => {
|
||||
const setTypingMembers = useSetAtom(roomIdToTypingMembersAtom);
|
||||
const mx = useMatrixClient();
|
||||
const typingMembers = useAtomValue(
|
||||
useMemo(() => selectRoomTypingMembersAtom(room.roomId, roomIdToTypingMembersAtom), [room])
|
||||
);
|
||||
|
||||
const typingNames = typingMembers
|
||||
.filter((member) => member.userId !== mx.getUserId())
|
||||
.map((member) => getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.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((member) =>
|
||||
setTypingMembers({
|
||||
type: 'DELETE',
|
||||
roomId: room.roomId,
|
||||
member,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
/* eslint-disable jsx-a11y/media-has-caption */
|
||||
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, as, toRem } from 'folds';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { Range } from 'react-range';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { getFileSrcUrl } from './util';
|
||||
import { IAudioInfo } from '../../../../types/matrix/common';
|
||||
import { MediaControl } from '../../../components/media';
|
||||
import {
|
||||
PlayTimeCallback,
|
||||
useMediaLoading,
|
||||
useMediaPlay,
|
||||
useMediaPlayTimeCallback,
|
||||
useMediaSeek,
|
||||
useMediaVolume,
|
||||
} from '../../../hooks/media';
|
||||
import { useThrottle } from '../../../hooks/useThrottle';
|
||||
import { secondsToMinutesAndSeconds } from '../../../utils/common';
|
||||
|
||||
const PLAY_TIME_THROTTLE_OPS = {
|
||||
wait: 500,
|
||||
immediate: true,
|
||||
};
|
||||
|
||||
export type AudioContentProps = {
|
||||
mimeType: string;
|
||||
url: string;
|
||||
info: IAudioInfo;
|
||||
encInfo?: EncryptedAttachmentInfo;
|
||||
};
|
||||
export const AudioContent = as<'div', AudioContentProps>(
|
||||
({ mimeType, url, info, encInfo, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(
|
||||
() => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
|
||||
[mx, url, mimeType, encInfo]
|
||||
)
|
||||
);
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
// duration in seconds. (NOTE: info.duration is in milliseconds)
|
||||
const infoDuration = info.duration ?? 0;
|
||||
const [duration, setDuration] = useState((infoDuration >= 0 ? infoDuration : 0) / 1000);
|
||||
|
||||
const getAudioRef = useCallback(() => audioRef.current, []);
|
||||
const { loading } = useMediaLoading(getAudioRef);
|
||||
const { playing, setPlaying } = useMediaPlay(getAudioRef);
|
||||
const { seek } = useMediaSeek(getAudioRef);
|
||||
const { volume, mute, setMute, setVolume } = useMediaVolume(getAudioRef);
|
||||
const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => {
|
||||
setDuration(d);
|
||||
setCurrentTime(ct);
|
||||
}, []);
|
||||
useMediaPlayTimeCallback(
|
||||
getAudioRef,
|
||||
useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS)
|
||||
);
|
||||
|
||||
const handlePlay = () => {
|
||||
if (srcState.status === AsyncStatus.Success) {
|
||||
setPlaying(!playing);
|
||||
} else if (srcState.status !== AsyncStatus.Loading) {
|
||||
loadSrc();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MediaControl
|
||||
after={
|
||||
<Range
|
||||
step={1}
|
||||
min={0}
|
||||
max={duration || 1}
|
||||
values={[currentTime]}
|
||||
onChange={(values) => seek(values[0])}
|
||||
renderTrack={(params) => (
|
||||
<div {...params.props}>
|
||||
{params.children}
|
||||
<ProgressBar
|
||||
as="div"
|
||||
variant="Secondary"
|
||||
size="300"
|
||||
min={0}
|
||||
max={duration}
|
||||
value={currentTime}
|
||||
radii="300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
renderThumb={(params) => (
|
||||
<Badge
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Solid"
|
||||
radii="Pill"
|
||||
outlined
|
||||
{...params.props}
|
||||
style={{
|
||||
...params.props.style,
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
leftControl={
|
||||
<>
|
||||
<Chip
|
||||
onClick={handlePlay}
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
disabled={srcState.status === AsyncStatus.Loading}
|
||||
before={
|
||||
srcState.status === AsyncStatus.Loading || loading ? (
|
||||
<Spinner variant="Secondary" size="50" />
|
||||
) : (
|
||||
<Icon src={playing ? Icons.Pause : Icons.Play} size="50" filled={playing} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="B300">{playing ? 'Pause' : 'Play'}</Text>
|
||||
</Chip>
|
||||
|
||||
<Text size="T200">{`${secondsToMinutesAndSeconds(
|
||||
currentTime
|
||||
)} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
|
||||
</>
|
||||
}
|
||||
rightControl={
|
||||
<>
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="Pill"
|
||||
onClick={() => setMute(!mute)}
|
||||
aria-pressed={mute}
|
||||
>
|
||||
<Icon src={mute ? Icons.VolumeMute : Icons.VolumeHigh} size="50" />
|
||||
</IconButton>
|
||||
<Range
|
||||
step={0.1}
|
||||
min={0}
|
||||
max={1}
|
||||
values={[volume]}
|
||||
onChange={(values) => setVolume(values[0])}
|
||||
renderTrack={(params) => (
|
||||
<div {...params.props}>
|
||||
{params.children}
|
||||
<ProgressBar
|
||||
style={{ width: toRem(48) }}
|
||||
variant="Secondary"
|
||||
size="300"
|
||||
min={0}
|
||||
max={1}
|
||||
value={volume}
|
||||
radii="300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
renderThumb={(params) => (
|
||||
<Badge
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Solid"
|
||||
radii="Pill"
|
||||
outlined
|
||||
{...params.props}
|
||||
style={{
|
||||
...params.props.style,
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<audio controls={false} autoPlay ref={audioRef}>
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<source src={srcState.data} type={mimeType} />
|
||||
)}
|
||||
</audio>
|
||||
</MediaControl>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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()}</>;
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { Box, Icon, IconSrc } from 'folds';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { CompactLayout, ModernLayout } from '../../../components/message';
|
||||
|
||||
export type EventContentProps = {
|
||||
messageLayout: number;
|
||||
time: ReactNode;
|
||||
iconSrc: IconSrc;
|
||||
content: ReactNode;
|
||||
};
|
||||
export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
|
||||
const beforeJSX = (
|
||||
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
|
||||
{messageLayout === 1 && time}
|
||||
<Box
|
||||
grow={messageLayout === 1 ? undefined : 'Yes'}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Icon style={{ opacity: 0.6 }} size="50" src={iconSrc} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const msgContentJSX = (
|
||||
<Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200">
|
||||
{content}
|
||||
{messageLayout !== 1 && time}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return messageLayout === 1 ? (
|
||||
<CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
|
||||
) : (
|
||||
<ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Icon,
|
||||
Icons,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
as,
|
||||
} from 'folds';
|
||||
import FileSaver from 'file-saver';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { IFileInfo } from '../../../../types/matrix/common';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getFileSrcUrl, getSrcFile } from './util';
|
||||
import { bytesToSize } from '../../../utils/common';
|
||||
import { TextViewer } from '../../../components/text-viewer';
|
||||
import {
|
||||
READABLE_EXT_TO_MIME_TYPE,
|
||||
READABLE_TEXT_MIME_TYPES,
|
||||
getFileNameExt,
|
||||
mimeTypeToExt,
|
||||
} from '../../../utils/mimeTypes';
|
||||
import { PdfViewer } from '../../../components/Pdf-viewer';
|
||||
import * as css from './styles.css';
|
||||
|
||||
export type FileContentProps = {
|
||||
body: string;
|
||||
mimeType: string;
|
||||
url: string;
|
||||
info: IFileInfo;
|
||||
encInfo?: EncryptedAttachmentInfo;
|
||||
};
|
||||
|
||||
const renderErrorButton = (retry: () => void, text: string) => (
|
||||
<TooltipProvider
|
||||
tooltip={
|
||||
<Tooltip variant="Critical">
|
||||
<Text>Failed to load file!</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
position="Top"
|
||||
align="Center"
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
size="400"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
onClick={retry}
|
||||
before={<Icon size="100" src={Icons.Warning} filled />}
|
||||
>
|
||||
<Text size="B400" truncate>
|
||||
{text}
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
function ReadTextFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, 'info'>) {
|
||||
const mx = useMatrixClient();
|
||||
const [textViewer, setTextViewer] = useState(false);
|
||||
|
||||
const loadSrc = useCallback(
|
||||
() => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
|
||||
[mx, url, mimeType, encInfo]
|
||||
);
|
||||
|
||||
const [textState, loadText] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const src = await loadSrc();
|
||||
const blob = await getSrcFile(src);
|
||||
const text = blob.text();
|
||||
setTextViewer(true);
|
||||
return text;
|
||||
}, [loadSrc])
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{textState.status === AsyncStatus.Success && (
|
||||
<Overlay open={textViewer} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setTextViewer(false),
|
||||
clickOutsideDeactivates: true,
|
||||
}}
|
||||
>
|
||||
<Modal
|
||||
className={css.ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
>
|
||||
<TextViewer
|
||||
name={body}
|
||||
text={textState.data}
|
||||
langName={
|
||||
READABLE_TEXT_MIME_TYPES.includes(mimeType)
|
||||
? mimeTypeToExt(mimeType)
|
||||
: mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType)
|
||||
}
|
||||
requestClose={() => setTextViewer(false)}
|
||||
/>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
{textState.status === AsyncStatus.Error ? (
|
||||
renderErrorButton(loadText, 'Open File')
|
||||
) : (
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
size="400"
|
||||
onClick={() =>
|
||||
textState.status === AsyncStatus.Success ? setTextViewer(true) : loadText()
|
||||
}
|
||||
disabled={textState.status === AsyncStatus.Loading}
|
||||
before={
|
||||
textState.status === AsyncStatus.Loading ? (
|
||||
<Spinner fill="Solid" size="100" variant="Secondary" />
|
||||
) : (
|
||||
<Icon size="100" src={Icons.ArrowRight} filled />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="B400" truncate>
|
||||
Open File
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadPdfFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, 'info'>) {
|
||||
const mx = useMatrixClient();
|
||||
const [pdfViewer, setPdfViewer] = useState(false);
|
||||
|
||||
const [pdfState, loadPdf] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const httpUrl = await getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo);
|
||||
setPdfViewer(true);
|
||||
return httpUrl;
|
||||
}, [mx, url, mimeType, encInfo])
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{pdfState.status === AsyncStatus.Success && (
|
||||
<Overlay open={pdfViewer} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setPdfViewer(false),
|
||||
clickOutsideDeactivates: true,
|
||||
}}
|
||||
>
|
||||
<Modal
|
||||
className={css.ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
>
|
||||
<PdfViewer
|
||||
name={body}
|
||||
src={pdfState.data}
|
||||
requestClose={() => setPdfViewer(false)}
|
||||
/>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
{pdfState.status === AsyncStatus.Error ? (
|
||||
renderErrorButton(loadPdf, 'Open PDF')
|
||||
) : (
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
size="400"
|
||||
onClick={() => (pdfState.status === AsyncStatus.Success ? setPdfViewer(true) : loadPdf())}
|
||||
disabled={pdfState.status === AsyncStatus.Loading}
|
||||
before={
|
||||
pdfState.status === AsyncStatus.Loading ? (
|
||||
<Spinner fill="Solid" size="100" variant="Secondary" />
|
||||
) : (
|
||||
<Icon size="100" src={Icons.ArrowRight} filled />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="B400" truncate>
|
||||
Open PDF
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadFile({ body, mimeType, url, info, encInfo }: FileContentProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [downloadState, download] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const httpUrl = await getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo);
|
||||
FileSaver.saveAs(httpUrl, body);
|
||||
return httpUrl;
|
||||
}, [mx, url, mimeType, encInfo, body])
|
||||
);
|
||||
|
||||
return downloadState.status === AsyncStatus.Error ? (
|
||||
renderErrorButton(download, `Retry Download (${bytesToSize(info.size ?? 0)})`)
|
||||
) : (
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="400"
|
||||
onClick={() =>
|
||||
downloadState.status === AsyncStatus.Success
|
||||
? FileSaver.saveAs(downloadState.data, body)
|
||||
: download()
|
||||
}
|
||||
disabled={downloadState.status === AsyncStatus.Loading}
|
||||
before={
|
||||
downloadState.status === AsyncStatus.Loading ? (
|
||||
<Spinner fill="Soft" size="100" variant="Secondary" />
|
||||
) : (
|
||||
<Icon size="100" src={Icons.Download} filled />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="B400" truncate>{`Download (${bytesToSize(info.size ?? 0)})`}</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export const FileContent = as<'div', FileContentProps>(
|
||||
({ body, mimeType, url, info, encInfo, ...props }, ref) => (
|
||||
<Box direction="Column" gap="300" {...props} ref={ref}>
|
||||
{(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
|
||||
READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) && (
|
||||
<ReadTextFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
|
||||
)}
|
||||
{mimeType === 'application/pdf' && (
|
||||
<ReadPdfFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
|
||||
)}
|
||||
<DownloadFile body={body} mimeType={mimeType} url={url} info={info} encInfo={encInfo} />
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { Badge, Box, Text, as, toRem } from 'folds';
|
||||
import React from 'react';
|
||||
import { mimeTypeToExt } from '../../../utils/mimeTypes';
|
||||
|
||||
const badgeStyles = { maxWidth: toRem(100) };
|
||||
|
||||
export type FileHeaderProps = {
|
||||
body: string;
|
||||
mimeType: string;
|
||||
};
|
||||
export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, ...props }, ref) => (
|
||||
<Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}>
|
||||
<Badge style={badgeStyles} variant="Secondary" radii="Pill">
|
||||
<Text size="O400" truncate>
|
||||
{mimeTypeToExt(mimeType)}
|
||||
</Text>
|
||||
</Badge>
|
||||
<Text size="T300" truncate>
|
||||
{body}
|
||||
</Text>
|
||||
</Box>
|
||||
));
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Icon,
|
||||
Icons,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
as,
|
||||
} from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { BlurhashCanvas } from 'react-blurhash';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getFileSrcUrl } from './util';
|
||||
import { Image } from '../../../components/media';
|
||||
import * as css from './styles.css';
|
||||
import { bytesToSize } from '../../../utils/common';
|
||||
import { ImageViewer } from '../../../components/image-viewer';
|
||||
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
||||
|
||||
export type ImageContentProps = {
|
||||
body: string;
|
||||
mimeType?: string;
|
||||
url: string;
|
||||
info?: IImageInfo;
|
||||
encInfo?: EncryptedAttachmentInfo;
|
||||
autoPlay?: boolean;
|
||||
};
|
||||
export const ImageContent = as<'div', ImageContentProps>(
|
||||
({ className, body, mimeType, url, info, encInfo, autoPlay, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const blurHash = info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
|
||||
|
||||
const [load, setLoad] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [viewer, setViewer] = useState(false);
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(
|
||||
() => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType || FALLBACK_MIMETYPE, encInfo),
|
||||
[mx, url, mimeType, encInfo]
|
||||
)
|
||||
);
|
||||
|
||||
const handleLoad = () => {
|
||||
setLoad(true);
|
||||
};
|
||||
const handleError = () => {
|
||||
setLoad(false);
|
||||
setError(true);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setError(false);
|
||||
loadSrc();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (autoPlay) loadSrc();
|
||||
}, [autoPlay, loadSrc]);
|
||||
|
||||
return (
|
||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setViewer(false),
|
||||
clickOutsideDeactivates: true,
|
||||
}}
|
||||
>
|
||||
<Modal
|
||||
className={css.ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
>
|
||||
<ImageViewer
|
||||
src={srcState.data}
|
||||
alt={body}
|
||||
requestClose={() => setViewer(false)}
|
||||
/>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
{typeof blurHash === 'string' && !load && (
|
||||
<BlurhashCanvas
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
width={32}
|
||||
height={32}
|
||||
hash={blurHash}
|
||||
punch={1}
|
||||
/>
|
||||
)}
|
||||
{!autoPlay && srcState.status === AsyncStatus.Idle && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
size="300"
|
||||
onClick={loadSrc}
|
||||
before={<Icon size="Inherit" src={Icons.Photo} filled />}
|
||||
>
|
||||
<Text size="B300">View</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Box className={css.AbsoluteContainer}>
|
||||
<Image
|
||||
alt={body}
|
||||
title={body}
|
||||
src={srcState.data}
|
||||
loading="lazy"
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
onClick={() => setViewer(true)}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
||||
!load && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<Spinner variant="Secondary" />
|
||||
</Box>
|
||||
)}
|
||||
{(error || srcState.status === AsyncStatus.Error) && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<TooltipProvider
|
||||
tooltip={
|
||||
<Tooltip variant="Critical">
|
||||
<Text>Failed to load image!</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
position="Top"
|
||||
align="Center"
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
onClick={handleRetry}
|
||||
before={<Icon size="Inherit" src={Icons.Warning} filled />}
|
||||
>
|
||||
<Text size="B300">Retry</Text>
|
||||
</Button>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</Box>
|
||||
)}
|
||||
{!load && typeof info?.size === 'number' && (
|
||||
<Box className={css.AbsoluteFooter} justifyContent="End" alignContent="Center" gap="200">
|
||||
<Badge variant="Secondary" fill="Soft">
|
||||
<Text size="L400">{bytesToSize(info.size)}</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,310 +0,0 @@
|
|||
import React, { KeyboardEventHandler, useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Chip, Icon, IconButton, Icons, Line, PopOut, 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={false}>
|
||||
{(emojiBoard: boolean, setEmojiBoard) => (
|
||||
<PopOut
|
||||
alignOffset={-8}
|
||||
position="Top"
|
||||
align="End"
|
||||
open={!!emojiBoard}
|
||||
content={
|
||||
<EmojiBoard
|
||||
imagePackRooms={imagePackRooms ?? []}
|
||||
returnFocusOnDeactivate={false}
|
||||
onEmojiSelect={handleEmoticonSelect}
|
||||
onCustomEmojiSelect={handleEmoticonSelect}
|
||||
requestClose={() => {
|
||||
setEmojiBoard(false);
|
||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
ref={anchorRef}
|
||||
aria-pressed={emojiBoard}
|
||||
onClick={() => setEmojiBoard(true)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="400" src={Icons.Smile} filled={emojiBoard} />
|
||||
</IconButton>
|
||||
)}
|
||||
</PopOut>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
</Box>
|
||||
</Box>
|
||||
{toolbar && (
|
||||
<div>
|
||||
<Line variant="SurfaceVariant" size="300" />
|
||||
<Toolbar />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import React from 'react';
|
||||
import { as, toRem } from 'folds';
|
||||
import { MatrixEvent } from 'matrix-js-sdk';
|
||||
import {
|
||||
AttachmentBox,
|
||||
MessageBrokenContent,
|
||||
MessageDeletedContent,
|
||||
} from '../../../components/message';
|
||||
import { ImageContent } from './ImageContent';
|
||||
import { scaleYDimension } from '../../../utils/common';
|
||||
import { IImageContent } from '../../../../types/matrix/common';
|
||||
|
||||
type StickerContentProps = {
|
||||
mEvent: MatrixEvent;
|
||||
autoPlay: boolean;
|
||||
};
|
||||
export const StickerContent = as<'div', StickerContentProps>(
|
||||
({ mEvent, autoPlay, ...props }, ref) => {
|
||||
if (mEvent.isRedacted()) return <MessageDeletedContent />;
|
||||
const content = mEvent.getContent<IImageContent>();
|
||||
const imgInfo = content?.info;
|
||||
const mxcUrl = content.file?.url ?? content.url;
|
||||
if (typeof mxcUrl !== 'string') {
|
||||
return <MessageBrokenContent />;
|
||||
}
|
||||
const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
|
||||
|
||||
return (
|
||||
<AttachmentBox
|
||||
style={{
|
||||
height: toRem(height < 48 ? 48 : height),
|
||||
width: toRem(152),
|
||||
}}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<ImageContent
|
||||
autoPlay={autoPlay}
|
||||
body={content.body || 'Image'}
|
||||
info={imgInfo}
|
||||
mimeType={imgInfo?.mimetype}
|
||||
url={mxcUrl}
|
||||
encInfo={content.file}
|
||||
/>
|
||||
</AttachmentBox>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { IPreviewUrlResponse } from 'matrix-js-sdk';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import {
|
||||
UrlPreview,
|
||||
UrlPreviewContent,
|
||||
UrlPreviewDescription,
|
||||
UrlPreviewImg,
|
||||
} from '../../../components/url-preview';
|
||||
import {
|
||||
getIntersectionObserverEntry,
|
||||
useIntersectionObserver,
|
||||
} from '../../../hooks/useIntersectionObserver';
|
||||
import * as css from './styles.css';
|
||||
|
||||
const linkStyles = { color: color.Success.Main };
|
||||
|
||||
export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
({ url, ts, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const [previewStatus, loadPreview] = useAsyncCallback(
|
||||
useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadPreview();
|
||||
}, [loadPreview]);
|
||||
|
||||
if (previewStatus.status === AsyncStatus.Error) return null;
|
||||
|
||||
const renderContent = (prev: IPreviewUrlResponse) => {
|
||||
const imgUrl = mx.mxcUrlToHttp(prev['og:image'] || '', 256, 256, 'scale', false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{imgUrl && <UrlPreviewImg src={imgUrl} alt={prev['og:title']} title={prev['og:title']} />}
|
||||
<UrlPreviewContent>
|
||||
<Text
|
||||
style={linkStyles}
|
||||
truncate
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="no-referrer"
|
||||
size="T200"
|
||||
priority="300"
|
||||
>
|
||||
{typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `}
|
||||
{decodeURIComponent(url)}
|
||||
</Text>
|
||||
<Text truncate priority="400">
|
||||
<b>{prev['og:title']}</b>
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
<UrlPreviewDescription>{prev['og:description']}</UrlPreviewDescription>
|
||||
</Text>
|
||||
</UrlPreviewContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<UrlPreview {...props} ref={ref}>
|
||||
{previewStatus.status === AsyncStatus.Success ? (
|
||||
renderContent(previewStatus.data)
|
||||
) : (
|
||||
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
</Box>
|
||||
)}
|
||||
</UrlPreview>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const backAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const frontAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const [backVisible, setBackVisible] = useState(true);
|
||||
const [frontVisible, setFrontVisible] = useState(true);
|
||||
|
||||
const intersectionObserver = useIntersectionObserver(
|
||||
useCallback((entries) => {
|
||||
const backAnchor = backAnchorRef.current;
|
||||
const frontAnchor = frontAnchorRef.current;
|
||||
const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
|
||||
const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
|
||||
if (backEntry) {
|
||||
setBackVisible(backEntry.isIntersecting);
|
||||
}
|
||||
if (frontEntry) {
|
||||
setFrontVisible(frontEntry.isIntersecting);
|
||||
}
|
||||
}, []),
|
||||
useCallback(
|
||||
() => ({
|
||||
root: scrollRef.current,
|
||||
rootMargin: '10px',
|
||||
}),
|
||||
[]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const backAnchor = backAnchorRef.current;
|
||||
const frontAnchor = frontAnchorRef.current;
|
||||
if (backAnchor) intersectionObserver?.observe(backAnchor);
|
||||
if (frontAnchor) intersectionObserver?.observe(frontAnchor);
|
||||
return () => {
|
||||
if (backAnchor) intersectionObserver?.unobserve(backAnchor);
|
||||
if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
|
||||
};
|
||||
}, [intersectionObserver]);
|
||||
|
||||
const handleScrollBack = () => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) return;
|
||||
const { offsetWidth, scrollLeft } = scroll;
|
||||
scroll.scrollTo({
|
||||
left: scrollLeft - offsetWidth / 1.3,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
const handleScrollFront = () => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) return;
|
||||
const { offsetWidth, scrollLeft } = scroll;
|
||||
scroll.scrollTo({
|
||||
left: scrollLeft + offsetWidth / 1.3,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
{...props}
|
||||
ref={ref}
|
||||
style={{ marginTop: config.space.S200, position: 'relative' }}
|
||||
>
|
||||
<Scroll ref={scrollRef} direction="Horizontal" size="0" visibility="Hover" hideTrack>
|
||||
<Box shrink="No" alignItems="Center">
|
||||
<div ref={backAnchorRef} />
|
||||
{!backVisible && (
|
||||
<>
|
||||
<div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
|
||||
<IconButton
|
||||
className={css.UrlPreviewHolderBtn({ position: 'Left' })}
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
size="300"
|
||||
outlined
|
||||
onClick={handleScrollBack}
|
||||
>
|
||||
<Icon size="300" src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<Box alignItems="Inherit" gap="200">
|
||||
{children}
|
||||
|
||||
{!frontVisible && (
|
||||
<>
|
||||
<div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
|
||||
<IconButton
|
||||
className={css.UrlPreviewHolderBtn({ position: 'Right' })}
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
size="300"
|
||||
outlined
|
||||
onClick={handleScrollFront}
|
||||
>
|
||||
<Icon size="300" src={Icons.ArrowRight} />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<div ref={frontAnchorRef} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Icon,
|
||||
Icons,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
as,
|
||||
} from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { BlurhashCanvas } from 'react-blurhash';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import {
|
||||
IThumbnailContent,
|
||||
IVideoInfo,
|
||||
MATRIX_BLUR_HASH_PROPERTY_NAME,
|
||||
} from '../../../../types/matrix/common';
|
||||
import * as css from './styles.css';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { getFileSrcUrl } from './util';
|
||||
import { Image, Video } from '../../../components/media';
|
||||
import { bytesToSize } from '../../../../util/common';
|
||||
import { millisecondsToMinutesAndSeconds } from '../../../utils/common';
|
||||
|
||||
export type VideoContentProps = {
|
||||
body: string;
|
||||
mimeType: string;
|
||||
url: string;
|
||||
info: IVideoInfo & IThumbnailContent;
|
||||
encInfo?: EncryptedAttachmentInfo;
|
||||
autoPlay?: boolean;
|
||||
loadThumbnail?: boolean;
|
||||
};
|
||||
export const VideoContent = as<'div', VideoContentProps>(
|
||||
({ className, body, mimeType, url, info, encInfo, autoPlay, loadThumbnail, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const blurHash = info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
|
||||
|
||||
const [load, setLoad] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(
|
||||
() => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
|
||||
[mx, url, mimeType, encInfo]
|
||||
)
|
||||
);
|
||||
const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
|
||||
useCallback(() => {
|
||||
const thumbInfo = info.thumbnail_info;
|
||||
const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
|
||||
if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
|
||||
throw new Error('Failed to load thumbnail');
|
||||
}
|
||||
return getFileSrcUrl(
|
||||
mx.mxcUrlToHttp(thumbMxcUrl) ?? '',
|
||||
thumbInfo.mimetype,
|
||||
info.thumbnail_file
|
||||
);
|
||||
}, [mx, info])
|
||||
);
|
||||
|
||||
const handleLoad = () => {
|
||||
setLoad(true);
|
||||
};
|
||||
const handleError = () => {
|
||||
setLoad(false);
|
||||
setError(true);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setError(false);
|
||||
loadSrc();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (autoPlay) loadSrc();
|
||||
}, [autoPlay, loadSrc]);
|
||||
useEffect(() => {
|
||||
if (loadThumbnail) loadThumbSrc();
|
||||
}, [loadThumbnail, loadThumbSrc]);
|
||||
|
||||
return (
|
||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||
{typeof blurHash === 'string' && !load && (
|
||||
<BlurhashCanvas
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
width={32}
|
||||
height={32}
|
||||
hash={blurHash}
|
||||
punch={1}
|
||||
/>
|
||||
)}
|
||||
{thumbSrcState.status === AsyncStatus.Success && !load && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<Image alt={body} title={body} src={thumbSrcState.data} loading="lazy" />
|
||||
</Box>
|
||||
)}
|
||||
{!autoPlay && srcState.status === AsyncStatus.Idle && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
size="300"
|
||||
onClick={loadSrc}
|
||||
before={<Icon size="Inherit" src={Icons.Play} filled />}
|
||||
>
|
||||
<Text size="B300">Watch</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Box className={css.AbsoluteContainer}>
|
||||
<Video
|
||||
title={body}
|
||||
src={srcState.data}
|
||||
onLoadedMetadata={handleLoad}
|
||||
onError={handleError}
|
||||
autoPlay
|
||||
controls
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
|
||||
!load && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<Spinner variant="Secondary" />
|
||||
</Box>
|
||||
)}
|
||||
{(error || srcState.status === AsyncStatus.Error) && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<TooltipProvider
|
||||
tooltip={
|
||||
<Tooltip variant="Critical">
|
||||
<Text>Failed to load video!</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
position="Top"
|
||||
align="Center"
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
onClick={handleRetry}
|
||||
before={<Icon size="Inherit" src={Icons.Warning} filled />}
|
||||
>
|
||||
<Text size="B300">Retry</Text>
|
||||
</Button>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</Box>
|
||||
)}
|
||||
{!load && typeof info.size === 'number' && (
|
||||
<Box
|
||||
className={css.AbsoluteFooter}
|
||||
justifyContent="SpaceBetween"
|
||||
alignContent="Center"
|
||||
gap="200"
|
||||
>
|
||||
<Badge variant="Secondary" fill="Soft">
|
||||
<Text size="L400">{millisecondsToMinutesAndSeconds(info.duration ?? 0)}</Text>
|
||||
</Badge>
|
||||
<Badge variant="Secondary" fill="Soft">
|
||||
<Text size="L400">{bytesToSize(info.size)}</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import React from 'react';
|
||||
import { MatrixEvent } from 'matrix-js-sdk';
|
||||
import { IFileContent } from '../../../../types/matrix/common';
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentBox,
|
||||
AttachmentContent,
|
||||
AttachmentHeader,
|
||||
} from '../../../components/message';
|
||||
import { FileHeader } from './FileHeader';
|
||||
import { FileContent } from './FileContent';
|
||||
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
||||
|
||||
export const fileRenderer = (mEventId: string, mEvent: MatrixEvent) => {
|
||||
const content = mEvent.getContent<IFileContent>();
|
||||
|
||||
const fileInfo = content?.info;
|
||||
const mxcUrl = content.file?.url ?? content.url;
|
||||
|
||||
if (typeof mxcUrl !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Attachment>
|
||||
<AttachmentHeader>
|
||||
<FileHeader
|
||||
body={content.body ?? 'Unnamed File'}
|
||||
mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
|
||||
/>
|
||||
</AttachmentHeader>
|
||||
<AttachmentBox>
|
||||
<AttachmentContent>
|
||||
<FileContent
|
||||
body={content.body ?? 'File'}
|
||||
info={fileInfo ?? {}}
|
||||
mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
|
||||
url={mxcUrl}
|
||||
encInfo={content.file}
|
||||
/>
|
||||
</AttachmentContent>
|
||||
</AttachmentBox>
|
||||
</Attachment>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
export * from './ImageContent';
|
||||
export * from './VideoContent';
|
||||
export * from './FileHeader';
|
||||
export * from './fileRenderer';
|
||||
export * from './AudioContent';
|
||||
export * from './Reactions';
|
||||
export * from './EventContent';
|
||||
export * from './Message';
|
||||
export * from './EncryptedContent';
|
||||
export * from './StickerContent';
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { recipe } from '@vanilla-extract/recipes';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
export const RelativeBase = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const AbsoluteContainer = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const AbsoluteFooter = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
bottom: config.space.S100,
|
||||
left: config.space.S100,
|
||||
right: config.space.S100,
|
||||
},
|
||||
]);
|
||||
|
||||
export const ModalWide = style({
|
||||
minWidth: '85vw',
|
||||
minHeight: '90vh',
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
export const UrlPreviewHolderGradient = recipe({
|
||||
base: [
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
width: toRem(10),
|
||||
zIndex: 1,
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
position: {
|
||||
Left: {
|
||||
left: 0,
|
||||
background: `linear-gradient(to right,${color.Surface.Container} , rgba(116,116,116,0))`,
|
||||
},
|
||||
Right: {
|
||||
right: 0,
|
||||
background: `linear-gradient(to left,${color.Surface.Container} , rgba(116,116,116,0))`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
export const UrlPreviewHolderBtn = recipe({
|
||||
base: [
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
position: {
|
||||
Left: {
|
||||
left: 0,
|
||||
transform: 'translateX(-25%)',
|
||||
},
|
||||
Right: {
|
||||
right: 0,
|
||||
transform: 'translateX(25%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { decryptFile } from '../../../utils/matrix';
|
||||
|
||||
export const getFileSrcUrl = async (
|
||||
httpUrl: string,
|
||||
mimeType: string,
|
||||
encInfo?: EncryptedAttachmentInfo
|
||||
): Promise<string> => {
|
||||
if (encInfo) {
|
||||
if (typeof httpUrl !== 'string') throw new Error('Malformed event');
|
||||
const encRes = await fetch(httpUrl, { method: 'GET' });
|
||||
const encData = await encRes.arrayBuffer();
|
||||
const decryptedBlob = await decryptFile(encData, mimeType, encInfo);
|
||||
return URL.createObjectURL(decryptedBlob);
|
||||
}
|
||||
return httpUrl;
|
||||
};
|
||||
|
||||
export const getSrcFile = async (src: string): Promise<Blob> => {
|
||||
const res = await fetch(src, { method: 'GET' });
|
||||
const blob = await res.blob();
|
||||
return blob;
|
||||
};
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
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/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;
|
||||
};
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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,
|
||||
});
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
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 colorMXID from '../../../../util/colorMXID';
|
||||
import { openProfileViewer } from '../../../../client/action/navigation';
|
||||
import { useRelations } from '../../../hooks/useRelations';
|
||||
import { Reaction } from '../../../components/message';
|
||||
import { getHexcodeForEmoji, getShortcodeFor } from '../../../plugins/emoji';
|
||||
|
||||
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">
|
||||
{avatarUrl ? (
|
||||
<AvatarImage src={avatarUrl} />
|
||||
) : (
|
||||
<AvatarFallback
|
||||
style={{
|
||||
background: colorMXID(senderId),
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Text size="H6">{name[0]}</Text>
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T400" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from './ReactionViewer';
|
||||
|
|
@ -5,7 +5,6 @@ import initMatrix from '../../../client/initMatrix';
|
|||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import AsyncSearch from '../../../util/AsyncSearch';
|
||||
import { selectRoom, selectTab } from '../../../client/action/navigation';
|
||||
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
|
||||
import { roomIdByActivity } from '../../../util/sort';
|
||||
|
||||
|
|
@ -19,6 +18,7 @@ import RoomSelector from '../../molecules/room-selector/RoomSelector';
|
|||
|
||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
|
||||
function useVisiblityToggle(setResult) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
|
@ -64,13 +64,13 @@ function mapRoomIds(roomIds) {
|
|||
if (room.isSpaceRoom()) type = 'space';
|
||||
else if (directs.has(roomId)) type = 'direct';
|
||||
|
||||
return ({
|
||||
return {
|
||||
type,
|
||||
name: room.name,
|
||||
parents,
|
||||
roomId,
|
||||
room,
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +80,7 @@ function Search() {
|
|||
const [isOpen, requestClose] = useVisiblityToggle(setResult);
|
||||
const searchRef = useRef(null);
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||
|
||||
const handleSearchResults = (chunk, term) => {
|
||||
setResult({
|
||||
|
|
@ -155,8 +156,8 @@ function Search() {
|
|||
};
|
||||
|
||||
const openItem = (roomId, type) => {
|
||||
if (type === 'space') selectTab(roomId);
|
||||
else selectRoom(roomId);
|
||||
if (type === 'space') navigateSpace(roomId);
|
||||
else navigateRoom(roomId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
|
|
@ -173,7 +174,8 @@ function Search() {
|
|||
let imageSrc = null;
|
||||
let iconSrc = null;
|
||||
if (item.type === 'direct') {
|
||||
imageSrc = item.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
|
||||
imageSrc =
|
||||
item.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
|
||||
} else {
|
||||
iconSrc = joinRuleToIconSrc(item.room.getJoinRule(), item.type === 'space');
|
||||
}
|
||||
|
|
@ -204,19 +206,21 @@ function Search() {
|
|||
size="small"
|
||||
>
|
||||
<div className="search-dialog">
|
||||
<form className="search-dialog__input" onSubmit={(e) => { e.preventDefault(); openFirstResult(); }}>
|
||||
<form
|
||||
className="search-dialog__input"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
openFirstResult();
|
||||
}}
|
||||
>
|
||||
<RawIcon src={SearchIC} size="small" />
|
||||
<Input
|
||||
onChange={handleOnChange}
|
||||
forwardRef={searchRef}
|
||||
placeholder="Search"
|
||||
/>
|
||||
<Input onChange={handleOnChange} forwardRef={searchRef} placeholder="Search" />
|
||||
<IconButton size="small" src={CrossIC} type="reset" onClick={handleCross} tabIndex={-1} />
|
||||
</form>
|
||||
<div className="search-dialog__content-wrapper">
|
||||
<ScrollView autoHide>
|
||||
<div className="search-dialog__content">
|
||||
{ Array.isArray(result?.chunk) && result.chunk.map(renderRoomSelector) }
|
||||
{Array.isArray(result?.chunk) && result.chunk.map(renderRoomSelector)}
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,12 +8,6 @@ import initMatrix from '../../../client/initMatrix';
|
|||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { leave } from '../../../client/action/room';
|
||||
import {
|
||||
createSpaceShortcut,
|
||||
deleteSpaceShortcut,
|
||||
categorizeSpace,
|
||||
unCategorizeSpace,
|
||||
} from '../../../client/action/accountData';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
|
|
@ -32,14 +26,9 @@ import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
|||
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
|
||||
import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
|
||||
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
||||
import PinIC from '../../../../public/res/ic/outlined/pin.svg';
|
||||
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
||||
import CategoryIC from '../../../../public/res/ic/outlined/category.svg';
|
||||
import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg';
|
||||
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
||||
|
||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
|
||||
const tabText = {
|
||||
GENERAL: 'General',
|
||||
|
|
@ -48,54 +37,36 @@ const tabText = {
|
|||
PERMISSIONS: 'Permissions',
|
||||
};
|
||||
|
||||
const tabItems = [{
|
||||
iconSrc: SettingsIC,
|
||||
text: tabText.GENERAL,
|
||||
disabled: false,
|
||||
}, {
|
||||
iconSrc: UserIC,
|
||||
text: tabText.MEMBERS,
|
||||
disabled: false,
|
||||
}, {
|
||||
iconSrc: EmojiIC,
|
||||
text: tabText.EMOJIS,
|
||||
disabled: false,
|
||||
}, {
|
||||
iconSrc: ShieldUserIC,
|
||||
text: tabText.PERMISSIONS,
|
||||
disabled: false,
|
||||
}];
|
||||
const tabItems = [
|
||||
{
|
||||
iconSrc: SettingsIC,
|
||||
text: tabText.GENERAL,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
iconSrc: UserIC,
|
||||
text: tabText.MEMBERS,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
iconSrc: EmojiIC,
|
||||
text: tabText.EMOJIS,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
iconSrc: ShieldUserIC,
|
||||
text: tabText.PERMISSIONS,
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
function GeneralSettings({ roomId }) {
|
||||
const isPinned = initMatrix.accountData.spaceShortcut.has(roomId);
|
||||
const isCategorized = initMatrix.accountData.categorizedSpaces.has(roomId);
|
||||
const roomName = initMatrix.matrixClient.getRoom(roomId)?.name;
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="room-settings__card">
|
||||
<MenuHeader>Options</MenuHeader>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (isCategorized) unCategorizeSpace(roomId);
|
||||
else categorizeSpace(roomId);
|
||||
forceUpdate();
|
||||
}}
|
||||
iconSrc={isCategorized ? CategoryFilledIC : CategoryIC}
|
||||
>
|
||||
{isCategorized ? 'Uncategorize subspaces' : 'Categorize subspaces'}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (isPinned) deleteSpaceShortcut(roomId);
|
||||
else createSpaceShortcut(roomId);
|
||||
forceUpdate();
|
||||
}}
|
||||
iconSrc={isPinned ? PinFilledIC : PinIC}
|
||||
>
|
||||
{isPinned ? 'Unpin from sidebar' : 'Pin to sidebar'}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
variant="danger"
|
||||
onClick={async () => {
|
||||
|
|
@ -103,7 +74,7 @@ function GeneralSettings({ roomId }) {
|
|||
'Leave space',
|
||||
`Are you sure that you want to leave "${roomName}" space?`,
|
||||
'Leave',
|
||||
'danger',
|
||||
'danger'
|
||||
);
|
||||
if (isConfirmed) leave(roomId);
|
||||
}}
|
||||
|
|
@ -165,12 +136,12 @@ function SpaceSettings() {
|
|||
<PopupWindow
|
||||
isOpen={isOpen}
|
||||
className="space-settings"
|
||||
title={(
|
||||
title={
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
{isOpen && twemojify(room.name)}
|
||||
<span style={{ color: 'var(--tc-surface-low)' }}> — space settings</span>
|
||||
</Text>
|
||||
)}
|
||||
}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||
onRequestClose={requestClose}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue