Refactor state & Custom editor (#1190)

* Fix eslint

* Enable ts strict mode

* install folds, jotai & immer

* Enable immer map/set

* change cross-signing alert anim to 30 iteration

* Add function to access matrix client

* Add new types

* Add disposable util

* Add room utils

* Add mDirect list atom

* Add invite list atom

* add room list atom

* add utils for jotai atoms

* Add room id to parents atom

* Add mute list atom

* Add room to unread atom

* Use hook to bind atoms with sdk

* Add settings atom

* Add settings hook

* Extract set settings hook

* Add Sidebar components

* WIP

* Add bind atoms hook

* Fix init muted room list atom

* add navigation atoms

* Add custom editor

* Fix hotkeys

* Update folds

* Add editor output function

* Add matrix client context

* Add tooltip to editor toolbar items

* WIP - Add editor to room input

* Refocus editor on toolbar item click

* Add Mentions - WIP

* update folds

* update mention focus outline

* rename emoji element type

* Add auto complete menu

* add autocomplete query functions

* add index file for editor

* fix bug in getPrevWord function

* Show room mention autocomplete

* Add async search function

* add use async search hook

* use async search in room mention autocomplete

* remove folds prefer font for now

* allow number array in async search

* reset search with empty query

* Autocomplete unknown room mention

* Autocomplete first room mention on tab

* fix roomAliasFromQueryText

* change mention color to primary

* add isAlive hook

* add getMxIdLocalPart to mx utils

* fix getRoomAvatarUrl size

* fix types

* add room members hook

* fix bug in room mention

* add user mention autocomplete

* Fix async search giving prev result after no match

* update folds

* add twemoji font

* add use state provider hook

* add prevent scroll with arrow key util

* add ts to custom-emoji and emoji files

* add types

* add hook for emoji group labels

* add hook for emoji group icons

* add emoji board with basic emoji

* add emojiboard in room input

* select multiple emoji with shift press

* display custom emoji in emojiboard

* Add emoji preview

* focus element on hover

* update folds

* position emojiboard properly

* convert recent-emoji.js to ts

* add use recent emoji hook

* add io.element.recent_emoji to account data evt

* Render recent emoji in emoji board

* show custom emoji from parent spaces

* show room emoji

* improve emoji sidebar

* update folds

* fix pack avatar and name fallback in emoji board

* add stickers to emoji board

* fix bug in emoji preview

* Add sticker icon in room input

* add debounce hook

* add search in emoji board

* Optimize emoji board

* fix emoji board sidebar divider

* sync emojiboard sidebar with scroll & update ui

* Add use throttle hook

* support custom emoji in editor

* remove duplicate emoji selection function

* fix emoji and mention spacing

* add emoticon autocomplete in editor

* fix string

* makes emoji size relative to font size in editor

* add option to render link element

* add spoiler in editor

* fix sticker in emoji board search using wrong type

* render custom placeholder

* update hotkey for block quote and block code

* add terminate search function in async search

* add getImageInfo to matrix utils

* send stickers

* add resize observer hook

* move emoji board component hooks in hooks dir

* prevent editor expand hides room timeline

* send typing notifications

* improve emoji style and performance

* fix imports

* add on paste param to editor

* add selectFile utils

* add file picker hook

* add file paste handler hook

* add file drop handler

* update folds

* Add file upload card

* add bytes to size util

* add blurHash util

* add await to js lib

* add browser-encrypt-attachment types

* add list atom

* convert mimetype file to ts

* add matrix types

* add matrix file util

* add file related dom utils

* add common utils

* add upload atom

* add room input draft atom

* add upload card renderer component

* add upload board component

* add support for file upload in editor

* send files with message / enter

* fix circular deps

* store editor toolbar state in local store

* move msg content util to separate file

* store msg draft on room switch

* fix following member not updating on msg sent

* add theme for folds component

* fix system default theme

* Add reply support in editor

* prevent initMatrix to init multiple time

* add state event hooks

* add async callback hook

* Show tombstone info for tombstone room

* fix room tombstone component border

* add power level hook

* Add room input placeholder component

* Show input placeholder for muted member
This commit is contained in:
Ajay Bura
2023-06-12 21:15:23 +10:00
committed by GitHub
parent 2055d7a07f
commit 0b06bed1db
128 changed files with 8799 additions and 409 deletions

View File

@@ -1,24 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import './DragDrop.scss';
import RawModal from '../../atoms/modal/RawModal';
import Text from '../../atoms/text/Text';
function DragDrop({ isOpen }) {
return (
<RawModal
className="drag-drop__modal"
overlayClassName="drag-drop__overlay"
isOpen={isOpen}
>
<Text variant="h2" weight="medium">Drop file to upload</Text>
</RawModal>
);
}
DragDrop.propTypes = {
isOpen: PropTypes.bool.isRequired,
};
export default DragDrop;

View File

@@ -1,12 +0,0 @@
.drag-drop__modal {
box-shadow: none;
text-align: center;
.text {
color: white;
}
}
.drag-drop__overlay {
background-color: var(--bg-overlay-low);
}

View File

@@ -7,10 +7,7 @@
width: var(--navigation-sidebar-width);
height: 100%;
background-color: var(--bg-surface-extra-low);
@include dir.side(border,
none,
1px solid var(--bg-surface-border),
);
@include dir.side(border, none, 1px solid var(--bg-surface-border));
&__scrollable,
&__sticky {
@@ -24,7 +21,7 @@
.scrollable-content {
&::after {
content: "";
content: '';
display: block;
width: 100%;
height: 8px;
@@ -33,7 +30,8 @@
background-image: linear-gradient(
to top,
var(--bg-surface-extra-low),
var(--bg-surface-extra-low-transparent));
var(--bg-surface-extra-low-transparent)
);
position: sticky;
bottom: -1px;
left: 0;
@@ -44,7 +42,7 @@
.space-container,
.sticky-container {
@extend .cp-fx__column--c-c;
padding: var(--sp-ultra-tight) 0;
& > .sidebar-avatar,
@@ -63,7 +61,7 @@
box-shadow: var(--bs-danger-border);
animation-name: pushRight;
animation-duration: 400ms;
animation-iteration-count: infinite;
animation-iteration-count: 30;
animation-direction: alternate;
}
@@ -74,4 +72,4 @@
to {
transform: translateX(0) scale(1);
}
}
}

View File

@@ -0,0 +1,125 @@
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>
);
}

View File

@@ -15,6 +15,7 @@ import PeopleDrawer from './PeopleDrawer';
function Room() {
const [roomInfo, setRoomInfo] = useState({
room: null,
roomTimeline: null,
eventId: null,
});
@@ -25,14 +26,17 @@ function Room() {
useEffect(() => {
const handleRoomSelected = (rId, pRoomId, eId) => {
roomInfo.roomTimeline?.removeInternalListeners();
if (mx.getRoom(rId)) {
const r = mx.getRoom(rId);
if (r) {
setRoomInfo({
room: r,
roomTimeline: new RoomTimeline(rId),
eventId: eId ?? null,
});
} else {
// TODO: add ability to join room if roomId is invalid
setRoomInfo({
room: r,
roomTimeline: null,
eventId: null,
});
@@ -43,7 +47,7 @@ function Room() {
return () => {
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
};
}, [roomInfo]);
}, [roomInfo, mx]);
useEffect(() => {
const handleDrawerToggling = (visiblity) => setIsDrawer(visiblity);
@@ -53,7 +57,7 @@ function Room() {
};
}, []);
const { roomTimeline, eventId } = roomInfo;
const { room, roomTimeline, eventId } = roomInfo;
if (roomTimeline === null) {
setTimeout(() => openNavigation());
return <Welcome />;
@@ -63,7 +67,7 @@ function Room() {
<div className="room">
<div className="room__content">
<RoomSettings roomId={roomTimeline.roomId} />
<RoomView roomTimeline={roomTimeline} eventId={eventId} />
<RoomView room={room} roomTimeline={roomTimeline} eventId={eventId} />
</div>
{isDrawer && <PeopleDrawer roomId={roomTimeline.roomId} />}
</div>

View File

@@ -0,0 +1,539 @@
import React, {
KeyboardEventHandler,
RefObject,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useAtom } from 'jotai';
import isHotkey from 'is-hotkey';
import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { Transforms, Range, Editor, Element } from 'slate';
import {
Box,
Dialog,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
PopOut,
Scroll,
Text,
config,
toRem,
} from 'folds';
import to from 'await-to-js';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import {
CustomEditor,
EditorChangeHandler,
useEditor,
Toolbar,
toMatrixCustomHTML,
toPlainText,
AUTOCOMPLETE_PREFIXES,
AutocompletePrefix,
AutocompleteQuery,
getAutocompleteQuery,
getPrevWorldRange,
resetEditor,
RoomMentionAutocomplete,
UserMentionAutocomplete,
EmoticonAutocomplete,
createEmoticonElement,
moveCursor,
} 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 navigation from '../../../client/state/navigation';
import cons from '../../../client/state/cons';
import { MessageReply } from '../../molecules/message/Message';
import colorMXID from '../../../util/colorMXID';
import { parseReplyBody, parseReplyFormattedBody } from '../../utils/room';
import { sanitizeText } from '../../utils/sanitize';
interface RoomInputProps {
roomViewRef: RefObject<HTMLElement>;
roomId: string;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ roomViewRef, roomId }, ref) => {
const mx = useMatrixClient();
const editor = useEditor();
const room = mx.getRoom(roomId);
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);
useEffect(() => {
Transforms.insertFragment(editor, msgDraft);
}, [editor, msgDraft]);
useEffect(() => {
ReactEditor.focus(editor);
return () => {
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft);
resetEditor(editor);
};
}, [roomId, editor, setMsgDraft]);
useEffect(() => {
const handleReplyTo = (
userId: string,
eventId: string,
body: string,
formattedBody: string
) => {
setReplyDraft({
userId,
eventId,
body,
formattedBody,
});
};
navigation.on(cons.events.navigation.REPLY_TO_CLICKED, handleReplyTo);
return () => {
navigation.removeListener(cons.events.navigation.REPLY_TO_CLICKED, handleReplyTo);
};
}, [setReplyDraft]);
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 sendPromises = uploads.map(async (upload) => {
const fileItem = selectedFiles.find((f) => f.file === upload.file);
if (fileItem && fileItem.file.type.startsWith('image')) {
const [imgError, imgContent] = await to(getImageMsgContent(fileItem, upload.mxc));
if (imgError) console.warn(imgError);
if (imgContent) mx.sendMessage(roomId, imgContent);
return;
}
if (fileItem && fileItem.file.type.startsWith('video')) {
const [videoError, videoContent] = await to(getVideoMsgContent(mx, fileItem, upload.mxc));
if (videoError) console.warn(videoError);
if (videoContent) mx.sendMessage(roomId, videoContent);
return;
}
if (fileItem && fileItem.file.type.startsWith('audio')) {
mx.sendMessage(roomId, getAudioMsgContent(fileItem, upload.mxc));
return;
}
if (fileItem) {
mx.sendMessage(roomId, getFileMsgContent(fileItem, upload.mxc));
}
});
handleCancelUpload(uploads);
await Promise.allSettled(sendPromises);
};
const submit = useCallback(() => {
uploadBoardHandlers.current?.handleSend();
const plainText = toPlainText(editor.children).trim();
const customHtml = toMatrixCustomHTML(editor.children);
if (plainText === '') return;
let body = plainText;
let formattedBody = customHtml;
if (replyDraft) {
body = parseReplyBody(replyDraft.userId, replyDraft.userId) + body;
formattedBody =
parseReplyFormattedBody(
roomId,
replyDraft.userId,
replyDraft.eventId,
replyDraft.formattedBody ?? sanitizeText(replyDraft.body)
) + formattedBody;
}
const content: IContent = {
msgtype: MsgType.Text,
body,
format: 'org.matrix.custom.html',
formatted_body: formattedBody,
};
if (replyDraft) {
content['m.relates_to'] = {
'm.in_reply_to': {
event_id: replyDraft.eventId,
},
};
}
mx.sendMessage(roomId, content);
resetEditor(editor);
setReplyDraft();
sendTypingStatus(false);
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft]);
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
const { selection } = editor;
if (isHotkey('enter', evt)) {
evt.preventDefault();
submit();
}
if (isHotkey('escape', evt)) {
evt.preventDefault();
setReplyDraft();
}
if (selection && Range.isCollapsed(selection)) {
if (isHotkey('arrowleft', evt)) {
evt.preventDefault();
Transforms.move(editor, { unit: 'offset', reverse: true });
}
if (isHotkey('arrowright', evt)) {
evt.preventDefault();
Transforms.move(editor, { unit: 'offset' });
}
}
},
[submit, editor, setReplyDraft]
);
const handleChange: EditorChangeHandler = (value) => {
const prevWordRange = getPrevWorldRange(editor);
const query = prevWordRange
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
: undefined;
setAutocompleteQuery(query);
const descendant = value[0];
if (descendant && Element.isElement(descendant)) {
const isEmpty = value.length === 1 && Editor.isEmpty(editor, descendant);
sendTypingStatus(!isEmpty);
}
};
const handleEmoticonSelect = (key: string, shortcode: string) => {
editor.insertNode(createEmoticonElement(key, shortcode));
moveCursor(editor);
};
const handleStickerSelect = async (mxc: string, shortcode: 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: shortcode,
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={() => setAutocompleteQuery(undefined)}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
<UserMentionAutocomplete
roomId={roomId}
editor={editor}
query={autocompleteQuery}
requestClose={() => setAutocompleteQuery(undefined)}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
<EmoticonAutocomplete
imagePackRooms={imagePackRooms}
editor={editor}
query={autocompleteQuery}
requestClose={() => setAutocompleteQuery(undefined)}
/>
)}
<CustomEditor
editor={editor}
placeholder="Send a message..."
onKeyDown={handleKeyDown}
onChange={handleChange}
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);
ReactEditor.focus(editor);
}}
/>
}
>
{(anchorRef) => (
<>
<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={emojiBoardTab === EmojiBoardTab.Emoji}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Smile} filled={emojiBoardTab === EmojiBoardTab.Emoji} />
</IconButton>
</>
)}
</PopOut>
)}
</UseStateProvider>
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Send} />
</IconButton>
</>
}
bottom={toolbar && <Toolbar />}
/>
</div>
);
}
);

View File

@@ -0,0 +1,10 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const RoomInputPlaceholder = style({
minHeight: toRem(48),
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R400,
});

View File

@@ -0,0 +1,11 @@
import React, { ComponentProps } from 'react';
import { Box, as } from 'folds';
import classNames from 'classnames';
import * as css from './RoomInputPlaceholder.css';
export const RoomInputPlaceholder = as<'div', ComponentProps<typeof Box>>(
({ className, ...props }, ref) => (
<Box className={classNames(css.RoomInputPlaceholder, className)} {...props} ref={ref} />
)
);

View File

@@ -0,0 +1,7 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
export const RoomTombstone = style({
padding: config.space.S200,
paddingLeft: config.space.S400,
});

View File

@@ -0,0 +1,67 @@
import React, { useCallback } from 'react';
import { Box, Button, Spinner, Text, color } from 'folds';
import { 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>
);
}

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import './RoomView.scss';
import { Text, config } from 'folds';
import EventEmitter from 'events';
@@ -10,16 +11,29 @@ import navigation from '../../../client/state/navigation';
import RoomViewHeader from './RoomViewHeader';
import RoomViewContent from './RoomViewContent';
import RoomViewFloating from './RoomViewFloating';
import RoomViewInput from './RoomViewInput';
import RoomViewCmdBar from './RoomViewCmdBar';
import { RoomInput } from './RoomInput';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { RoomTombstone } from './RoomTombstone';
import { usePowerLevels } from '../../hooks/usePowerLevels';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
const viewEvent = new EventEmitter();
function RoomView({ roomTimeline, eventId }) {
function RoomView({ room, roomTimeline, eventId }) {
const roomInputRef = useRef(null);
const roomViewRef = useRef(null);
// eslint-disable-next-line react/prop-types
const { roomId } = roomTimeline;
const mx = useMatrixClient();
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
const { getPowerLevel, canSendEvent } = usePowerLevels(room);
const myUserId = mx.getUserId();
const canMessage = myUserId ? canSendEvent(undefined, getPowerLevel(myUserId)) : false;
useEffect(() => {
const settingsToggle = (isVisible) => {
const roomView = roomViewRef.current;
@@ -47,23 +61,36 @@ function RoomView({ roomTimeline, eventId }) {
<RoomViewContent
eventId={eventId}
roomTimeline={roomTimeline}
roomInputRef={roomInputRef}
/>
<RoomViewFloating
roomId={roomId}
roomTimeline={roomTimeline}
/>
<RoomViewFloating roomId={roomId} roomTimeline={roomTimeline} />
</div>
<div className="room-view__sticky">
<RoomViewInput
roomId={roomId}
roomTimeline={roomTimeline}
viewEvent={viewEvent}
/>
<RoomViewCmdBar
roomId={roomId}
roomTimeline={roomTimeline}
viewEvent={viewEvent}
/>
<div className="room-view__editor">
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}
body={tombstoneEvent.getContent().body}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
/>
) : (
<>
{canMessage && (
<RoomInput 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>
<RoomViewCmdBar roomId={roomId} roomTimeline={roomTimeline} viewEvent={viewEvent} />
</div>
</div>
</div>
@@ -74,6 +101,7 @@ RoomView.defaultProps = {
eventId: null,
};
RoomView.propTypes = {
room: PropTypes.shape({}).isRequired,
roomTimeline: PropTypes.shape({}).isRequired,
eventId: PropTypes.string,
};

View File

@@ -35,11 +35,12 @@
@extend .cp-fx__item-one;
position: relative;
}
&__sticky {
min-height: 85px;
position: relative;
background: var(--bg-surface);
border-top: 1px solid var(--bg-surface-border);
}
}
&__editor {
padding: 0 var(--sp-normal);
}
}

View File

@@ -28,6 +28,7 @@ import { useForceUpdate } from '../../hooks/useForceUpdate';
import { parseTimelineChange } from './common';
import TimelineScroll from './TimelineScroll';
import EventLimit from './EventLimit';
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
const PAG_LIMIT = 30;
const MAX_MSG_DIFF_MINUTES = 5;
@@ -392,7 +393,7 @@ function useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, event
let jumpToItemIndex = -1;
function RoomViewContent({ eventId, roomTimeline }) {
function RoomViewContent({ roomInputRef, eventId, roomTimeline }) {
const [throttle] = useState(new Throttle());
const timelineSVRef = useRef(null);
@@ -484,6 +485,21 @@ function RoomViewContent({ eventId, roomTimeline }) {
}
}, [newEvent]);
useResizeObserver(
roomInputRef.current,
useCallback((entries) => {
if (!roomInputRef.current) return;
const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
if (!editorBaseEntry) return;
const timelineScroll = timelineScrollRef.current;
if (!roomTimeline.initialized) return;
if (timelineScroll.bottom < 40 && !roomTimeline.canPaginateForward() && document.visibilityState === 'visible') {
timelineScroll.scrollToBottom();
}
}, [roomInputRef])
);
const listenKeyboard = useCallback((event) => {
if (event.ctrlKey || event.altKey || event.metaKey) return;
if (event.key !== 'ArrowUp') return;
@@ -620,6 +636,9 @@ RoomViewContent.defaultProps = {
RoomViewContent.propTypes = {
eventId: PropTypes.string,
roomTimeline: PropTypes.shape({}).isRequired,
roomInputRef: PropTypes.shape({
current: PropTypes.shape({})
}).isRequired
};
export default RoomViewContent;

View File

@@ -0,0 +1,148 @@
import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
import to from 'await-to-js';
import { IThumbnailContent } 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 { MATRIX_BLUR_HASH_PROPERTY_NAME, encodeBlurHash } from '../../utils/blurHash';
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 (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) {
content.info = {
...getImageInfo(imgEl, file),
[MATRIX_BLUR_HASH_PROPERTY_NAME]: encodeBlurHash(imgEl),
};
}
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 (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;
};