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:
parent
2055d7a07f
commit
0b06bed1db
128 changed files with 8799 additions and 409 deletions
|
@ -27,6 +27,7 @@ module.exports = {
|
||||||
rules: {
|
rules: {
|
||||||
'linebreak-style': 0,
|
'linebreak-style': 0,
|
||||||
'no-underscore-dangle': 0,
|
'no-underscore-dangle': 0,
|
||||||
|
"no-shadow": "off",
|
||||||
|
|
||||||
"import/prefer-default-export": "off",
|
"import/prefer-default-export": "off",
|
||||||
"import/extensions": "off",
|
"import/extensions": "off",
|
||||||
|
@ -55,5 +56,6 @@ module.exports = {
|
||||||
"react-hooks/exhaustive-deps": "error",
|
"react-hooks/exhaustive-deps": "error",
|
||||||
|
|
||||||
"@typescript-eslint/no-unused-vars": "error",
|
"@typescript-eslint/no-unused-vars": "error",
|
||||||
|
"@typescript-eslint/no-shadow": "error"
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
|
|
||||||
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
||||||
|
|
||||||
<link rel="manifest" href="./manifest.json" />
|
<link rel="manifest" href="./public/manifest.json" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="application-name" content="Cinny" />
|
<meta name="application-name" content="Cinny" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Cinny" />
|
<meta name="apple-mobile-web-app-title" content="Cinny" />
|
||||||
|
|
764
package-lock.json
generated
764
package-lock.json
generated
File diff suppressed because it is too large
Load diff
19
package.json
19
package.json
|
@ -24,14 +24,25 @@
|
||||||
"@khanacademy/simple-markdown": "0.8.6",
|
"@khanacademy/simple-markdown": "0.8.6",
|
||||||
"@matrix-org/olm": "3.2.14",
|
"@matrix-org/olm": "3.2.14",
|
||||||
"@tippyjs/react": "4.2.6",
|
"@tippyjs/react": "4.2.6",
|
||||||
|
"@vanilla-extract/css": "1.9.3",
|
||||||
|
"@vanilla-extract/recipes": "0.3.0",
|
||||||
|
"@vanilla-extract/vite-plugin": "3.7.1",
|
||||||
|
"await-to-js": "3.0.0",
|
||||||
"blurhash": "2.0.4",
|
"blurhash": "2.0.4",
|
||||||
"browser-encrypt-attachment": "0.3.0",
|
"browser-encrypt-attachment": "0.3.0",
|
||||||
|
"classnames": "2.3.2",
|
||||||
"dateformat": "5.0.3",
|
"dateformat": "5.0.3",
|
||||||
|
"emojibase": "6.1.0",
|
||||||
"emojibase-data": "7.0.1",
|
"emojibase-data": "7.0.1",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"flux": "4.0.3",
|
"flux": "4.0.3",
|
||||||
|
"focus-trap-react": "10.0.2",
|
||||||
|
"folds": "1.2.1",
|
||||||
"formik": "2.2.9",
|
"formik": "2.2.9",
|
||||||
"html-react-parser": "3.0.4",
|
"html-react-parser": "3.0.4",
|
||||||
|
"immer": "9.0.16",
|
||||||
|
"is-hotkey": "0.2.0",
|
||||||
|
"jotai": "1.12.0",
|
||||||
"katex": "0.16.4",
|
"katex": "0.16.4",
|
||||||
"linkify-html": "4.0.2",
|
"linkify-html": "4.0.2",
|
||||||
"linkifyjs": "4.0.2",
|
"linkifyjs": "4.0.2",
|
||||||
|
@ -46,8 +57,11 @@
|
||||||
"react-google-recaptcha": "2.1.0",
|
"react-google-recaptcha": "2.1.0",
|
||||||
"react-modal": "3.16.1",
|
"react-modal": "3.16.1",
|
||||||
"sanitize-html": "2.8.0",
|
"sanitize-html": "2.8.0",
|
||||||
|
"slate": "0.90.0",
|
||||||
|
"slate-react": "0.90.0",
|
||||||
"tippy.js": "6.3.7",
|
"tippy.js": "6.3.7",
|
||||||
"twemoji": "14.0.2"
|
"twemoji": "14.0.2",
|
||||||
|
"ua-parser-js": "1.0.35"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||||
|
@ -56,6 +70,7 @@
|
||||||
"@types/node": "18.11.18",
|
"@types/node": "18.11.18",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.0.26",
|
||||||
"@types/react-dom": "18.0.9",
|
"@types/react-dom": "18.0.9",
|
||||||
|
"@types/ua-parser-js": "0.7.36",
|
||||||
"@typescript-eslint/eslint-plugin": "5.46.1",
|
"@typescript-eslint/eslint-plugin": "5.46.1",
|
||||||
"@typescript-eslint/parser": "5.46.1",
|
"@typescript-eslint/parser": "5.46.1",
|
||||||
"@vitejs/plugin-react": "3.0.0",
|
"@vitejs/plugin-react": "3.0.0",
|
||||||
|
@ -71,7 +86,7 @@
|
||||||
"prettier": "2.8.1",
|
"prettier": "2.8.1",
|
||||||
"sass": "1.56.2",
|
"sass": "1.56.2",
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"vite": "4.0.1",
|
"vite": "4.0.4",
|
||||||
"vite-plugin-static-copy": "0.13.0"
|
"vite-plugin-static-copy": "0.13.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
BIN
public/font/Twemoji.Mozilla.v.7.0.woff2
Normal file
BIN
public/font/Twemoji.Mozilla.v.7.0.woff2
Normal file
Binary file not shown.
BIN
public/font/Twemoji.Mozilla.v0.7.0.ttf
Normal file
BIN
public/font/Twemoji.Mozilla.v0.7.0.ttf
Normal file
Binary file not shown.
9
src/app/components/UseStateProvider.tsx
Normal file
9
src/app/components/UseStateProvider.tsx
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { Dispatch, ReactElement, SetStateAction, useState } from 'react';
|
||||||
|
|
||||||
|
type UseStateProviderProps<T> = {
|
||||||
|
initial: T | (() => T);
|
||||||
|
children: (value: T, setter: Dispatch<SetStateAction<T>>) => ReactElement;
|
||||||
|
};
|
||||||
|
export function UseStateProvider<T>({ initial, children }: UseStateProviderProps<T>) {
|
||||||
|
return children(...useState(initial));
|
||||||
|
}
|
63
src/app/components/editor/Editor.css.ts
Normal file
63
src/app/components/editor/Editor.css.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, DefaultReset, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const Editor = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EditorOptions = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
padding: config.space.S200,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EditorTextareaScroll = style({});
|
||||||
|
|
||||||
|
export const EditorTextarea = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
flexGrow: 1,
|
||||||
|
height: '100%',
|
||||||
|
padding: `${toRem(13)} 0`,
|
||||||
|
selectors: {
|
||||||
|
[`${EditorTextareaScroll}:first-child &`]: {
|
||||||
|
paddingLeft: toRem(13),
|
||||||
|
},
|
||||||
|
[`${EditorTextareaScroll}:last-child &`]: {
|
||||||
|
paddingRight: toRem(13),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EditorPlaceholder = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: 1,
|
||||||
|
opacity: config.opacity.Placeholder,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'&:not(:first-child)': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EditorToolbar = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
padding: config.space.S100,
|
||||||
|
},
|
||||||
|
]);
|
82
src/app/components/editor/Editor.preview.tsx
Normal file
82
src/app/components/editor/Editor.preview.tsx
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
config,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Line,
|
||||||
|
Modal,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
} from 'folds';
|
||||||
|
|
||||||
|
import { CustomEditor, useEditor } from './Editor';
|
||||||
|
import { Toolbar } from './Toolbar';
|
||||||
|
|
||||||
|
export function EditorPreview() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const editor = useEditor();
|
||||||
|
const [toolbar, setToolbar] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconButton variant="SurfaceVariant" onClick={() => setOpen(!open)}>
|
||||||
|
<Icon src={Icons.BlockQuote} />
|
||||||
|
</IconButton>
|
||||||
|
<Overlay open={open} backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setOpen(false),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal size="500">
|
||||||
|
<div style={{ padding: config.space.S400 }}>
|
||||||
|
<CustomEditor
|
||||||
|
editor={editor}
|
||||||
|
placeholder="Send a message..."
|
||||||
|
before={
|
||||||
|
<IconButton variant="SurfaceVariant" size="300" radii="300">
|
||||||
|
<Icon src={Icons.PlusCircle} />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
after={
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => setToolbar(!toolbar)}
|
||||||
|
aria-pressed={toolbar}
|
||||||
|
>
|
||||||
|
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton variant="SurfaceVariant" size="300" radii="300">
|
||||||
|
<Icon src={Icons.Smile} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton variant="SurfaceVariant" size="300" radii="300">
|
||||||
|
<Icon src={Icons.Send} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
bottom={
|
||||||
|
toolbar && (
|
||||||
|
<div>
|
||||||
|
<Line variant="SurfaceVariant" size="300" />
|
||||||
|
<Toolbar />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
151
src/app/components/editor/Editor.tsx
Normal file
151
src/app/components/editor/Editor.tsx
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
/* eslint-disable no-param-reassign */
|
||||||
|
import React, {
|
||||||
|
ClipboardEventHandler,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
ReactNode,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { Box, Scroll, Text } from 'folds';
|
||||||
|
import { Descendant, Editor, createEditor } from 'slate';
|
||||||
|
import {
|
||||||
|
Slate,
|
||||||
|
Editable,
|
||||||
|
withReact,
|
||||||
|
RenderLeafProps,
|
||||||
|
RenderElementProps,
|
||||||
|
RenderPlaceholderProps,
|
||||||
|
} from 'slate-react';
|
||||||
|
import { BlockType, RenderElement, RenderLeaf } from './Elements';
|
||||||
|
import { CustomElement } from './slate';
|
||||||
|
import * as css from './Editor.css';
|
||||||
|
import { toggleKeyboardShortcut } from './keyboard';
|
||||||
|
|
||||||
|
const initialValue: CustomElement[] = [
|
||||||
|
{
|
||||||
|
type: BlockType.Paragraph,
|
||||||
|
children: [{ text: '' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const withInline = (editor: Editor): Editor => {
|
||||||
|
const { isInline } = editor;
|
||||||
|
|
||||||
|
editor.isInline = (element) =>
|
||||||
|
[BlockType.Mention, BlockType.Emoticon, BlockType.Link].includes(element.type) ||
|
||||||
|
isInline(element);
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withVoid = (editor: Editor): Editor => {
|
||||||
|
const { isVoid } = editor;
|
||||||
|
|
||||||
|
editor.isVoid = (element) =>
|
||||||
|
[BlockType.Mention, BlockType.Emoticon].includes(element.type) || isVoid(element);
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEditor = (): Editor => {
|
||||||
|
const [editor] = useState(withInline(withVoid(withReact(createEditor()))));
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditorChangeHandler = ((value: Descendant[]) => void) | undefined;
|
||||||
|
type CustomEditorProps = {
|
||||||
|
top?: ReactNode;
|
||||||
|
bottom?: ReactNode;
|
||||||
|
before?: ReactNode;
|
||||||
|
after?: ReactNode;
|
||||||
|
maxHeight?: string;
|
||||||
|
editor: Editor;
|
||||||
|
placeholder?: string;
|
||||||
|
onKeyDown?: KeyboardEventHandler;
|
||||||
|
onChange?: EditorChangeHandler;
|
||||||
|
onPaste?: ClipboardEventHandler;
|
||||||
|
};
|
||||||
|
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
top,
|
||||||
|
bottom,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
maxHeight = '50vh',
|
||||||
|
editor,
|
||||||
|
placeholder,
|
||||||
|
onKeyDown,
|
||||||
|
onChange,
|
||||||
|
onPaste,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const renderElement = useCallback(
|
||||||
|
(props: RenderElementProps) => <RenderElement {...props} />,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
|
||||||
|
|
||||||
|
const handleKeydown: KeyboardEventHandler = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
onKeyDown?.(evt);
|
||||||
|
toggleKeyboardShortcut(editor, evt);
|
||||||
|
},
|
||||||
|
[editor, onKeyDown]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPlaceholder = useCallback(({ attributes, children }: RenderPlaceholderProps) => {
|
||||||
|
// drop style attribute as we use our custom placeholder css.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { style, ...props } = attributes;
|
||||||
|
return (
|
||||||
|
<Text as="span" {...props} className={css.EditorPlaceholder} contentEditable={false}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.Editor} ref={ref}>
|
||||||
|
<Slate editor={editor} value={initialValue} onChange={onChange}>
|
||||||
|
{top}
|
||||||
|
<Box alignItems="Start">
|
||||||
|
{before && (
|
||||||
|
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
||||||
|
{before}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Scroll
|
||||||
|
className={css.EditorTextareaScroll}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
style={{ maxHeight }}
|
||||||
|
size="300"
|
||||||
|
visibility="Hover"
|
||||||
|
hideTrack
|
||||||
|
>
|
||||||
|
<Editable
|
||||||
|
className={css.EditorTextarea}
|
||||||
|
placeholder={placeholder}
|
||||||
|
renderPlaceholder={renderPlaceholder}
|
||||||
|
renderElement={renderElement}
|
||||||
|
renderLeaf={renderLeaf}
|
||||||
|
onKeyDown={handleKeydown}
|
||||||
|
onPaste={onPaste}
|
||||||
|
/>
|
||||||
|
</Scroll>
|
||||||
|
{after && (
|
||||||
|
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
||||||
|
{after}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{bottom}
|
||||||
|
</Slate>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
142
src/app/components/editor/Elements.css.ts
Normal file
142
src/app/components/editor/Elements.css.ts
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { recipe } from '@vanilla-extract/recipes';
|
||||||
|
import { color, config, DefaultReset, toRem } from 'folds';
|
||||||
|
|
||||||
|
const MarginBottom = style({
|
||||||
|
marginBottom: config.space.S200,
|
||||||
|
selectors: {
|
||||||
|
'&:last-child': {
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Paragraph = style([MarginBottom]);
|
||||||
|
|
||||||
|
export const Heading = style([MarginBottom]);
|
||||||
|
|
||||||
|
export const BlockQuote = style([
|
||||||
|
DefaultReset,
|
||||||
|
MarginBottom,
|
||||||
|
{
|
||||||
|
paddingLeft: config.space.S200,
|
||||||
|
borderLeft: `${config.borderWidth.B700} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BaseCode = style({
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: color.Warning.OnContainer,
|
||||||
|
background: color.Warning.Container,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Warning.ContainerLine}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Code = style([
|
||||||
|
DefaultReset,
|
||||||
|
BaseCode,
|
||||||
|
{
|
||||||
|
padding: `0 ${config.space.S100}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
export const Spoiler = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
padding: `0 ${config.space.S100}`,
|
||||||
|
backgroundColor: color.SurfaceVariant.ContainerActive,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const CodeBlock = style([DefaultReset, BaseCode, MarginBottom]);
|
||||||
|
export const CodeBlockInternal = style({
|
||||||
|
padding: `${config.space.S200} ${config.space.S200} 0`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const List = style([
|
||||||
|
DefaultReset,
|
||||||
|
MarginBottom,
|
||||||
|
{
|
||||||
|
padding: `0 ${config.space.S100}`,
|
||||||
|
paddingLeft: config.space.S600,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const InlineChromiumBugfix = style({
|
||||||
|
fontSize: 0,
|
||||||
|
lineHeight: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Mention = recipe({
|
||||||
|
base: [
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
backgroundColor: color.Secondary.Container,
|
||||||
|
color: color.Secondary.OnContainer,
|
||||||
|
boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Secondary.ContainerLine}`,
|
||||||
|
padding: `0 ${toRem(2)}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
fontWeight: config.fontWeight.W500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variants: {
|
||||||
|
highlight: {
|
||||||
|
true: {
|
||||||
|
backgroundColor: color.Primary.Container,
|
||||||
|
color: color.Primary.OnContainer,
|
||||||
|
boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Primary.ContainerLine}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focus: {
|
||||||
|
true: {
|
||||||
|
boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.OnContainer}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EmoticonBase = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0.05rem',
|
||||||
|
height: '1em',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const Emoticon = recipe({
|
||||||
|
base: [
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'inline-flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
|
||||||
|
height: '1em',
|
||||||
|
minWidth: '1em',
|
||||||
|
fontSize: '1.47em',
|
||||||
|
lineHeight: '1em',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
position: 'relative',
|
||||||
|
top: '-0.25em',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variants: {
|
||||||
|
focus: {
|
||||||
|
true: {
|
||||||
|
boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.OnContainer}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EmoticonImg = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
height: '1em',
|
||||||
|
cursor: 'default',
|
||||||
|
},
|
||||||
|
]);
|
254
src/app/components/editor/Elements.tsx
Normal file
254
src/app/components/editor/Elements.tsx
Normal file
|
@ -0,0 +1,254 @@
|
||||||
|
import { Scroll, Text } from 'folds';
|
||||||
|
import React from 'react';
|
||||||
|
import { RenderElementProps, RenderLeafProps, useFocused, useSelected } from 'slate-react';
|
||||||
|
|
||||||
|
import * as css from './Elements.css';
|
||||||
|
import { EmoticonElement, LinkElement, MentionElement } from './slate';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
|
||||||
|
export enum MarkType {
|
||||||
|
Bold = 'bold',
|
||||||
|
Italic = 'italic',
|
||||||
|
Underline = 'underline',
|
||||||
|
StrikeThrough = 'strikeThrough',
|
||||||
|
Code = 'code',
|
||||||
|
Spoiler = 'spoiler',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum BlockType {
|
||||||
|
Paragraph = 'paragraph',
|
||||||
|
Heading = 'heading',
|
||||||
|
CodeLine = 'code-line',
|
||||||
|
CodeBlock = 'code-block',
|
||||||
|
QuoteLine = 'quote-line',
|
||||||
|
BlockQuote = 'block-quote',
|
||||||
|
ListItem = 'list-item',
|
||||||
|
OrderedList = 'ordered-list',
|
||||||
|
UnorderedList = 'unordered-list',
|
||||||
|
Mention = 'mention',
|
||||||
|
Emoticon = 'emoticon',
|
||||||
|
Link = 'link',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put this at the start and end of an inline component to work around this Chromium bug:
|
||||||
|
// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405
|
||||||
|
function InlineChromiumBugfix() {
|
||||||
|
return (
|
||||||
|
<span className={css.InlineChromiumBugfix} contentEditable={false}>
|
||||||
|
{String.fromCodePoint(160) /* Non-breaking space */}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenderMentionElement({
|
||||||
|
attributes,
|
||||||
|
element,
|
||||||
|
children,
|
||||||
|
}: { element: MentionElement } & RenderElementProps) {
|
||||||
|
const selected = useSelected();
|
||||||
|
const focused = useFocused();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
{...attributes}
|
||||||
|
className={css.Mention({
|
||||||
|
highlight: element.highlight,
|
||||||
|
focus: selected && focused,
|
||||||
|
})}
|
||||||
|
contentEditable={false}
|
||||||
|
>
|
||||||
|
{element.name}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenderEmoticonElement({
|
||||||
|
attributes,
|
||||||
|
element,
|
||||||
|
children,
|
||||||
|
}: { element: EmoticonElement } & RenderElementProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const selected = useSelected();
|
||||||
|
const focused = useFocused();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={css.EmoticonBase} {...attributes}>
|
||||||
|
<span
|
||||||
|
className={css.Emoticon({
|
||||||
|
focus: selected && focused,
|
||||||
|
})}
|
||||||
|
contentEditable={false}
|
||||||
|
>
|
||||||
|
{element.key.startsWith('mxc://') ? (
|
||||||
|
<img
|
||||||
|
className={css.EmoticonImg}
|
||||||
|
src={mx.mxcUrlToHttp(element.key) ?? element.key}
|
||||||
|
alt={element.shortcode}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
element.key
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenderLinkElement({
|
||||||
|
attributes,
|
||||||
|
element,
|
||||||
|
children,
|
||||||
|
}: { element: LinkElement } & RenderElementProps) {
|
||||||
|
return (
|
||||||
|
<a href={element.href} {...attributes}>
|
||||||
|
<InlineChromiumBugfix />
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenderElement({ attributes, element, children }: RenderElementProps) {
|
||||||
|
switch (element.type) {
|
||||||
|
case BlockType.Paragraph:
|
||||||
|
return (
|
||||||
|
<Text {...attributes} className={css.Paragraph}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
case BlockType.Heading:
|
||||||
|
if (element.level === 1)
|
||||||
|
return (
|
||||||
|
<Text className={css.Heading} as="h2" size="H2" {...attributes}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
if (element.level === 2)
|
||||||
|
return (
|
||||||
|
<Text className={css.Heading} as="h3" size="H3" {...attributes}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
if (element.level === 3)
|
||||||
|
return (
|
||||||
|
<Text className={css.Heading} as="h4" size="H4" {...attributes}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Text className={css.Heading} as="h3" size="H3" {...attributes}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
case BlockType.CodeLine:
|
||||||
|
return <div {...attributes}>{children}</div>;
|
||||||
|
case BlockType.CodeBlock:
|
||||||
|
return (
|
||||||
|
<Text as="pre" className={css.CodeBlock} {...attributes}>
|
||||||
|
<Scroll direction="Horizontal" variant="Warning" size="300" visibility="Hover" hideTrack>
|
||||||
|
<div className={css.CodeBlockInternal}>{children}</div>
|
||||||
|
</Scroll>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
case BlockType.QuoteLine:
|
||||||
|
return <div {...attributes}>{children}</div>;
|
||||||
|
case BlockType.BlockQuote:
|
||||||
|
return (
|
||||||
|
<Text as="blockquote" className={css.BlockQuote} {...attributes}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
case BlockType.ListItem:
|
||||||
|
return (
|
||||||
|
<Text as="li" {...attributes}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
case BlockType.OrderedList:
|
||||||
|
return (
|
||||||
|
<ol className={css.List} {...attributes}>
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
case BlockType.UnorderedList:
|
||||||
|
return (
|
||||||
|
<ul className={css.List} {...attributes}>
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
case BlockType.Mention:
|
||||||
|
return (
|
||||||
|
<RenderMentionElement attributes={attributes} element={element}>
|
||||||
|
{children}
|
||||||
|
</RenderMentionElement>
|
||||||
|
);
|
||||||
|
case BlockType.Emoticon:
|
||||||
|
return (
|
||||||
|
<RenderEmoticonElement attributes={attributes} element={element}>
|
||||||
|
{children}
|
||||||
|
</RenderEmoticonElement>
|
||||||
|
);
|
||||||
|
case BlockType.Link:
|
||||||
|
return (
|
||||||
|
<RenderLinkElement attributes={attributes} element={element}>
|
||||||
|
{children}
|
||||||
|
</RenderLinkElement>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Text className={css.Paragraph} {...attributes}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenderLeaf({ attributes, leaf, children }: RenderLeafProps) {
|
||||||
|
let child = children;
|
||||||
|
if (leaf.bold)
|
||||||
|
child = (
|
||||||
|
<strong {...attributes}>
|
||||||
|
<InlineChromiumBugfix />
|
||||||
|
{child}
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
|
if (leaf.italic)
|
||||||
|
child = (
|
||||||
|
<i {...attributes}>
|
||||||
|
<InlineChromiumBugfix />
|
||||||
|
{child}
|
||||||
|
</i>
|
||||||
|
);
|
||||||
|
if (leaf.underline)
|
||||||
|
child = (
|
||||||
|
<u {...attributes}>
|
||||||
|
<InlineChromiumBugfix />
|
||||||
|
{child}
|
||||||
|
</u>
|
||||||
|
);
|
||||||
|
if (leaf.strikeThrough)
|
||||||
|
child = (
|
||||||
|
<s {...attributes}>
|
||||||
|
<InlineChromiumBugfix />
|
||||||
|
{child}
|
||||||
|
</s>
|
||||||
|
);
|
||||||
|
if (leaf.code)
|
||||||
|
child = (
|
||||||
|
<code className={css.Code} {...attributes}>
|
||||||
|
<InlineChromiumBugfix />
|
||||||
|
{child}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
if (leaf.spoiler)
|
||||||
|
child = (
|
||||||
|
<span className={css.Spoiler} {...attributes}>
|
||||||
|
<InlineChromiumBugfix />
|
||||||
|
{child}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (child !== children) return child;
|
||||||
|
|
||||||
|
return <span {...attributes}>{child}</span>;
|
||||||
|
}
|
247
src/app/components/editor/Toolbar.tsx
Normal file
247
src/app/components/editor/Toolbar.tsx
Normal file
|
@ -0,0 +1,247 @@
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
config,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
IconSrc,
|
||||||
|
Line,
|
||||||
|
Menu,
|
||||||
|
PopOut,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import React, { ReactNode, useState } from 'react';
|
||||||
|
import { ReactEditor, useSlate } from 'slate-react';
|
||||||
|
import { isBlockActive, isMarkActive, toggleBlock, toggleMark } from './common';
|
||||||
|
import * as css from './Editor.css';
|
||||||
|
import { BlockType, MarkType } from './Elements';
|
||||||
|
import { HeadingLevel } from './slate';
|
||||||
|
import { isMacOS } from '../../utils/user-agent';
|
||||||
|
import { KeySymbol } from '../../utils/key-symbol';
|
||||||
|
|
||||||
|
function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
|
||||||
|
return (
|
||||||
|
<Tooltip style={{ padding: config.space.S300 }}>
|
||||||
|
<Box gap="200" direction="Column" alignItems="Center">
|
||||||
|
<Text align="Center">{text}</Text>
|
||||||
|
{shortCode && (
|
||||||
|
<Badge as="kbd" radii="300" size="500">
|
||||||
|
<Text size="T200" align="Center">
|
||||||
|
{shortCode}
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarkButtonProps = { format: MarkType; icon: IconSrc; tooltip: ReactNode };
|
||||||
|
export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
|
||||||
|
const editor = useSlate();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
toggleMark(editor, format);
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider tooltip={tooltip} delay={500}>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
onClick={handleClick}
|
||||||
|
aria-pressed={isMarkActive(editor, format)}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon size="50" src={icon} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlockButtonProps = {
|
||||||
|
format: BlockType;
|
||||||
|
icon: IconSrc;
|
||||||
|
tooltip: ReactNode;
|
||||||
|
};
|
||||||
|
export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
|
||||||
|
const editor = useSlate();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
toggleBlock(editor, format, { level: 1 });
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider tooltip={tooltip} delay={500}>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
onClick={handleClick}
|
||||||
|
aria-pressed={isBlockActive(editor, format)}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon size="50" src={icon} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeadingBlockButton() {
|
||||||
|
const editor = useSlate();
|
||||||
|
const [level, setLevel] = useState<HeadingLevel>(1);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const isActive = isBlockActive(editor, BlockType.Heading);
|
||||||
|
|
||||||
|
const handleMenuSelect = (selectedLevel: HeadingLevel) => {
|
||||||
|
setOpen(false);
|
||||||
|
setLevel(selectedLevel);
|
||||||
|
toggleBlock(editor, BlockType.Heading, { level: selectedLevel });
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
open={open}
|
||||||
|
align="Start"
|
||||||
|
position="Top"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setOpen(false),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ padding: config.space.S100 }}>
|
||||||
|
<Box gap="100">
|
||||||
|
<IconButton onClick={() => handleMenuSelect(1)} size="300" radii="300">
|
||||||
|
<Icon size="100" src={Icons.Heading1} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton onClick={() => handleMenuSelect(2)} size="300" radii="300">
|
||||||
|
<Icon size="100" src={Icons.Heading2} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton onClick={() => handleMenuSelect(3)} size="300" radii="300">
|
||||||
|
<Icon size="100" src={Icons.Heading3} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(ref) => (
|
||||||
|
<IconButton
|
||||||
|
style={{ width: 'unset' }}
|
||||||
|
ref={ref}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
onClick={() => (isActive ? toggleBlock(editor, BlockType.Heading) : setOpen(!open))}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons[`Heading${level}`]} />
|
||||||
|
<Icon size="50" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toolbar() {
|
||||||
|
const editor = useSlate();
|
||||||
|
const allowInline = !isBlockActive(editor, BlockType.CodeBlock);
|
||||||
|
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={css.EditorToolbar} alignItems="Center" gap="300">
|
||||||
|
<Box gap="100">
|
||||||
|
<HeadingBlockButton />
|
||||||
|
<BlockButton
|
||||||
|
format={BlockType.OrderedList}
|
||||||
|
icon={Icons.OrderList}
|
||||||
|
tooltip={
|
||||||
|
<BtnTooltip text="Ordered List" shortCode={`${modKey} + ${KeySymbol.Shift} + 0`} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<BlockButton
|
||||||
|
format={BlockType.UnorderedList}
|
||||||
|
icon={Icons.UnorderList}
|
||||||
|
tooltip={
|
||||||
|
<BtnTooltip text="Unordered List" shortCode={`${modKey} + ${KeySymbol.Shift} + 8`} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<BlockButton
|
||||||
|
format={BlockType.BlockQuote}
|
||||||
|
icon={Icons.BlockQuote}
|
||||||
|
tooltip={
|
||||||
|
<BtnTooltip text="Block Quote" shortCode={`${modKey} + ${KeySymbol.Shift} + '`} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<BlockButton
|
||||||
|
format={BlockType.CodeBlock}
|
||||||
|
icon={Icons.BlockCode}
|
||||||
|
tooltip={
|
||||||
|
<BtnTooltip text="Block Code" shortCode={`${modKey} + ${KeySymbol.Shift} + ;`} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{allowInline && (
|
||||||
|
<>
|
||||||
|
<Line variant="SurfaceVariant" direction="Vertical" style={{ height: toRem(12) }} />
|
||||||
|
<Box gap="100">
|
||||||
|
<MarkButton
|
||||||
|
format={MarkType.Bold}
|
||||||
|
icon={Icons.Bold}
|
||||||
|
tooltip={<BtnTooltip text="Bold" shortCode={`${modKey} + B`} />}
|
||||||
|
/>
|
||||||
|
<MarkButton
|
||||||
|
format={MarkType.Italic}
|
||||||
|
icon={Icons.Italic}
|
||||||
|
tooltip={<BtnTooltip text="Italic" shortCode={`${modKey} + I`} />}
|
||||||
|
/>
|
||||||
|
<MarkButton
|
||||||
|
format={MarkType.Underline}
|
||||||
|
icon={Icons.Underline}
|
||||||
|
tooltip={<BtnTooltip text="Underline" shortCode={`${modKey} + U`} />}
|
||||||
|
/>
|
||||||
|
<MarkButton
|
||||||
|
format={MarkType.StrikeThrough}
|
||||||
|
icon={Icons.Strike}
|
||||||
|
tooltip={
|
||||||
|
<BtnTooltip
|
||||||
|
text="Strike Through"
|
||||||
|
shortCode={`${modKey} + ${KeySymbol.Shift} + U`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MarkButton
|
||||||
|
format={MarkType.Code}
|
||||||
|
icon={Icons.Code}
|
||||||
|
tooltip={<BtnTooltip text="Inline Code" shortCode={`${modKey} + [`} />}
|
||||||
|
/>
|
||||||
|
<MarkButton
|
||||||
|
format={MarkType.Spoiler}
|
||||||
|
icon={Icons.EyeBlind}
|
||||||
|
tooltip={<BtnTooltip text="Spoiler" shortCode={`${modKey} + H`} />}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { DefaultReset, config } from 'folds';
|
||||||
|
|
||||||
|
export const AutocompleteMenuBase = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const AutocompleteMenuContainer = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: config.space.S200,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: config.zIndex.Max,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const AutocompleteMenu = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
maxHeight: '30vh',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const AutocompleteMenuHeader = style([
|
||||||
|
DefaultReset,
|
||||||
|
{ padding: `0 ${config.space.S300}`, flexShrink: 0 },
|
||||||
|
]);
|
40
src/app/components/editor/autocomplete/AutocompleteMenu.tsx
Normal file
40
src/app/components/editor/autocomplete/AutocompleteMenu.tsx
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import isHotkey from 'is-hotkey';
|
||||||
|
import { Header, Menu, Scroll, config } from 'folds';
|
||||||
|
|
||||||
|
import * as css from './AutocompleteMenu.css';
|
||||||
|
import { preventScrollWithArrowKey } from '../../../utils/keyboard';
|
||||||
|
|
||||||
|
type AutocompleteMenuProps = {
|
||||||
|
requestClose: () => void;
|
||||||
|
headerContent: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
export function AutocompleteMenu({ headerContent, requestClose, children }: AutocompleteMenuProps) {
|
||||||
|
return (
|
||||||
|
<div className={css.AutocompleteMenuBase}>
|
||||||
|
<div className={css.AutocompleteMenuContainer}>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => requestClose(),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
allowOutsideClick: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => isHotkey('arrowdown', evt),
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => isHotkey('arrowup', evt),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu className={css.AutocompleteMenu}>
|
||||||
|
<Header className={css.AutocompleteMenuHeader} size="400">
|
||||||
|
{headerContent}
|
||||||
|
</Header>
|
||||||
|
<Scroll style={{ flexGrow: 1 }} onKeyDown={preventScrollWithArrowKey}>
|
||||||
|
<div style={{ padding: config.space.S200 }}>{children}</div>
|
||||||
|
</Scroll>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
129
src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx
Normal file
129
src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo } from 'react';
|
||||||
|
import { Editor } from 'slate';
|
||||||
|
import { Box, MenuItem, Text, toRem } from 'folds';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
import { AutocompleteQuery } from './autocompleteQuery';
|
||||||
|
import { AutocompleteMenu } from './AutocompleteMenu';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import {
|
||||||
|
SearchItemStrGetter,
|
||||||
|
UseAsyncSearchOptions,
|
||||||
|
useAsyncSearch,
|
||||||
|
} from '../../../hooks/useAsyncSearch';
|
||||||
|
import { onTabPress } from '../../../utils/keyboard';
|
||||||
|
import { createEmoticonElement, moveCursor, replaceWithElement } from '../common';
|
||||||
|
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||||
|
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
|
||||||
|
import { IEmoji, emojis } from '../../../plugins/emoji';
|
||||||
|
import { ExtendedPackImage, PackUsage } from '../../../plugins/custom-emoji';
|
||||||
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
|
|
||||||
|
type EmoticonCompleteHandler = (key: string, shortcode: string) => void;
|
||||||
|
|
||||||
|
type EmoticonSearchItem = ExtendedPackImage | IEmoji;
|
||||||
|
|
||||||
|
type EmoticonAutocompleteProps = {
|
||||||
|
imagePackRooms: Room[];
|
||||||
|
editor: Editor;
|
||||||
|
query: AutocompleteQuery<string>;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
|
limit: 20,
|
||||||
|
matchOptions: {
|
||||||
|
contain: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEmoticonStr: SearchItemStrGetter<EmoticonSearchItem> = (emoticon) => [
|
||||||
|
`:${emoticon.shortcode}:`,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function EmoticonAutocomplete({
|
||||||
|
imagePackRooms,
|
||||||
|
editor,
|
||||||
|
query,
|
||||||
|
requestClose,
|
||||||
|
}: EmoticonAutocompleteProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
const imagePacks = useRelevantImagePacks(mx, PackUsage.Emoticon, imagePackRooms);
|
||||||
|
const recentEmoji = useRecentEmoji(mx, 20);
|
||||||
|
|
||||||
|
const searchList = useMemo(() => {
|
||||||
|
const list: Array<EmoticonSearchItem> = [];
|
||||||
|
return list.concat(
|
||||||
|
imagePacks.flatMap((pack) => pack.getImagesFor(PackUsage.Emoticon)),
|
||||||
|
emojis
|
||||||
|
);
|
||||||
|
}, [imagePacks]);
|
||||||
|
|
||||||
|
const [result, search] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS);
|
||||||
|
const autoCompleteEmoticon = result ? result.items : recentEmoji;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
search(query.text);
|
||||||
|
}, [query.text, search]);
|
||||||
|
|
||||||
|
const handleAutocomplete: EmoticonCompleteHandler = (key, shortcode) => {
|
||||||
|
const emoticonEl = createEmoticonElement(key, shortcode);
|
||||||
|
replaceWithElement(editor, query.range, emoticonEl);
|
||||||
|
moveCursor(editor, true);
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
useKeyDown(window, (evt: KeyboardEvent) => {
|
||||||
|
onTabPress(evt, () => {
|
||||||
|
if (autoCompleteEmoticon.length === 0) return;
|
||||||
|
const emoticon = autoCompleteEmoticon[0];
|
||||||
|
const key = 'url' in emoticon ? emoticon.url : emoticon.unicode;
|
||||||
|
handleAutocomplete(key, emoticon.shortcode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return autoCompleteEmoticon.length === 0 ? null : (
|
||||||
|
<AutocompleteMenu headerContent={<Text size="L400">Emojis</Text>} requestClose={requestClose}>
|
||||||
|
{autoCompleteEmoticon.map((emoticon) => {
|
||||||
|
const isCustomEmoji = 'url' in emoticon;
|
||||||
|
const key = isCustomEmoji ? emoticon.url : emoticon.unicode;
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={emoticon.shortcode + key}
|
||||||
|
as="button"
|
||||||
|
radii="300"
|
||||||
|
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
|
||||||
|
onTabPress(evt, () => handleAutocomplete(key, emoticon.shortcode))
|
||||||
|
}
|
||||||
|
onClick={() => handleAutocomplete(key, emoticon.shortcode)}
|
||||||
|
before={
|
||||||
|
isCustomEmoji ? (
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
as="img"
|
||||||
|
src={mx.mxcUrlToHttp(key) || key}
|
||||||
|
alt={emoticon.shortcode}
|
||||||
|
style={{ width: toRem(24), height: toRem(24) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
as="span"
|
||||||
|
display="InlineFlex"
|
||||||
|
style={{ fontSize: toRem(24), lineHeight: toRem(24) }}
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||||
|
:{emoticon.shortcode}:
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AutocompleteMenu>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,181 @@
|
||||||
|
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { Editor } from 'slate';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage, Icon, Icons, MenuItem, Text, color } from 'folds';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
import { createMentionElement, moveCursor, replaceWithElement } from '../common';
|
||||||
|
import { getRoomAvatarUrl, joinRuleToIconSrc } from '../../../utils/room';
|
||||||
|
import { roomIdByActivity } from '../../../../util/sort';
|
||||||
|
import initMatrix from '../../../../client/initMatrix';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { AutocompleteQuery } from './autocompleteQuery';
|
||||||
|
import { AutocompleteMenu } from './AutocompleteMenu';
|
||||||
|
import { getMxIdServer, validMxId } from '../../../utils/matrix';
|
||||||
|
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
|
||||||
|
import { onTabPress } from '../../../utils/keyboard';
|
||||||
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
|
|
||||||
|
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
|
||||||
|
|
||||||
|
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
|
||||||
|
validMxId(`#${text}`)
|
||||||
|
? `#${text}`
|
||||||
|
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
||||||
|
|
||||||
|
function UnknownRoomMentionItem({
|
||||||
|
query,
|
||||||
|
handleAutocomplete,
|
||||||
|
}: {
|
||||||
|
query: AutocompleteQuery<string>;
|
||||||
|
handleAutocomplete: MentionAutoCompleteHandler;
|
||||||
|
}) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const roomAlias: string = roomAliasFromQueryText(mx, query.text);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
as="button"
|
||||||
|
radii="300"
|
||||||
|
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
|
||||||
|
onTabPress(evt, () => handleAutocomplete(roomAlias, roomAlias))
|
||||||
|
}
|
||||||
|
onClick={() => handleAutocomplete(roomAlias, roomAlias)}
|
||||||
|
before={
|
||||||
|
<Avatar size="200">
|
||||||
|
<Icon src={Icons.Hash} size="100" />
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} size="B400">
|
||||||
|
{roomAlias}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoomMentionAutocompleteProps = {
|
||||||
|
roomId: string;
|
||||||
|
editor: Editor;
|
||||||
|
query: AutocompleteQuery<string>;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
|
limit: 20,
|
||||||
|
matchOptions: {
|
||||||
|
contain: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoomMentionAutocomplete({
|
||||||
|
roomId,
|
||||||
|
editor,
|
||||||
|
query,
|
||||||
|
requestClose,
|
||||||
|
}: RoomMentionAutocompleteProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const dms: Set<string> = initMatrix.roomList?.directs ?? new Set();
|
||||||
|
|
||||||
|
const allRoomId: string[] = useMemo(() => {
|
||||||
|
const { spaces = [], rooms = [], directs = [] } = initMatrix.roomList ?? {};
|
||||||
|
return [...spaces, ...rooms, ...directs].sort(roomIdByActivity);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [result, search] = useAsyncSearch(
|
||||||
|
allRoomId,
|
||||||
|
useCallback(
|
||||||
|
(rId) => {
|
||||||
|
const r = mx.getRoom(rId);
|
||||||
|
if (!r) return 'Unknown Room';
|
||||||
|
const alias = r.getCanonicalAlias();
|
||||||
|
if (alias) return [r.name, alias];
|
||||||
|
return r.name;
|
||||||
|
},
|
||||||
|
[mx]
|
||||||
|
),
|
||||||
|
SEARCH_OPTIONS
|
||||||
|
);
|
||||||
|
|
||||||
|
const autoCompleteRoomIds = result ? result.items : allRoomId.slice(0, 20);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
search(query.text);
|
||||||
|
}, [query.text, search]);
|
||||||
|
|
||||||
|
const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => {
|
||||||
|
const mentionEl = createMentionElement(
|
||||||
|
roomAliasOrId,
|
||||||
|
name.startsWith('#') ? name : `#${name}`,
|
||||||
|
roomId === roomAliasOrId || mx.getRoom(roomId)?.getCanonicalAlias() === roomAliasOrId
|
||||||
|
);
|
||||||
|
replaceWithElement(editor, query.range, mentionEl);
|
||||||
|
moveCursor(editor, true);
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
useKeyDown(window, (evt: KeyboardEvent) => {
|
||||||
|
onTabPress(evt, () => {
|
||||||
|
if (autoCompleteRoomIds.length === 0) {
|
||||||
|
const alias = roomAliasFromQueryText(mx, query.text);
|
||||||
|
handleAutocomplete(alias, alias);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rId = autoCompleteRoomIds[0];
|
||||||
|
const name = mx.getRoom(rId)?.name ?? rId;
|
||||||
|
handleAutocomplete(rId, name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AutocompleteMenu headerContent={<Text size="L400">Rooms</Text>} requestClose={requestClose}>
|
||||||
|
{autoCompleteRoomIds.length === 0 ? (
|
||||||
|
<UnknownRoomMentionItem query={query} handleAutocomplete={handleAutocomplete} />
|
||||||
|
) : (
|
||||||
|
autoCompleteRoomIds.map((rId) => {
|
||||||
|
const room = mx.getRoom(rId);
|
||||||
|
if (!room) return null;
|
||||||
|
const dm = dms.has(room.roomId);
|
||||||
|
const avatarUrl = getRoomAvatarUrl(mx, room);
|
||||||
|
const iconSrc = !dm && joinRuleToIconSrc(Icons, room.getJoinRule(), room.isSpaceRoom());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={rId}
|
||||||
|
as="button"
|
||||||
|
radii="300"
|
||||||
|
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
|
||||||
|
onTabPress(evt, () => handleAutocomplete(rId, room.name))
|
||||||
|
}
|
||||||
|
onClick={() => handleAutocomplete(rId, room.name)}
|
||||||
|
after={
|
||||||
|
<Text size="T200" priority="300" truncate>
|
||||||
|
{room.getCanonicalAlias() ?? ''}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
before={
|
||||||
|
<Avatar size="200">
|
||||||
|
{iconSrc && <Icon src={iconSrc} size="100" />}
|
||||||
|
{avatarUrl && !iconSrc && <AvatarImage src={avatarUrl} alt={room.name} />}
|
||||||
|
{!avatarUrl && !iconSrc && (
|
||||||
|
<AvatarFallback
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.Secondary.Container,
|
||||||
|
color: color.Secondary.OnContainer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="H6">{room.name[0]}</Text>
|
||||||
|
</AvatarFallback>
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||||
|
{room.name}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</AutocompleteMenu>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,191 @@
|
||||||
|
import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||||
|
import { Editor } from 'slate';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage, MenuItem, Text, color } from 'folds';
|
||||||
|
import { MatrixClient, RoomMember } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
import { AutocompleteQuery } from './autocompleteQuery';
|
||||||
|
import { AutocompleteMenu } from './AutocompleteMenu';
|
||||||
|
import { useRoomMembers } from '../../../hooks/useRoomMembers';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import {
|
||||||
|
SearchItemStrGetter,
|
||||||
|
UseAsyncSearchOptions,
|
||||||
|
useAsyncSearch,
|
||||||
|
} from '../../../hooks/useAsyncSearch';
|
||||||
|
import { onTabPress } from '../../../utils/keyboard';
|
||||||
|
import { createMentionElement, moveCursor, replaceWithElement } from '../common';
|
||||||
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
|
import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix';
|
||||||
|
|
||||||
|
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
|
||||||
|
|
||||||
|
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
|
||||||
|
validMxId(`@${text}`)
|
||||||
|
? `@${text}`
|
||||||
|
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
||||||
|
|
||||||
|
function UnknownMentionItem({
|
||||||
|
query,
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
handleAutocomplete,
|
||||||
|
}: {
|
||||||
|
query: AutocompleteQuery<string>;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
handleAutocomplete: MentionAutoCompleteHandler;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
as="button"
|
||||||
|
radii="300"
|
||||||
|
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
|
||||||
|
onTabPress(evt, () => handleAutocomplete(userId, name))
|
||||||
|
}
|
||||||
|
onClick={() => handleAutocomplete(userId, name)}
|
||||||
|
before={
|
||||||
|
<Avatar size="200">
|
||||||
|
<AvatarFallback
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.Secondary.Container,
|
||||||
|
color: color.Secondary.OnContainer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="H6">{query.text[0]}</Text>
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} size="B400">
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserMentionAutocompleteProps = {
|
||||||
|
roomId: string;
|
||||||
|
editor: Editor;
|
||||||
|
query: AutocompleteQuery<string>;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
|
limit: 20,
|
||||||
|
matchOptions: {
|
||||||
|
contain: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (roomMember) => [
|
||||||
|
roomMember.name,
|
||||||
|
getMxIdLocalPart(roomMember.userId) ?? roomMember.userId,
|
||||||
|
roomMember.userId,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function UserMentionAutocomplete({
|
||||||
|
roomId,
|
||||||
|
editor,
|
||||||
|
query,
|
||||||
|
requestClose,
|
||||||
|
}: UserMentionAutocompleteProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
const roomAliasOrId = room?.getCanonicalAlias() || roomId;
|
||||||
|
const members = useRoomMembers(mx, roomId);
|
||||||
|
|
||||||
|
const [result, search] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS);
|
||||||
|
const autoCompleteMembers = result ? result.items : members.slice(0, 20);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
search(query.text);
|
||||||
|
}, [query.text, search]);
|
||||||
|
|
||||||
|
const handleAutocomplete: MentionAutoCompleteHandler = (uId, name) => {
|
||||||
|
const mentionEl = createMentionElement(
|
||||||
|
uId,
|
||||||
|
name.startsWith('@') ? name : `@${name}`,
|
||||||
|
mx.getUserId() === uId || roomAliasOrId === uId
|
||||||
|
);
|
||||||
|
replaceWithElement(editor, query.range, mentionEl);
|
||||||
|
moveCursor(editor, true);
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
useKeyDown(window, (evt: KeyboardEvent) => {
|
||||||
|
onTabPress(evt, () => {
|
||||||
|
if (query.text === 'room') {
|
||||||
|
handleAutocomplete(roomAliasOrId, '@room');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (autoCompleteMembers.length === 0) {
|
||||||
|
const userId = userIdFromQueryText(mx, query.text);
|
||||||
|
handleAutocomplete(userId, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const roomMember = autoCompleteMembers[0];
|
||||||
|
handleAutocomplete(roomMember.userId, roomMember.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
|
||||||
|
{query.text === 'room' && (
|
||||||
|
<UnknownMentionItem
|
||||||
|
query={query}
|
||||||
|
userId={roomAliasOrId}
|
||||||
|
name="@room"
|
||||||
|
handleAutocomplete={handleAutocomplete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{autoCompleteMembers.length === 0 ? (
|
||||||
|
<UnknownMentionItem
|
||||||
|
query={query}
|
||||||
|
userId={userIdFromQueryText(mx, query.text)}
|
||||||
|
name={userIdFromQueryText(mx, query.text)}
|
||||||
|
handleAutocomplete={handleAutocomplete}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
autoCompleteMembers.map((roomMember) => {
|
||||||
|
const avatarUrl = roomMember.getAvatarUrl(mx.baseUrl, 32, 32, 'crop', undefined, false);
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={roomMember.userId}
|
||||||
|
as="button"
|
||||||
|
radii="300"
|
||||||
|
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
|
||||||
|
onTabPress(evt, () => handleAutocomplete(roomMember.userId, roomMember.name))
|
||||||
|
}
|
||||||
|
onClick={() => handleAutocomplete(roomMember.userId, roomMember.name)}
|
||||||
|
after={
|
||||||
|
<Text size="T200" priority="300" truncate>
|
||||||
|
{roomMember.userId}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
before={
|
||||||
|
<Avatar size="200">
|
||||||
|
{avatarUrl ? (
|
||||||
|
<AvatarImage src={avatarUrl} alt={roomMember.userId} />
|
||||||
|
) : (
|
||||||
|
<AvatarFallback
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.Secondary.Container,
|
||||||
|
color: color.Secondary.OnContainer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="H6">{roomMember.name[0] || roomMember.userId[1]}</Text>
|
||||||
|
</AvatarFallback>
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||||
|
{roomMember.name}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</AutocompleteMenu>
|
||||||
|
);
|
||||||
|
}
|
46
src/app/components/editor/autocomplete/autocompleteQuery.ts
Normal file
46
src/app/components/editor/autocomplete/autocompleteQuery.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import { BaseRange, Editor } from 'slate';
|
||||||
|
|
||||||
|
export enum AutocompletePrefix {
|
||||||
|
RoomMention = '#',
|
||||||
|
UserMention = '@',
|
||||||
|
Emoticon = ':',
|
||||||
|
}
|
||||||
|
export const AUTOCOMPLETE_PREFIXES: readonly AutocompletePrefix[] = [
|
||||||
|
AutocompletePrefix.RoomMention,
|
||||||
|
AutocompletePrefix.UserMention,
|
||||||
|
AutocompletePrefix.Emoticon,
|
||||||
|
];
|
||||||
|
|
||||||
|
export type AutocompleteQuery<TPrefix extends string> = {
|
||||||
|
range: BaseRange;
|
||||||
|
prefix: TPrefix;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAutocompletePrefix = <TPrefix extends string>(
|
||||||
|
editor: Editor,
|
||||||
|
queryRange: BaseRange,
|
||||||
|
validPrefixes: readonly TPrefix[]
|
||||||
|
): TPrefix | undefined => {
|
||||||
|
const world = Editor.string(editor, queryRange);
|
||||||
|
const prefix = world[0] as TPrefix | undefined;
|
||||||
|
if (!prefix) return undefined;
|
||||||
|
return validPrefixes.includes(prefix) ? prefix : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAutocompleteQueryText = (editor: Editor, queryRange: BaseRange): string =>
|
||||||
|
Editor.string(editor, queryRange).slice(1);
|
||||||
|
|
||||||
|
export const getAutocompleteQuery = <TPrefix extends string>(
|
||||||
|
editor: Editor,
|
||||||
|
queryRange: BaseRange,
|
||||||
|
validPrefixes: readonly TPrefix[]
|
||||||
|
): AutocompleteQuery<TPrefix> | undefined => {
|
||||||
|
const prefix = getAutocompletePrefix(editor, queryRange, validPrefixes);
|
||||||
|
if (!prefix) return undefined;
|
||||||
|
return {
|
||||||
|
range: queryRange,
|
||||||
|
prefix,
|
||||||
|
text: getAutocompleteQueryText(editor, queryRange),
|
||||||
|
};
|
||||||
|
};
|
5
src/app/components/editor/autocomplete/index.ts
Normal file
5
src/app/components/editor/autocomplete/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export * from './AutocompleteMenu';
|
||||||
|
export * from './autocompleteQuery';
|
||||||
|
export * from './RoomMentionAutocomplete';
|
||||||
|
export * from './UserMentionAutocomplete';
|
||||||
|
export * from './EmoticonAutocomplete';
|
194
src/app/components/editor/common.ts
Normal file
194
src/app/components/editor/common.ts
Normal file
|
@ -0,0 +1,194 @@
|
||||||
|
import { BasePoint, BaseRange, Editor, Element, Point, Range, Transforms } from 'slate';
|
||||||
|
import { BlockType, MarkType } from './Elements';
|
||||||
|
import { EmoticonElement, FormattedText, HeadingLevel, LinkElement, MentionElement } from './slate';
|
||||||
|
|
||||||
|
export const isMarkActive = (editor: Editor, format: MarkType) => {
|
||||||
|
const marks = Editor.marks(editor);
|
||||||
|
return marks ? marks[format] === true : false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleMark = (editor: Editor, format: MarkType) => {
|
||||||
|
const isActive = isMarkActive(editor, format);
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
Editor.removeMark(editor, format);
|
||||||
|
} else {
|
||||||
|
Editor.addMark(editor, format, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isBlockActive = (editor: Editor, format: BlockType) => {
|
||||||
|
const [match] = Editor.nodes(editor, {
|
||||||
|
match: (node) => Element.isElement(node) && node.type === format,
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!match;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BlockOption = { level: HeadingLevel };
|
||||||
|
const NESTED_BLOCK = [
|
||||||
|
BlockType.OrderedList,
|
||||||
|
BlockType.UnorderedList,
|
||||||
|
BlockType.BlockQuote,
|
||||||
|
BlockType.CodeBlock,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const toggleBlock = (editor: Editor, format: BlockType, option?: BlockOption) => {
|
||||||
|
const isActive = isBlockActive(editor, format);
|
||||||
|
|
||||||
|
Transforms.unwrapNodes(editor, {
|
||||||
|
match: (node) => Element.isElement(node) && NESTED_BLOCK.includes(node.type),
|
||||||
|
split: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
Transforms.setNodes(editor, {
|
||||||
|
type: BlockType.Paragraph,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === BlockType.OrderedList || format === BlockType.UnorderedList) {
|
||||||
|
Transforms.setNodes(editor, {
|
||||||
|
type: BlockType.ListItem,
|
||||||
|
});
|
||||||
|
const block = {
|
||||||
|
type: format,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
Transforms.wrapNodes(editor, block);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (format === BlockType.CodeBlock) {
|
||||||
|
Transforms.setNodes(editor, {
|
||||||
|
type: BlockType.CodeLine,
|
||||||
|
});
|
||||||
|
const block = {
|
||||||
|
type: format,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
Transforms.wrapNodes(editor, block);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === BlockType.BlockQuote) {
|
||||||
|
Transforms.setNodes(editor, {
|
||||||
|
type: BlockType.QuoteLine,
|
||||||
|
});
|
||||||
|
const block = {
|
||||||
|
type: format,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
Transforms.wrapNodes(editor, block);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === BlockType.Heading) {
|
||||||
|
Transforms.setNodes(editor, {
|
||||||
|
type: format,
|
||||||
|
level: option?.level ?? 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Transforms.setNodes(editor, {
|
||||||
|
type: format,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetEditor = (editor: Editor) => {
|
||||||
|
Transforms.delete(editor, {
|
||||||
|
at: {
|
||||||
|
anchor: Editor.start(editor, []),
|
||||||
|
focus: Editor.end(editor, []),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toggleBlock(editor, BlockType.Paragraph);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMentionElement = (
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
highlight: boolean
|
||||||
|
): MentionElement => ({
|
||||||
|
type: BlockType.Mention,
|
||||||
|
id,
|
||||||
|
highlight,
|
||||||
|
name,
|
||||||
|
children: [{ text: '' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createEmoticonElement = (key: string, shortcode: string): EmoticonElement => ({
|
||||||
|
type: BlockType.Emoticon,
|
||||||
|
key,
|
||||||
|
shortcode,
|
||||||
|
children: [{ text: '' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createLinkElement = (
|
||||||
|
href: string,
|
||||||
|
children: string | FormattedText[]
|
||||||
|
): LinkElement => ({
|
||||||
|
type: BlockType.Link,
|
||||||
|
href,
|
||||||
|
children: typeof children === 'string' ? [{ text: children }] : children,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
|
||||||
|
Transforms.select(editor, selectRange);
|
||||||
|
Transforms.insertNodes(editor, element);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const moveCursor = (editor: Editor, withSpace?: boolean) => {
|
||||||
|
// without timeout it works properly when we select autocomplete with Tab or Space
|
||||||
|
setTimeout(() => {
|
||||||
|
Transforms.move(editor);
|
||||||
|
if (withSpace) editor.insertText(' ');
|
||||||
|
}, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PointUntilCharOptions {
|
||||||
|
match: (char: string) => boolean;
|
||||||
|
reverse?: boolean;
|
||||||
|
}
|
||||||
|
export const getPointUntilChar = (
|
||||||
|
editor: Editor,
|
||||||
|
cursorPoint: BasePoint,
|
||||||
|
options: PointUntilCharOptions
|
||||||
|
): BasePoint | undefined => {
|
||||||
|
let targetPoint: BasePoint | undefined;
|
||||||
|
let prevPoint: BasePoint | undefined;
|
||||||
|
let char: string | undefined;
|
||||||
|
|
||||||
|
const pointItr = Editor.positions(editor, {
|
||||||
|
at: {
|
||||||
|
anchor: Editor.start(editor, []),
|
||||||
|
focus: Editor.point(editor, cursorPoint, { edge: 'start' }),
|
||||||
|
},
|
||||||
|
unit: 'character',
|
||||||
|
reverse: options.reverse,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for (const point of pointItr) {
|
||||||
|
if (!Point.equals(point, cursorPoint) && prevPoint) {
|
||||||
|
char = Editor.string(editor, { anchor: point, focus: prevPoint });
|
||||||
|
|
||||||
|
if (options.match(char)) break;
|
||||||
|
targetPoint = point;
|
||||||
|
}
|
||||||
|
prevPoint = point;
|
||||||
|
}
|
||||||
|
return targetPoint;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => {
|
||||||
|
const { selection } = editor;
|
||||||
|
if (!selection || !Range.isCollapsed(selection)) return undefined;
|
||||||
|
const [cursorPoint] = Range.edges(selection);
|
||||||
|
const worldStartPoint = getPointUntilChar(editor, cursorPoint, {
|
||||||
|
reverse: true,
|
||||||
|
match: (char) => char === ' ',
|
||||||
|
});
|
||||||
|
return worldStartPoint && Editor.range(editor, worldStartPoint, cursorPoint);
|
||||||
|
};
|
7
src/app/components/editor/index.ts
Normal file
7
src/app/components/editor/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export * from './autocomplete';
|
||||||
|
export * from './common';
|
||||||
|
export * from './Editor';
|
||||||
|
export * from './Elements';
|
||||||
|
export * from './keyboard';
|
||||||
|
export * from './output';
|
||||||
|
export * from './Toolbar';
|
40
src/app/components/editor/keyboard.ts
Normal file
40
src/app/components/editor/keyboard.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import { isHotkey } from 'is-hotkey';
|
||||||
|
import { KeyboardEvent } from 'react';
|
||||||
|
import { Editor } from 'slate';
|
||||||
|
import { isBlockActive, toggleBlock, toggleMark } from './common';
|
||||||
|
import { BlockType, MarkType } from './Elements';
|
||||||
|
|
||||||
|
export const INLINE_HOTKEYS: Record<string, MarkType> = {
|
||||||
|
'mod+b': MarkType.Bold,
|
||||||
|
'mod+i': MarkType.Italic,
|
||||||
|
'mod+u': MarkType.Underline,
|
||||||
|
'mod+shift+u': MarkType.StrikeThrough,
|
||||||
|
'mod+[': MarkType.Code,
|
||||||
|
'mod+h': MarkType.Spoiler,
|
||||||
|
};
|
||||||
|
const INLINE_KEYS = Object.keys(INLINE_HOTKEYS);
|
||||||
|
|
||||||
|
export const BLOCK_HOTKEYS: Record<string, BlockType> = {
|
||||||
|
'mod+shift+0': BlockType.OrderedList,
|
||||||
|
'mod+shift+8': BlockType.UnorderedList,
|
||||||
|
"mod+shift+'": BlockType.BlockQuote,
|
||||||
|
'mod+shift+;': BlockType.CodeBlock,
|
||||||
|
};
|
||||||
|
const BLOCK_KEYS = Object.keys(BLOCK_HOTKEYS);
|
||||||
|
|
||||||
|
export const toggleKeyboardShortcut = (editor: Editor, event: KeyboardEvent<Element>) => {
|
||||||
|
BLOCK_KEYS.forEach((hotkey) => {
|
||||||
|
if (isHotkey(hotkey, event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleBlock(editor, BLOCK_HOTKEYS[hotkey]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isBlockActive(editor, BlockType.CodeBlock))
|
||||||
|
INLINE_KEYS.forEach((hotkey) => {
|
||||||
|
if (isHotkey(hotkey, event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleMark(editor, INLINE_HOTKEYS[hotkey]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
95
src/app/components/editor/output.ts
Normal file
95
src/app/components/editor/output.ts
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import { Descendant, Text } from 'slate';
|
||||||
|
import { sanitizeText } from '../../utils/sanitize';
|
||||||
|
import { BlockType } from './Elements';
|
||||||
|
import { CustomElement, FormattedText } from './slate';
|
||||||
|
|
||||||
|
const textToCustomHtml = (node: FormattedText): string => {
|
||||||
|
let string = sanitizeText(node.text);
|
||||||
|
if (node.bold) string = `<strong>${string}</strong>`;
|
||||||
|
if (node.italic) string = `<i>${string}</i>`;
|
||||||
|
if (node.underline) string = `<u>${string}</u>`;
|
||||||
|
if (node.strikeThrough) string = `<s>${string}</s>`;
|
||||||
|
if (node.code) string = `<code>${string}</code>`;
|
||||||
|
if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
|
||||||
|
return string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const elementToCustomHtml = (node: CustomElement, children: string): string => {
|
||||||
|
switch (node.type) {
|
||||||
|
case BlockType.Paragraph:
|
||||||
|
return `<p>${children}</p>`;
|
||||||
|
case BlockType.Heading:
|
||||||
|
return `<h${node.level}>${children}</h${node.level}>`;
|
||||||
|
case BlockType.CodeLine:
|
||||||
|
return `${children}\n`;
|
||||||
|
case BlockType.CodeBlock:
|
||||||
|
return `<pre><code>${children}</code></pre>`;
|
||||||
|
case BlockType.QuoteLine:
|
||||||
|
return `<p>${children}</p>`;
|
||||||
|
case BlockType.BlockQuote:
|
||||||
|
return `<blockquote>${children}</blockquote>`;
|
||||||
|
case BlockType.ListItem:
|
||||||
|
return `<li><p>${children}</p></li>`;
|
||||||
|
case BlockType.OrderedList:
|
||||||
|
return `<ol>${children}</ol>`;
|
||||||
|
case BlockType.UnorderedList:
|
||||||
|
return `<ul>${children}</ul>`;
|
||||||
|
case BlockType.Mention:
|
||||||
|
return `<a href="https://matrix.to/#/${node.id}">${node.name}</a>`;
|
||||||
|
case BlockType.Emoticon:
|
||||||
|
return node.key.startsWith('mxc://')
|
||||||
|
? `<img data-mx-emoticon src="${node.key}" alt="${node.shortcode}" title="${node.shortcode}" height="32">`
|
||||||
|
: node.key;
|
||||||
|
case BlockType.Link:
|
||||||
|
return `<a href="${node.href}">${node.children}</a>`;
|
||||||
|
default:
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toMatrixCustomHTML = (node: Descendant | Descendant[]): string => {
|
||||||
|
if (Array.isArray(node)) return node.map((n) => toMatrixCustomHTML(n)).join('');
|
||||||
|
if (Text.isText(node)) return textToCustomHtml(node);
|
||||||
|
|
||||||
|
const children = node.children.map((n) => toMatrixCustomHTML(n)).join('');
|
||||||
|
return elementToCustomHtml(node, children);
|
||||||
|
};
|
||||||
|
|
||||||
|
const elementToPlainText = (node: CustomElement, children: string): string => {
|
||||||
|
switch (node.type) {
|
||||||
|
case BlockType.Paragraph:
|
||||||
|
return `${children}\n`;
|
||||||
|
case BlockType.Heading:
|
||||||
|
return `${children}\n`;
|
||||||
|
case BlockType.CodeLine:
|
||||||
|
return `${children}\n`;
|
||||||
|
case BlockType.CodeBlock:
|
||||||
|
return `${children}\n`;
|
||||||
|
case BlockType.QuoteLine:
|
||||||
|
return `| ${children}\n`;
|
||||||
|
case BlockType.BlockQuote:
|
||||||
|
return `${children}\n`;
|
||||||
|
case BlockType.ListItem:
|
||||||
|
return `- ${children}\n`;
|
||||||
|
case BlockType.OrderedList:
|
||||||
|
return `${children}\n`;
|
||||||
|
case BlockType.UnorderedList:
|
||||||
|
return `${children}\n`;
|
||||||
|
case BlockType.Mention:
|
||||||
|
return node.id;
|
||||||
|
case BlockType.Emoticon:
|
||||||
|
return node.key.startsWith('mxc://') ? `:${node.shortcode}:` : node.key;
|
||||||
|
case BlockType.Link:
|
||||||
|
return `[${node.children}](${node.href})`;
|
||||||
|
default:
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toPlainText = (node: Descendant | Descendant[]): string => {
|
||||||
|
if (Array.isArray(node)) return node.map((n) => toPlainText(n)).join('');
|
||||||
|
if (Text.isText(node)) return sanitizeText(node.text);
|
||||||
|
|
||||||
|
const children = node.children.map((n) => toPlainText(n)).join('');
|
||||||
|
return elementToPlainText(node, children);
|
||||||
|
};
|
107
src/app/components/editor/slate.d.ts
vendored
Normal file
107
src/app/components/editor/slate.d.ts
vendored
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
import { BaseEditor } from 'slate';
|
||||||
|
import { ReactEditor } from 'slate-react';
|
||||||
|
import { BlockType } from './Elements';
|
||||||
|
|
||||||
|
export type HeadingLevel = 1 | 2 | 3;
|
||||||
|
|
||||||
|
export type Editor = BaseEditor & ReactEditor;
|
||||||
|
|
||||||
|
export type Text = {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormattedText = Text & {
|
||||||
|
bold?: boolean;
|
||||||
|
italic?: boolean;
|
||||||
|
underline?: boolean;
|
||||||
|
strikeThrough?: boolean;
|
||||||
|
code?: boolean;
|
||||||
|
spoiler?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LinkElement = {
|
||||||
|
type: BlockType.Link;
|
||||||
|
href: string;
|
||||||
|
children: FormattedText[];
|
||||||
|
};
|
||||||
|
export type SpoilerElement = {
|
||||||
|
type: 'spoiler';
|
||||||
|
alert?: string;
|
||||||
|
children: FormattedText[];
|
||||||
|
};
|
||||||
|
export type MentionElement = {
|
||||||
|
type: BlockType.Mention;
|
||||||
|
id: string;
|
||||||
|
highlight: boolean;
|
||||||
|
name: string;
|
||||||
|
children: Text[];
|
||||||
|
};
|
||||||
|
export type EmoticonElement = {
|
||||||
|
type: BlockType.Emoticon;
|
||||||
|
key: string;
|
||||||
|
shortcode: string;
|
||||||
|
children: Text[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParagraphElement = {
|
||||||
|
type: BlockType.Paragraph;
|
||||||
|
children: FormattedText[];
|
||||||
|
};
|
||||||
|
export type HeadingElement = {
|
||||||
|
type: BlockType.Heading;
|
||||||
|
level: HeadingLevel;
|
||||||
|
children: FormattedText[];
|
||||||
|
};
|
||||||
|
export type CodeLineElement = {
|
||||||
|
type: BlockType.CodeLine;
|
||||||
|
children: Text[];
|
||||||
|
};
|
||||||
|
export type CodeBlockElement = {
|
||||||
|
type: BlockType.CodeBlock;
|
||||||
|
children: CodeLineElement[];
|
||||||
|
};
|
||||||
|
export type QuoteLineElement = {
|
||||||
|
type: BlockType.QuoteLine;
|
||||||
|
children: FormattedText[];
|
||||||
|
};
|
||||||
|
export type BlockQuoteElement = {
|
||||||
|
type: BlockType.BlockQuote;
|
||||||
|
children: QuoteLineElement[];
|
||||||
|
};
|
||||||
|
export type ListItemElement = {
|
||||||
|
type: BlockType.ListItem;
|
||||||
|
children: FormattedText[];
|
||||||
|
};
|
||||||
|
export type OrderedListElement = {
|
||||||
|
type: BlockType.OrderedList;
|
||||||
|
children: ListItemElement[];
|
||||||
|
};
|
||||||
|
export type UnorderedListElement = {
|
||||||
|
type: BlockType.UnorderedList;
|
||||||
|
children: ListItemElement[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomElement =
|
||||||
|
| LinkElement
|
||||||
|
// | SpoilerElement
|
||||||
|
| MentionElement
|
||||||
|
| EmoticonElement
|
||||||
|
| ParagraphElement
|
||||||
|
| HeadingElement
|
||||||
|
| CodeLineElement
|
||||||
|
| CodeBlockElement
|
||||||
|
| QuoteLineElement
|
||||||
|
| BlockQuoteElement
|
||||||
|
| ListItemElement
|
||||||
|
| OrderedListElement
|
||||||
|
| UnorderedListElement;
|
||||||
|
|
||||||
|
export type CustomEditor = BaseEditor & ReactEditor;
|
||||||
|
|
||||||
|
declare module 'slate' {
|
||||||
|
interface CustomTypes {
|
||||||
|
Editor: BaseEditor & ReactEditor;
|
||||||
|
Element: CustomElement;
|
||||||
|
Text: FormattedText & Text;
|
||||||
|
}
|
||||||
|
}
|
134
src/app/components/emoji-board/EmojiBoard.css.tsx
Normal file
134
src/app/components/emoji-board/EmojiBoard.css.tsx
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const Base = style({
|
||||||
|
maxWidth: toRem(432),
|
||||||
|
width: `calc(100vw - 2 * ${config.space.S400})`,
|
||||||
|
height: toRem(450),
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
boxShadow: config.shadow.E200,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Sidebar = style({
|
||||||
|
width: toRem(54),
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SidebarContent = style({
|
||||||
|
padding: `${config.space.S200} 0`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SidebarStack = style({
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const NativeEmojiSidebarStack = style({
|
||||||
|
position: 'sticky',
|
||||||
|
bottom: '-67%',
|
||||||
|
zIndex: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SidebarDivider = style({
|
||||||
|
width: toRem(18),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Header = style({
|
||||||
|
padding: config.space.S300,
|
||||||
|
paddingBottom: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EmojiBoardTab = style({
|
||||||
|
cursor: 'pointer',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Footer = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
margin: config.space.S300,
|
||||||
|
marginTop: 0,
|
||||||
|
minHeight: toRem(40),
|
||||||
|
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EmojiGroup = style({
|
||||||
|
padding: `${config.space.S300} 0`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EmojiGroupLabel = style({
|
||||||
|
position: 'sticky',
|
||||||
|
top: config.space.S200,
|
||||||
|
zIndex: 1,
|
||||||
|
|
||||||
|
margin: 'auto',
|
||||||
|
padding: `${config.space.S100} ${config.space.S200}`,
|
||||||
|
borderRadius: config.radii.Pill,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EmojiGroupContent = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
padding: `0 ${config.space.S200}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EmojiPreview = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: toRem(32),
|
||||||
|
height: toRem(32),
|
||||||
|
fontSize: toRem(32),
|
||||||
|
lineHeight: toRem(32),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EmojiItem = style([
|
||||||
|
DefaultReset,
|
||||||
|
FocusOutline,
|
||||||
|
{
|
||||||
|
width: toRem(48),
|
||||||
|
height: toRem(48),
|
||||||
|
fontSize: toRem(32),
|
||||||
|
lineHeight: toRem(32),
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
cursor: 'pointer',
|
||||||
|
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: color.Surface.ContainerHover,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const StickerItem = style([
|
||||||
|
EmojiItem,
|
||||||
|
{
|
||||||
|
width: toRem(112),
|
||||||
|
height: toRem(112),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const CustomEmojiImg = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: toRem(32),
|
||||||
|
height: toRem(32),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const StickerImg = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: toRem(96),
|
||||||
|
height: toRem(96),
|
||||||
|
},
|
||||||
|
]);
|
860
src/app/components/emoji-board/EmojiBoard.tsx
Normal file
860
src/app/components/emoji-board/EmojiBoard.tsx
Normal file
|
@ -0,0 +1,860 @@
|
||||||
|
import React, {
|
||||||
|
ChangeEventHandler,
|
||||||
|
FocusEventHandler,
|
||||||
|
MouseEventHandler,
|
||||||
|
UIEventHandler,
|
||||||
|
ReactNode,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
Line,
|
||||||
|
Scroll,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
as,
|
||||||
|
config,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import isHotkey from 'is-hotkey';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { MatrixClient, Room } from 'matrix-js-sdk';
|
||||||
|
import { atom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
|
|
||||||
|
import * as css from './EmojiBoard.css';
|
||||||
|
import { EmojiGroupId, IEmoji, IEmojiGroup, emojiGroups, emojis } from '../../plugins/emoji';
|
||||||
|
import { IEmojiGroupLabels, useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||||
|
import { IEmojiGroupIcons, useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||||
|
import { preventScrollWithArrowKey } from '../../utils/keyboard';
|
||||||
|
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
||||||
|
import { ExtendedPackImage, ImagePack, PackUsage } from '../../plugins/custom-emoji';
|
||||||
|
import { isUserId } from '../../utils/matrix';
|
||||||
|
import { editableActiveElement, inVisibleScrollArea, targetFromEvent } from '../../utils/dom';
|
||||||
|
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
|
||||||
|
import { useDebounce } from '../../hooks/useDebounce';
|
||||||
|
import { useThrottle } from '../../hooks/useThrottle';
|
||||||
|
|
||||||
|
const RECENT_GROUP_ID = 'recent_group';
|
||||||
|
const SEARCH_GROUP_ID = 'search_group';
|
||||||
|
|
||||||
|
export enum EmojiBoardTab {
|
||||||
|
Emoji = 'Emoji',
|
||||||
|
Sticker = 'Sticker',
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EmojiType {
|
||||||
|
Emoji = 'emoji',
|
||||||
|
CustomEmoji = 'customEmoji',
|
||||||
|
Sticker = 'sticker',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmojiItemInfo = {
|
||||||
|
type: EmojiType;
|
||||||
|
data: string;
|
||||||
|
shortcode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDOMGroupId = (id: string): string => `EmojiBoardGroup-${id}`;
|
||||||
|
|
||||||
|
const getEmojiItemInfo = (element: Element): EmojiItemInfo | undefined => {
|
||||||
|
const type = element.getAttribute('data-emoji-type') as EmojiType | undefined;
|
||||||
|
const data = element.getAttribute('data-emoji-data');
|
||||||
|
const shortcode = element.getAttribute('data-emoji-shortcode');
|
||||||
|
|
||||||
|
if (type && data && shortcode)
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
shortcode,
|
||||||
|
};
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeGroupIdAtom = atom<string | undefined>(undefined);
|
||||||
|
|
||||||
|
function Sidebar({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box className={css.Sidebar} shrink="No">
|
||||||
|
<Scroll size="0">
|
||||||
|
<Box className={css.SidebarContent} direction="Column" alignItems="Center" gap="100">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarStack = as<'div'>(({ className, children, ...props }, ref) => (
|
||||||
|
<Box
|
||||||
|
className={classNames(css.SidebarStack, className)}
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
|
function SidebarDivider() {
|
||||||
|
return <Line className={css.SidebarDivider} size="300" variant="Surface" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box className={css.Header} direction="Column" shrink="No">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Content({ children }: { children: ReactNode }) {
|
||||||
|
return <Box grow="Yes">{children}</Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Footer({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box shrink="No" className={css.Footer} gap="300" alignItems="Center">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmojiBoardLayout = as<
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
header: ReactNode;
|
||||||
|
sidebar?: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
>(({ className, header, sidebar, footer, children, ...props }, ref) => (
|
||||||
|
<Box
|
||||||
|
display="InlineFlex"
|
||||||
|
className={classNames(css.Base, className)}
|
||||||
|
direction="Row"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Box direction="Column" grow="Yes">
|
||||||
|
{header}
|
||||||
|
{children}
|
||||||
|
{footer}
|
||||||
|
</Box>
|
||||||
|
<Line size="300" direction="Vertical" />
|
||||||
|
{sidebar}
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
|
|
||||||
|
function EmojiBoardTabs({
|
||||||
|
tab,
|
||||||
|
onTabChange,
|
||||||
|
}: {
|
||||||
|
tab: EmojiBoardTab;
|
||||||
|
onTabChange: (tab: EmojiBoardTab) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box gap="100">
|
||||||
|
<Badge
|
||||||
|
className={css.EmojiBoardTab}
|
||||||
|
as="button"
|
||||||
|
variant="Secondary"
|
||||||
|
fill={tab === EmojiBoardTab.Emoji ? 'Solid' : 'None'}
|
||||||
|
size="500"
|
||||||
|
onClick={() => onTabChange(EmojiBoardTab.Emoji)}
|
||||||
|
>
|
||||||
|
<Text as="span" size="L400">
|
||||||
|
Emoji
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
className={css.EmojiBoardTab}
|
||||||
|
as="button"
|
||||||
|
variant="Secondary"
|
||||||
|
fill={tab === EmojiBoardTab.Sticker ? 'Solid' : 'None'}
|
||||||
|
size="500"
|
||||||
|
onClick={() => onTabChange(EmojiBoardTab.Sticker)}
|
||||||
|
>
|
||||||
|
<Text as="span" size="L400">
|
||||||
|
Sticker
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarBtn<T extends string>({
|
||||||
|
active,
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
onItemClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
label: string;
|
||||||
|
id: T;
|
||||||
|
onItemClick: (id: T) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
delay={500}
|
||||||
|
position="Left"
|
||||||
|
tooltip={
|
||||||
|
<Tooltip id={`SidebarStackItem-${id}-label`}>
|
||||||
|
<Text size="T300">{label}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(ref) => (
|
||||||
|
<IconButton
|
||||||
|
aria-pressed={active}
|
||||||
|
aria-labelledby={`SidebarStackItem-${id}-label`}
|
||||||
|
ref={ref}
|
||||||
|
onClick={() => onItemClick(id)}
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
variant="Surface"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmojiGroup = as<
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
>(({ className, id, label, children, ...props }, ref) => (
|
||||||
|
<Box
|
||||||
|
id={getDOMGroupId(id)}
|
||||||
|
data-group-id={id}
|
||||||
|
className={classNames(css.EmojiGroup, className)}
|
||||||
|
direction="Column"
|
||||||
|
gap="200"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Text id={`EmojiGroup-${id}-label`} as="label" className={css.EmojiGroupLabel} size="O400">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<div aria-labelledby={`EmojiGroup-${id}-label`} className={css.EmojiGroupContent}>
|
||||||
|
<Box wrap="Wrap" justifyContent="Center">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
|
|
||||||
|
export function EmojiItem({
|
||||||
|
label,
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
shortcode,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
type: EmojiType;
|
||||||
|
data: string;
|
||||||
|
shortcode: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="button"
|
||||||
|
className={css.EmojiItem}
|
||||||
|
type="button"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
title={label}
|
||||||
|
aria-label={`${label} emoji`}
|
||||||
|
data-emoji-type={type}
|
||||||
|
data-emoji-data={data}
|
||||||
|
data-emoji-shortcode={shortcode}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StickerItem({
|
||||||
|
label,
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
shortcode,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
type: EmojiType;
|
||||||
|
data: string;
|
||||||
|
shortcode: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="button"
|
||||||
|
className={css.StickerItem}
|
||||||
|
type="button"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
title={label}
|
||||||
|
aria-label={`${label} sticker`}
|
||||||
|
data-emoji-type={type}
|
||||||
|
data-emoji-data={data}
|
||||||
|
data-emoji-shortcode={shortcode}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentEmojiSidebarStack({ onItemClick }: { onItemClick: (id: string) => void }) {
|
||||||
|
const activeGroupId = useAtomValue(activeGroupIdAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarStack>
|
||||||
|
<SidebarBtn
|
||||||
|
active={activeGroupId === RECENT_GROUP_ID}
|
||||||
|
id={RECENT_GROUP_ID}
|
||||||
|
label="Recent"
|
||||||
|
onItemClick={() => onItemClick(RECENT_GROUP_ID)}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.RecentClock} filled={activeGroupId === RECENT_GROUP_ID} />
|
||||||
|
</SidebarBtn>
|
||||||
|
</SidebarStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImagePackSidebarStack({
|
||||||
|
mx,
|
||||||
|
packs,
|
||||||
|
usage,
|
||||||
|
onItemClick,
|
||||||
|
}: {
|
||||||
|
mx: MatrixClient;
|
||||||
|
packs: ImagePack[];
|
||||||
|
usage: PackUsage;
|
||||||
|
onItemClick: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const activeGroupId = useAtomValue(activeGroupIdAtom);
|
||||||
|
return (
|
||||||
|
<SidebarStack>
|
||||||
|
{usage === PackUsage.Emoticon && <SidebarDivider />}
|
||||||
|
{packs.map((pack) => {
|
||||||
|
let label = pack.displayName;
|
||||||
|
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
||||||
|
return (
|
||||||
|
<SidebarBtn
|
||||||
|
active={activeGroupId === pack.id}
|
||||||
|
key={pack.id}
|
||||||
|
id={pack.id}
|
||||||
|
label={label || 'Unknown Pack'}
|
||||||
|
onItemClick={onItemClick}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
style={{
|
||||||
|
width: toRem(24),
|
||||||
|
height: toRem(24),
|
||||||
|
}}
|
||||||
|
src={mx.mxcUrlToHttp(pack.getPackAvatarUrl(usage) ?? '') || pack.avatarUrl}
|
||||||
|
alt={label || 'Unknown Pack'}
|
||||||
|
/>
|
||||||
|
</SidebarBtn>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SidebarStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeEmojiSidebarStack({
|
||||||
|
groups,
|
||||||
|
icons,
|
||||||
|
labels,
|
||||||
|
onItemClick,
|
||||||
|
}: {
|
||||||
|
groups: IEmojiGroup[];
|
||||||
|
icons: IEmojiGroupIcons;
|
||||||
|
labels: IEmojiGroupLabels;
|
||||||
|
onItemClick: (id: EmojiGroupId) => void;
|
||||||
|
}) {
|
||||||
|
const activeGroupId = useAtomValue(activeGroupIdAtom);
|
||||||
|
return (
|
||||||
|
<SidebarStack className={css.NativeEmojiSidebarStack}>
|
||||||
|
<SidebarDivider />
|
||||||
|
{groups.map((group) => (
|
||||||
|
<SidebarBtn
|
||||||
|
key={group.id}
|
||||||
|
active={activeGroupId === group.id}
|
||||||
|
id={group.id}
|
||||||
|
label={labels[group.id]}
|
||||||
|
onItemClick={onItemClick}
|
||||||
|
>
|
||||||
|
<Icon src={icons[group.id]} filled={activeGroupId === group.id} />
|
||||||
|
</SidebarBtn>
|
||||||
|
))}
|
||||||
|
</SidebarStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentEmojiGroup({
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
emojis: recentEmojis,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
emojis: IEmoji[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<EmojiGroup key={id} id={id} label={label}>
|
||||||
|
{recentEmojis.map((emoji) => (
|
||||||
|
<EmojiItem
|
||||||
|
key={emoji.unicode}
|
||||||
|
label={emoji.label}
|
||||||
|
type={EmojiType.Emoji}
|
||||||
|
data={emoji.unicode}
|
||||||
|
shortcode={emoji.shortcode}
|
||||||
|
>
|
||||||
|
{emoji.unicode}
|
||||||
|
</EmojiItem>
|
||||||
|
))}
|
||||||
|
</EmojiGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchEmojiGroup({
|
||||||
|
mx,
|
||||||
|
tab,
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
emojis: searchResult,
|
||||||
|
}: {
|
||||||
|
mx: MatrixClient;
|
||||||
|
tab: EmojiBoardTab;
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
emojis: Array<ExtendedPackImage | IEmoji>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<EmojiGroup key={id} id={id} label={label}>
|
||||||
|
{tab === EmojiBoardTab.Emoji
|
||||||
|
? searchResult.map((emoji) =>
|
||||||
|
'unicode' in emoji ? (
|
||||||
|
<EmojiItem
|
||||||
|
key={emoji.unicode}
|
||||||
|
label={emoji.label}
|
||||||
|
type={EmojiType.Emoji}
|
||||||
|
data={emoji.unicode}
|
||||||
|
shortcode={emoji.shortcode}
|
||||||
|
>
|
||||||
|
{emoji.unicode}
|
||||||
|
</EmojiItem>
|
||||||
|
) : (
|
||||||
|
<EmojiItem
|
||||||
|
key={emoji.shortcode}
|
||||||
|
label={emoji.body || emoji.shortcode}
|
||||||
|
type={EmojiType.CustomEmoji}
|
||||||
|
data={emoji.url}
|
||||||
|
shortcode={emoji.shortcode}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
className={css.CustomEmojiImg}
|
||||||
|
alt={emoji.body || emoji.shortcode}
|
||||||
|
src={mx.mxcUrlToHttp(emoji.url) ?? emoji.url}
|
||||||
|
/>
|
||||||
|
</EmojiItem>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: searchResult.map((emoji) =>
|
||||||
|
'unicode' in emoji ? null : (
|
||||||
|
<StickerItem
|
||||||
|
key={emoji.shortcode}
|
||||||
|
label={emoji.body || emoji.shortcode}
|
||||||
|
type={EmojiType.Sticker}
|
||||||
|
data={emoji.url}
|
||||||
|
shortcode={emoji.shortcode}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
className={css.StickerImg}
|
||||||
|
alt={emoji.body || emoji.shortcode}
|
||||||
|
src={mx.mxcUrlToHttp(emoji.url) ?? emoji.url}
|
||||||
|
/>
|
||||||
|
</StickerItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</EmojiGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomEmojiGroups = memo(
|
||||||
|
({ mx, groups }: { mx: MatrixClient; groups: ImagePack[] }) => (
|
||||||
|
<>
|
||||||
|
{groups.map((pack) => (
|
||||||
|
<EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
|
||||||
|
{pack.getEmojis().map((image) => (
|
||||||
|
<EmojiItem
|
||||||
|
key={image.shortcode}
|
||||||
|
label={image.body || image.shortcode}
|
||||||
|
type={EmojiType.CustomEmoji}
|
||||||
|
data={image.url}
|
||||||
|
shortcode={image.shortcode}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
className={css.CustomEmojiImg}
|
||||||
|
alt={image.body || image.shortcode}
|
||||||
|
src={mx.mxcUrlToHttp(image.url) ?? image.url}
|
||||||
|
/>
|
||||||
|
</EmojiItem>
|
||||||
|
))}
|
||||||
|
</EmojiGroup>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const StickerGroups = memo(({ mx, groups }: { mx: MatrixClient; groups: ImagePack[] }) => (
|
||||||
|
<>
|
||||||
|
{groups.length === 0 && (
|
||||||
|
<Box
|
||||||
|
style={{ padding: `${toRem(60)} ${config.space.S500}` }}
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
direction="Column"
|
||||||
|
gap="300"
|
||||||
|
>
|
||||||
|
<Icon size="600" src={Icons.Sticker} />
|
||||||
|
<Box direction="Inherit">
|
||||||
|
<Text align="Center">No Sticker Packs!</Text>
|
||||||
|
<Text priority="300" align="Center" size="T200">
|
||||||
|
Add stickers from user, room or space settings.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{groups.map((pack) => (
|
||||||
|
<EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
|
||||||
|
{pack.getStickers().map((image) => (
|
||||||
|
<StickerItem
|
||||||
|
key={image.shortcode}
|
||||||
|
label={image.body || image.shortcode}
|
||||||
|
type={EmojiType.Sticker}
|
||||||
|
data={image.url}
|
||||||
|
shortcode={image.shortcode}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
className={css.StickerImg}
|
||||||
|
alt={image.body || image.shortcode}
|
||||||
|
src={mx.mxcUrlToHttp(image.url) ?? image.url}
|
||||||
|
/>
|
||||||
|
</StickerItem>
|
||||||
|
))}
|
||||||
|
</EmojiGroup>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
));
|
||||||
|
|
||||||
|
export const NativeEmojiGroups = memo(
|
||||||
|
({ groups, labels }: { groups: IEmojiGroup[]; labels: IEmojiGroupLabels }) => (
|
||||||
|
<>
|
||||||
|
{groups.map((emojiGroup) => (
|
||||||
|
<EmojiGroup key={emojiGroup.id} id={emojiGroup.id} label={labels[emojiGroup.id]}>
|
||||||
|
{emojiGroup.emojis.map((emoji) => (
|
||||||
|
<EmojiItem
|
||||||
|
key={emoji.unicode}
|
||||||
|
label={emoji.label}
|
||||||
|
type={EmojiType.Emoji}
|
||||||
|
data={emoji.unicode}
|
||||||
|
shortcode={emoji.shortcode}
|
||||||
|
>
|
||||||
|
{emoji.unicode}
|
||||||
|
</EmojiItem>
|
||||||
|
))}
|
||||||
|
</EmojiGroup>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSearchListItemStr = (item: ExtendedPackImage | IEmoji) => `:${item.shortcode}:`;
|
||||||
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
|
limit: 26,
|
||||||
|
matchOptions: {
|
||||||
|
contain: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmojiBoard({
|
||||||
|
tab = EmojiBoardTab.Emoji,
|
||||||
|
onTabChange,
|
||||||
|
imagePackRooms,
|
||||||
|
requestClose,
|
||||||
|
returnFocusOnDeactivate,
|
||||||
|
onEmojiSelect,
|
||||||
|
onCustomEmojiSelect,
|
||||||
|
onStickerSelect,
|
||||||
|
}: {
|
||||||
|
tab?: EmojiBoardTab;
|
||||||
|
onTabChange?: (tab: EmojiBoardTab) => void;
|
||||||
|
imagePackRooms: Room[];
|
||||||
|
requestClose: () => void;
|
||||||
|
returnFocusOnDeactivate?: boolean;
|
||||||
|
onEmojiSelect?: (unicode: string, shortcode: string) => void;
|
||||||
|
onCustomEmojiSelect?: (mxc: string, shortcode: string) => void;
|
||||||
|
onStickerSelect?: (mxc: string, shortcode: string) => void;
|
||||||
|
}) {
|
||||||
|
const emojiTab = tab === EmojiBoardTab.Emoji;
|
||||||
|
const stickerTab = tab === EmojiBoardTab.Sticker;
|
||||||
|
const usage = emojiTab ? PackUsage.Emoticon : PackUsage.Sticker;
|
||||||
|
|
||||||
|
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const emojiGroupLabels = useEmojiGroupLabels();
|
||||||
|
const emojiGroupIcons = useEmojiGroupIcons();
|
||||||
|
const imagePacks = useRelevantImagePacks(mx, usage, imagePackRooms);
|
||||||
|
const recentEmojis = useRecentEmoji(mx, 21);
|
||||||
|
|
||||||
|
const contentScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const emojiPreviewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const emojiPreviewTextRef = useRef<HTMLParagraphElement>(null);
|
||||||
|
|
||||||
|
const searchList = useMemo(() => {
|
||||||
|
let list: Array<ExtendedPackImage | IEmoji> = [];
|
||||||
|
list = list.concat(imagePacks.flatMap((pack) => pack.getImagesFor(usage)));
|
||||||
|
if (emojiTab) list = list.concat(emojis);
|
||||||
|
return list;
|
||||||
|
}, [emojiTab, usage, imagePacks]);
|
||||||
|
|
||||||
|
const [result, search] = useAsyncSearch(searchList, getSearchListItemStr, SEARCH_OPTIONS);
|
||||||
|
|
||||||
|
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
|
||||||
|
useCallback(
|
||||||
|
(evt) => {
|
||||||
|
const term = evt.target.value;
|
||||||
|
search(term);
|
||||||
|
},
|
||||||
|
[search]
|
||||||
|
),
|
||||||
|
{ wait: 200 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncActiveGroupId = useCallback(() => {
|
||||||
|
const targetEl = contentScrollRef.current;
|
||||||
|
if (!targetEl) return;
|
||||||
|
const groupEls = [...targetEl.querySelectorAll('div[data-group-id]')] as HTMLElement[];
|
||||||
|
const groupEl = groupEls.find((el) => inVisibleScrollArea(targetEl, el));
|
||||||
|
const groupId = groupEl?.getAttribute('data-group-id') ?? undefined;
|
||||||
|
setActiveGroupId(groupId);
|
||||||
|
}, [setActiveGroupId]);
|
||||||
|
|
||||||
|
const handleOnScroll: UIEventHandler<HTMLDivElement> = useThrottle(syncActiveGroupId, {
|
||||||
|
wait: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleScrollToGroup = (groupId: string) => {
|
||||||
|
setActiveGroupId(groupId);
|
||||||
|
const groupElement = document.getElementById(getDOMGroupId(groupId));
|
||||||
|
groupElement?.scrollIntoView();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmojiClick: MouseEventHandler = (evt) => {
|
||||||
|
const targetEl = targetFromEvent(evt.nativeEvent, 'button');
|
||||||
|
if (!targetEl) return;
|
||||||
|
const emojiInfo = getEmojiItemInfo(targetEl);
|
||||||
|
if (!emojiInfo) return;
|
||||||
|
if (emojiInfo.type === EmojiType.Emoji) {
|
||||||
|
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||||
|
if (!evt.altKey && !evt.shiftKey) requestClose();
|
||||||
|
}
|
||||||
|
if (emojiInfo.type === EmojiType.CustomEmoji) {
|
||||||
|
onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||||
|
if (!evt.altKey && !evt.shiftKey) requestClose();
|
||||||
|
}
|
||||||
|
if (emojiInfo.type === EmojiType.Sticker) {
|
||||||
|
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||||
|
if (!evt.altKey && !evt.shiftKey) requestClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmojiPreview = useCallback(
|
||||||
|
(element: HTMLButtonElement) => {
|
||||||
|
const emojiInfo = getEmojiItemInfo(element);
|
||||||
|
if (!emojiInfo || !emojiPreviewTextRef.current) return;
|
||||||
|
if (emojiInfo.type === EmojiType.Emoji && emojiPreviewRef.current) {
|
||||||
|
emojiPreviewRef.current.textContent = emojiInfo.data;
|
||||||
|
} else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = css.CustomEmojiImg;
|
||||||
|
img.setAttribute('src', mx.mxcUrlToHttp(emojiInfo.data) || emojiInfo.data);
|
||||||
|
img.setAttribute('alt', emojiInfo.shortcode);
|
||||||
|
emojiPreviewRef.current.textContent = '';
|
||||||
|
emojiPreviewRef.current.appendChild(img);
|
||||||
|
}
|
||||||
|
emojiPreviewTextRef.current.textContent = `:${emojiInfo.shortcode}:`;
|
||||||
|
},
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
|
const throttleEmojiHover = useThrottle(handleEmojiPreview, {
|
||||||
|
wait: 200,
|
||||||
|
immediate: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEmojiHover: MouseEventHandler = (evt) => {
|
||||||
|
const targetEl = targetFromEvent(evt.nativeEvent, 'button') as HTMLButtonElement | undefined;
|
||||||
|
if (!targetEl) return;
|
||||||
|
throttleEmojiHover(targetEl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmojiFocus: FocusEventHandler = (evt) => {
|
||||||
|
const targetEl = evt.target as HTMLButtonElement;
|
||||||
|
handleEmojiPreview(targetEl);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset scroll top on search and tab change
|
||||||
|
useEffect(() => {
|
||||||
|
syncActiveGroupId();
|
||||||
|
contentScrollRef.current?.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
});
|
||||||
|
}, [result, emojiTab, syncActiveGroupId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
returnFocusOnDeactivate,
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: requestClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
allowOutsideClick: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
!editableActiveElement() && isHotkey(['arrowdown', 'arrowright'], evt),
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) =>
|
||||||
|
!editableActiveElement() && isHotkey(['arrowup', 'arrowleft'], evt),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EmojiBoardLayout
|
||||||
|
header={
|
||||||
|
<Header>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
{onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />}
|
||||||
|
<Input
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="400"
|
||||||
|
placeholder="Search"
|
||||||
|
maxLength={50}
|
||||||
|
after={<Icon src={Icons.Search} size="50" />}
|
||||||
|
onChange={handleOnChange}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
}
|
||||||
|
sidebar={
|
||||||
|
<Sidebar>
|
||||||
|
{emojiTab && recentEmojis.length > 0 && (
|
||||||
|
<RecentEmojiSidebarStack onItemClick={handleScrollToGroup} />
|
||||||
|
)}
|
||||||
|
{imagePacks.length > 0 && (
|
||||||
|
<ImagePackSidebarStack
|
||||||
|
mx={mx}
|
||||||
|
usage={usage}
|
||||||
|
packs={imagePacks}
|
||||||
|
onItemClick={handleScrollToGroup}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{emojiTab && (
|
||||||
|
<NativeEmojiSidebarStack
|
||||||
|
groups={emojiGroups}
|
||||||
|
icons={emojiGroupIcons}
|
||||||
|
labels={emojiGroupLabels}
|
||||||
|
onItemClick={handleScrollToGroup}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Sidebar>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
emojiTab ? (
|
||||||
|
<Footer>
|
||||||
|
<Box
|
||||||
|
display="InlineFlex"
|
||||||
|
ref={emojiPreviewRef}
|
||||||
|
className={css.EmojiPreview}
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
>
|
||||||
|
😃
|
||||||
|
</Box>
|
||||||
|
<Text ref={emojiPreviewTextRef} size="H5" truncate>
|
||||||
|
:smiley:
|
||||||
|
</Text>
|
||||||
|
</Footer>
|
||||||
|
) : (
|
||||||
|
imagePacks.length > 0 && (
|
||||||
|
<Footer>
|
||||||
|
<Text ref={emojiPreviewTextRef} size="H5" truncate>
|
||||||
|
:smiley:
|
||||||
|
</Text>
|
||||||
|
</Footer>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Content>
|
||||||
|
<Scroll
|
||||||
|
ref={contentScrollRef}
|
||||||
|
size="400"
|
||||||
|
onScroll={handleOnScroll}
|
||||||
|
onKeyDown={preventScrollWithArrowKey}
|
||||||
|
hideTrack
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
onClick={handleEmojiClick}
|
||||||
|
onMouseMove={handleEmojiHover}
|
||||||
|
onFocus={handleEmojiFocus}
|
||||||
|
direction="Column"
|
||||||
|
gap="200"
|
||||||
|
>
|
||||||
|
{result && (
|
||||||
|
<SearchEmojiGroup
|
||||||
|
mx={mx}
|
||||||
|
tab={tab}
|
||||||
|
id={SEARCH_GROUP_ID}
|
||||||
|
label={result.items.length ? 'Search Results' : 'No Results found'}
|
||||||
|
emojis={result.items}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{emojiTab && recentEmojis.length > 0 && (
|
||||||
|
<RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} />
|
||||||
|
)}
|
||||||
|
{emojiTab && <CustomEmojiGroups mx={mx} groups={imagePacks} />}
|
||||||
|
{stickerTab && <StickerGroups mx={mx} groups={imagePacks} />}
|
||||||
|
{emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Content>
|
||||||
|
</EmojiBoardLayout>
|
||||||
|
</FocusTrap>
|
||||||
|
);
|
||||||
|
}
|
1
src/app/components/emoji-board/index.ts
Normal file
1
src/app/components/emoji-board/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './EmojiBoard';
|
21
src/app/components/emoji-board/useEmojiGroupIcons.ts
Normal file
21
src/app/components/emoji-board/useEmojiGroupIcons.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { IconSrc, Icons } from 'folds';
|
||||||
|
|
||||||
|
import { EmojiGroupId } from '../../plugins/emoji';
|
||||||
|
|
||||||
|
export type IEmojiGroupIcons = Record<EmojiGroupId, IconSrc>;
|
||||||
|
|
||||||
|
export const useEmojiGroupIcons = (): IEmojiGroupIcons =>
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
[EmojiGroupId.People]: Icons.Smile,
|
||||||
|
[EmojiGroupId.Nature]: Icons.Leaf,
|
||||||
|
[EmojiGroupId.Food]: Icons.Cup,
|
||||||
|
[EmojiGroupId.Activity]: Icons.Ball,
|
||||||
|
[EmojiGroupId.Travel]: Icons.Photo,
|
||||||
|
[EmojiGroupId.Object]: Icons.Bulb,
|
||||||
|
[EmojiGroupId.Symbol]: Icons.Peace,
|
||||||
|
[EmojiGroupId.Flag]: Icons.Flag,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
19
src/app/components/emoji-board/useEmojiGroupLabels.ts
Normal file
19
src/app/components/emoji-board/useEmojiGroupLabels.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { EmojiGroupId } from '../../plugins/emoji';
|
||||||
|
|
||||||
|
export type IEmojiGroupLabels = Record<EmojiGroupId, string>;
|
||||||
|
|
||||||
|
export const useEmojiGroupLabels = (): IEmojiGroupLabels =>
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
[EmojiGroupId.People]: 'Smileys & People',
|
||||||
|
[EmojiGroupId.Nature]: 'Animals & Nature',
|
||||||
|
[EmojiGroupId.Food]: 'Food & Drinks',
|
||||||
|
[EmojiGroupId.Activity]: 'Activity',
|
||||||
|
[EmojiGroupId.Travel]: 'Travel & Places',
|
||||||
|
[EmojiGroupId.Object]: 'Objects',
|
||||||
|
[EmojiGroupId.Symbol]: 'Symbols',
|
||||||
|
[EmojiGroupId.Flag]: 'Flags',
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
111
src/app/components/sidebar/Sidebar.css.ts
Normal file
111
src/app/components/sidebar/Sidebar.css.ts
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
||||||
|
import { color, config, DefaultReset, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const Sidebar = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: toRem(66),
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
|
||||||
|
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
color: color.Background.OnContainer,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const SidebarStack = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S300,
|
||||||
|
padding: `${config.space.S300} 0`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PUSH_X = 2;
|
||||||
|
export const SidebarAvatarBox = recipe({
|
||||||
|
base: [
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
transform: `translateX(${toRem(PUSH_X)})`,
|
||||||
|
},
|
||||||
|
'&::before': {
|
||||||
|
content: '',
|
||||||
|
display: 'none',
|
||||||
|
position: 'absolute',
|
||||||
|
left: toRem(-11.5 - PUSH_X),
|
||||||
|
width: toRem(3 + PUSH_X),
|
||||||
|
height: toRem(16),
|
||||||
|
borderRadius: `0 ${toRem(4)} ${toRem(4)} 0`,
|
||||||
|
background: 'CurrentColor',
|
||||||
|
transition: 'height 200ms linear',
|
||||||
|
},
|
||||||
|
'&:hover::before': {
|
||||||
|
display: 'block',
|
||||||
|
width: toRem(3),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variants: {
|
||||||
|
active: {
|
||||||
|
true: {
|
||||||
|
selectors: {
|
||||||
|
'&::before': {
|
||||||
|
display: 'block',
|
||||||
|
height: toRem(24),
|
||||||
|
},
|
||||||
|
'&:hover::before': {
|
||||||
|
width: toRem(3 + PUSH_X),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SidebarAvatarBoxVariants = RecipeVariants<typeof SidebarAvatarBox>;
|
||||||
|
|
||||||
|
export const SidebarBadgeBox = recipe({
|
||||||
|
base: [
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variants: {
|
||||||
|
hasCount: {
|
||||||
|
true: {
|
||||||
|
top: toRem(-6),
|
||||||
|
right: toRem(-6),
|
||||||
|
},
|
||||||
|
false: {
|
||||||
|
top: toRem(-2),
|
||||||
|
right: toRem(-2),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
hasCount: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SidebarBadgeBoxVariants = RecipeVariants<typeof SidebarBadgeBox>;
|
||||||
|
|
||||||
|
export const SidebarBadgeOutline = style({
|
||||||
|
boxShadow: `0 0 0 ${config.borderWidth.B500} ${color.Background.Container}`,
|
||||||
|
});
|
8
src/app/components/sidebar/Sidebar.tsx
Normal file
8
src/app/components/sidebar/Sidebar.tsx
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { as } from 'folds';
|
||||||
|
import React from 'react';
|
||||||
|
import * as css from './Sidebar.css';
|
||||||
|
|
||||||
|
export const Sidebar = as<'div'>(({ as: AsSidebar = 'div', className, ...props }, ref) => (
|
||||||
|
<AsSidebar className={classNames(css.Sidebar, className)} {...props} ref={ref} />
|
||||||
|
));
|
75
src/app/components/sidebar/SidebarAvatar.tsx
Normal file
75
src/app/components/sidebar/SidebarAvatar.tsx
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { as, Avatar, Box, color, config, Text, Tooltip, TooltipProvider } from 'folds';
|
||||||
|
import React, { forwardRef, MouseEventHandler, ReactNode } from 'react';
|
||||||
|
import * as css from './Sidebar.css';
|
||||||
|
|
||||||
|
const SidebarAvatarBox = as<'div', css.SidebarAvatarBoxVariants>(
|
||||||
|
({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
|
||||||
|
<AsSidebarAvatarBox
|
||||||
|
className={classNames(css.SidebarAvatarBox({ active }), className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SidebarAvatar = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
css.SidebarAvatarBoxVariants &
|
||||||
|
css.SidebarBadgeBoxVariants & {
|
||||||
|
outlined?: boolean;
|
||||||
|
avatarChildren: ReactNode;
|
||||||
|
tooltip: ReactNode | string;
|
||||||
|
notificationBadge?: (badgeClassName: string) => ReactNode;
|
||||||
|
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
onContextMenu?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
active,
|
||||||
|
hasCount,
|
||||||
|
outlined,
|
||||||
|
avatarChildren,
|
||||||
|
tooltip,
|
||||||
|
notificationBadge,
|
||||||
|
onClick,
|
||||||
|
onContextMenu,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SidebarAvatarBox active={active} ref={ref}>
|
||||||
|
<TooltipProvider
|
||||||
|
delay={0}
|
||||||
|
position="Right"
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text size="T300">{tooltip}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(avRef) => (
|
||||||
|
<Avatar
|
||||||
|
ref={avRef}
|
||||||
|
as="button"
|
||||||
|
onClick={onClick}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
style={{
|
||||||
|
border: outlined
|
||||||
|
? `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`
|
||||||
|
: undefined,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{avatarChildren}
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
{notificationBadge && (
|
||||||
|
<Box className={css.SidebarBadgeBox({ hasCount })}>
|
||||||
|
{notificationBadge(css.SidebarBadgeOutline)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</SidebarAvatarBox>
|
||||||
|
)
|
||||||
|
);
|
21
src/app/components/sidebar/SidebarContent.tsx
Normal file
21
src/app/components/sidebar/SidebarContent.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { Box, Scroll } from 'folds';
|
||||||
|
|
||||||
|
type SidebarContentProps = {
|
||||||
|
scrollable: ReactNode;
|
||||||
|
sticky: ReactNode;
|
||||||
|
};
|
||||||
|
export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box direction="Column" grow="Yes">
|
||||||
|
<Scroll variant="Background" size="0">
|
||||||
|
{scrollable}
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" shrink="No">
|
||||||
|
{sticky}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
10
src/app/components/sidebar/SidebarStack.tsx
Normal file
10
src/app/components/sidebar/SidebarStack.tsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import React from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { as } from 'folds';
|
||||||
|
import * as css from './Sidebar.css';
|
||||||
|
|
||||||
|
export const SidebarStack = as<'div'>(
|
||||||
|
({ as: AsSidebarStack = 'div', className, ...props }, ref) => (
|
||||||
|
<AsSidebarStack className={classNames(css.SidebarStack, className)} {...props} ref={ref} />
|
||||||
|
)
|
||||||
|
);
|
13
src/app/components/sidebar/SidebarStackSeparator.tsx
Normal file
13
src/app/components/sidebar/SidebarStackSeparator.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Line, toRem } from 'folds';
|
||||||
|
|
||||||
|
export function SidebarStackSeparator() {
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
role="separator"
|
||||||
|
style={{ width: toRem(24), margin: '0 auto' }}
|
||||||
|
variant="Background"
|
||||||
|
size="300"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
5
src/app/components/sidebar/index.ts
Normal file
5
src/app/components/sidebar/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export * from './Sidebar';
|
||||||
|
export * from './SidebarAvatar';
|
||||||
|
export * from './SidebarContent';
|
||||||
|
export * from './SidebarStack';
|
||||||
|
export * from './SidebarStackSeparator';
|
46
src/app/components/upload-board/UploadBoard.css.ts
Normal file
46
src/app/components/upload-board/UploadBoard.css.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { DefaultReset, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const UploadBoardBase = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'relative',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const UploadBoardContainer = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: config.space.S200,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: config.zIndex.Max,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const UploadBoard = style({
|
||||||
|
maxWidth: toRem(400),
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: toRem(450),
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
boxShadow: config.shadow.E200,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
pointerEvents: 'all',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UploadBoardHeaderContent = style({
|
||||||
|
height: '100%',
|
||||||
|
padding: `0 ${config.space.S200}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UploadBoardContent = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
paddingBottom: 0,
|
||||||
|
paddingRight: 0,
|
||||||
|
});
|
145
src/app/components/upload-board/UploadBoard.tsx
Normal file
145
src/app/components/upload-board/UploadBoard.tsx
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
import React, { MutableRefObject, ReactNode, useImperativeHandle, useRef } from 'react';
|
||||||
|
import { Badge, Box, Chip, Header, Icon, Icons, Spinner, Text, as, percent } from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
|
||||||
|
import * as css from './UploadBoard.css';
|
||||||
|
import { TUploadFamilyObserverAtom, Upload, UploadStatus, UploadSuccess } from '../../state/upload';
|
||||||
|
|
||||||
|
type UploadBoardProps = {
|
||||||
|
header: ReactNode;
|
||||||
|
};
|
||||||
|
export const UploadBoard = as<'div', UploadBoardProps>(({ header, children, ...props }, ref) => (
|
||||||
|
<Box className={css.UploadBoardBase} {...props} ref={ref}>
|
||||||
|
<Box className={css.UploadBoardContainer} justifyContent="End">
|
||||||
|
<Box className={classNames(css.UploadBoard)} direction="Column">
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" shrink="No">
|
||||||
|
{header}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
|
|
||||||
|
export type UploadBoardImperativeHandlers = { handleSend: () => Promise<void> };
|
||||||
|
|
||||||
|
type UploadBoardHeaderProps = {
|
||||||
|
open: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
uploadFamilyObserverAtom: TUploadFamilyObserverAtom;
|
||||||
|
onCancel: (uploads: Upload[]) => void;
|
||||||
|
onSend: (uploads: UploadSuccess[]) => Promise<void>;
|
||||||
|
imperativeHandlerRef: MutableRefObject<UploadBoardImperativeHandlers | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UploadBoardHeader({
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
uploadFamilyObserverAtom,
|
||||||
|
onCancel,
|
||||||
|
onSend,
|
||||||
|
imperativeHandlerRef,
|
||||||
|
}: UploadBoardHeaderProps) {
|
||||||
|
const sendingRef = useRef(false);
|
||||||
|
const uploads = useAtomValue(uploadFamilyObserverAtom);
|
||||||
|
|
||||||
|
const isSuccess = uploads.every((upload) => upload.status === UploadStatus.Success);
|
||||||
|
const isError = uploads.some((upload) => upload.status === UploadStatus.Error);
|
||||||
|
const progress = uploads.reduce(
|
||||||
|
(acc, upload) => {
|
||||||
|
acc.total += upload.file.size;
|
||||||
|
if (upload.status === UploadStatus.Loading) {
|
||||||
|
acc.loaded += upload.progress.loaded;
|
||||||
|
}
|
||||||
|
if (upload.status === UploadStatus.Success) {
|
||||||
|
acc.loaded += upload.file.size;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ loaded: 0, total: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (sendingRef.current) return;
|
||||||
|
sendingRef.current = true;
|
||||||
|
await onSend(
|
||||||
|
uploads.filter((upload) => upload.status === UploadStatus.Success) as UploadSuccess[]
|
||||||
|
);
|
||||||
|
sendingRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
useImperativeHandle(imperativeHandlerRef, () => ({
|
||||||
|
handleSend,
|
||||||
|
}));
|
||||||
|
const handleCancel = () => onCancel(uploads);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Header size="400">
|
||||||
|
<Box
|
||||||
|
as="button"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={onToggle}
|
||||||
|
className={css.UploadBoardHeaderContent}
|
||||||
|
alignItems="Center"
|
||||||
|
grow="Yes"
|
||||||
|
gap="100"
|
||||||
|
>
|
||||||
|
<Icon src={open ? Icons.ChevronTop : Icons.ChevronRight} size="50" />
|
||||||
|
<Text size="H6">Files</Text>
|
||||||
|
</Box>
|
||||||
|
<Box className={css.UploadBoardHeaderContent} alignItems="Center" gap="100">
|
||||||
|
{isSuccess && (
|
||||||
|
<Chip
|
||||||
|
as="button"
|
||||||
|
onClick={handleSend}
|
||||||
|
variant="Primary"
|
||||||
|
radii="Pill"
|
||||||
|
outlined
|
||||||
|
after={<Icon src={Icons.Send} size="50" filled />}
|
||||||
|
>
|
||||||
|
<Text size="B300">Send</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
{isError && !open && (
|
||||||
|
<Badge variant="Critical" fill="Solid" radii="300">
|
||||||
|
<Text size="L400">Upload Failed</Text>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{!isSuccess && !isError && !open && (
|
||||||
|
<>
|
||||||
|
<Badge variant="Secondary" fill="Solid" radii="Pill">
|
||||||
|
<Text size="L400">{Math.round(percent(0, progress.total, progress.loaded))}%</Text>
|
||||||
|
</Badge>
|
||||||
|
<Spinner variant="Secondary" size="200" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isSuccess && open && (
|
||||||
|
<Chip
|
||||||
|
as="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
after={<Icon src={Icons.Cross} size="50" />}
|
||||||
|
>
|
||||||
|
<Text size="B300">{uploads.length === 1 ? 'Remove' : 'Remove All'}</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UploadBoardContent = as<'div'>(({ className, children, ...props }, ref) => (
|
||||||
|
<Box
|
||||||
|
className={classNames(css.UploadBoardContent, className)}
|
||||||
|
direction="Column"
|
||||||
|
gap="200"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
));
|
1
src/app/components/upload-board/index.ts
Normal file
1
src/app/components/upload-board/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './UploadBoard';
|
24
src/app/components/upload-card/UploadCard.css.ts
Normal file
24
src/app/components/upload-card/UploadCard.css.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
|
||||||
|
import { RadiiVariant, color, config } from 'folds';
|
||||||
|
|
||||||
|
export const UploadCard = recipe({
|
||||||
|
base: {
|
||||||
|
padding: config.space.S300,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
radii: RadiiVariant,
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
radii: '400',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UploadCardVariant = RecipeVariants<typeof UploadCard>;
|
||||||
|
|
||||||
|
export const UploadCardError = style({
|
||||||
|
padding: `0 ${config.space.S100}`,
|
||||||
|
color: color.Critical.Main,
|
||||||
|
});
|
63
src/app/components/upload-card/UploadCard.tsx
Normal file
63
src/app/components/upload-card/UploadCard.tsx
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { Badge, Box, Icon, Icons, ProgressBar, Text, percent } from 'folds';
|
||||||
|
import React, { ReactNode, forwardRef } from 'react';
|
||||||
|
|
||||||
|
import * as css from './UploadCard.css';
|
||||||
|
import { bytesToSize } from '../../utils/common';
|
||||||
|
|
||||||
|
type UploadCardProps = {
|
||||||
|
before?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
after?: ReactNode;
|
||||||
|
bottom?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UploadCard = forwardRef<HTMLDivElement, UploadCardProps & css.UploadCardVariant>(
|
||||||
|
({ before, after, children, bottom, radii }, ref) => (
|
||||||
|
<Box className={css.UploadCard({ radii })} direction="Column" gap="200" ref={ref}>
|
||||||
|
<Box alignItems="Center" gap="200">
|
||||||
|
{before}
|
||||||
|
<Box alignItems="Center" grow="Yes" gap="200">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
{after}
|
||||||
|
</Box>
|
||||||
|
{bottom}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
type UploadCardProgressProps = {
|
||||||
|
sentBytes: number;
|
||||||
|
totalBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UploadCardProgress({ sentBytes, totalBytes }: UploadCardProgressProps) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<ProgressBar variant="Secondary" size="300" min={0} max={totalBytes} value={sentBytes} />
|
||||||
|
<Box alignItems="Center" justifyContent="SpaceBetween">
|
||||||
|
<Badge variant="Secondary" fill="Solid" radii="Pill">
|
||||||
|
<Text size="L400">{`${Math.round(percent(0, totalBytes, sentBytes))}%`}</Text>
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="Secondary" fill="Soft" radii="Pill">
|
||||||
|
<Text size="L400">
|
||||||
|
{bytesToSize(sentBytes)} / {bytesToSize(totalBytes)}
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadCardErrorProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UploadCardError({ children }: UploadCardErrorProps) {
|
||||||
|
return (
|
||||||
|
<Box className={css.UploadCardError} alignItems="Center" gap="300">
|
||||||
|
<Icon src={Icons.Warning} size="50" />
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
89
src/app/components/upload-card/UploadCardRenderer.tsx
Normal file
89
src/app/components/upload-card/UploadCardRenderer.tsx
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Chip, Icon, IconButton, Icons, Text, color } from 'folds';
|
||||||
|
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
|
||||||
|
import { TUploadAtom, UploadStatus, useBindUploadAtom } from '../../state/upload';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { TUploadContent } from '../../utils/matrix';
|
||||||
|
import { getFileTypeIcon } from '../../utils/common';
|
||||||
|
|
||||||
|
type UploadCardRendererProps = {
|
||||||
|
file: TUploadContent;
|
||||||
|
isEncrypted?: boolean;
|
||||||
|
uploadAtom: TUploadAtom;
|
||||||
|
onRemove: (file: TUploadContent) => void;
|
||||||
|
};
|
||||||
|
export function UploadCardRenderer({
|
||||||
|
file,
|
||||||
|
isEncrypted,
|
||||||
|
uploadAtom,
|
||||||
|
onRemove,
|
||||||
|
}: UploadCardRendererProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const { upload, startUpload, cancelUpload } = useBindUploadAtom(
|
||||||
|
mx,
|
||||||
|
file,
|
||||||
|
uploadAtom,
|
||||||
|
isEncrypted
|
||||||
|
);
|
||||||
|
|
||||||
|
if (upload.status === UploadStatus.Idle) startUpload();
|
||||||
|
|
||||||
|
const removeUpload = () => {
|
||||||
|
cancelUpload();
|
||||||
|
onRemove(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UploadCard
|
||||||
|
radii="300"
|
||||||
|
before={<Icon src={getFileTypeIcon(Icons, file.type)} />}
|
||||||
|
after={
|
||||||
|
<>
|
||||||
|
{upload.status === UploadStatus.Error && (
|
||||||
|
<Chip
|
||||||
|
as="button"
|
||||||
|
onClick={startUpload}
|
||||||
|
aria-label="Retry Upload"
|
||||||
|
variant="Critical"
|
||||||
|
radii="Pill"
|
||||||
|
outlined
|
||||||
|
>
|
||||||
|
<Text size="B300">Retry</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
onClick={removeUpload}
|
||||||
|
aria-label="Cancel Upload"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
size="300"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} size="200" />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
bottom={
|
||||||
|
<>
|
||||||
|
{upload.status === UploadStatus.Idle && (
|
||||||
|
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
|
||||||
|
)}
|
||||||
|
{upload.status === UploadStatus.Loading && (
|
||||||
|
<UploadCardProgress sentBytes={upload.progress.loaded} totalBytes={file.size} />
|
||||||
|
)}
|
||||||
|
{upload.status === UploadStatus.Error && (
|
||||||
|
<UploadCardError>
|
||||||
|
<Text size="T200">{upload.error.message}</Text>
|
||||||
|
</UploadCardError>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="H6" truncate>
|
||||||
|
{file.name}
|
||||||
|
</Text>
|
||||||
|
{upload.status === UploadStatus.Success && (
|
||||||
|
<Icon style={{ color: color.Success.Main }} src={Icons.Check} size="100" />
|
||||||
|
)}
|
||||||
|
</UploadCard>
|
||||||
|
);
|
||||||
|
}
|
2
src/app/components/upload-card/index.ts
Normal file
2
src/app/components/upload-card/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './UploadCard';
|
||||||
|
export * from './UploadCardRenderer';
|
15
src/app/hooks/useAlive.ts
Normal file
15
src/app/hooks/useAlive.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export const useAlive = (): (() => boolean) => {
|
||||||
|
const aliveRef = useRef<boolean>(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
aliveRef.current = true;
|
||||||
|
return () => {
|
||||||
|
aliveRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const alive = useCallback(() => aliveRef.current, []);
|
||||||
|
return alive;
|
||||||
|
};
|
70
src/app/hooks/useAsyncCallback.ts
Normal file
70
src/app/hooks/useAsyncCallback.ts
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useAlive } from './useAlive';
|
||||||
|
|
||||||
|
export enum AsyncStatus {
|
||||||
|
Idle = 'idle',
|
||||||
|
Loading = 'loading',
|
||||||
|
Success = 'success',
|
||||||
|
Error = 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AsyncIdle = {
|
||||||
|
status: AsyncStatus.Idle;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AsyncLoading = {
|
||||||
|
status: AsyncStatus.Loading;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AsyncSuccess<T> = {
|
||||||
|
status: AsyncStatus.Success;
|
||||||
|
data: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AsyncError = {
|
||||||
|
status: AsyncStatus.Error;
|
||||||
|
error: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AsyncState<T> = AsyncIdle | AsyncLoading | AsyncSuccess<T> | AsyncError;
|
||||||
|
|
||||||
|
export type AsyncCallback<TArgs extends unknown[], TData> = (...args: TArgs) => Promise<TData>;
|
||||||
|
|
||||||
|
export const useAsyncCallback = <TArgs extends unknown[], TData>(
|
||||||
|
asyncCallback: AsyncCallback<TArgs, TData>
|
||||||
|
): [AsyncState<TData>, AsyncCallback<TArgs, TData>] => {
|
||||||
|
const [state, setState] = useState<AsyncState<TData>>({
|
||||||
|
status: AsyncStatus.Idle,
|
||||||
|
});
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
const callback: AsyncCallback<TArgs, TData> = useCallback(
|
||||||
|
async (...args) => {
|
||||||
|
setState({
|
||||||
|
status: AsyncStatus.Loading,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await asyncCallback(...args);
|
||||||
|
if (alive()) {
|
||||||
|
setState({
|
||||||
|
status: AsyncStatus.Success,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
if (alive()) {
|
||||||
|
setState({
|
||||||
|
status: AsyncStatus.Error,
|
||||||
|
error: e,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[asyncCallback, alive]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [state, callback];
|
||||||
|
};
|
81
src/app/hooks/useAsyncSearch.ts
Normal file
81
src/app/hooks/useAsyncSearch.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
MatchHandler,
|
||||||
|
AsyncSearch,
|
||||||
|
AsyncSearchHandler,
|
||||||
|
AsyncSearchOption,
|
||||||
|
MatchQueryOption,
|
||||||
|
NormalizeOption,
|
||||||
|
normalize,
|
||||||
|
matchQuery,
|
||||||
|
ResultHandler,
|
||||||
|
} from '../utils/AsyncSearch';
|
||||||
|
|
||||||
|
export type UseAsyncSearchOptions = AsyncSearchOption & {
|
||||||
|
matchOptions?: MatchQueryOption;
|
||||||
|
normalizeOptions?: NormalizeOption;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchItemStrGetter<TSearchItem extends object | string | number> = (
|
||||||
|
searchItem: TSearchItem
|
||||||
|
) => string | string[];
|
||||||
|
|
||||||
|
export type UseAsyncSearchResult<TSearchItem extends object | string | number> = {
|
||||||
|
query: string;
|
||||||
|
items: TSearchItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAsyncSearch = <TSearchItem extends object | string | number>(
|
||||||
|
list: TSearchItem[],
|
||||||
|
getItemStr: SearchItemStrGetter<TSearchItem>,
|
||||||
|
options?: UseAsyncSearchOptions
|
||||||
|
): [UseAsyncSearchResult<TSearchItem> | undefined, AsyncSearchHandler] => {
|
||||||
|
const [result, setResult] = useState<UseAsyncSearchResult<TSearchItem>>();
|
||||||
|
|
||||||
|
const [searchCallback, terminateSearch] = useMemo(() => {
|
||||||
|
setResult(undefined);
|
||||||
|
|
||||||
|
const handleMatch: MatchHandler<TSearchItem> = (item, query) => {
|
||||||
|
const itemStr = getItemStr(item);
|
||||||
|
if (Array.isArray(itemStr))
|
||||||
|
return !!itemStr.find((i) =>
|
||||||
|
matchQuery(normalize(i, options?.normalizeOptions), query, options?.matchOptions)
|
||||||
|
);
|
||||||
|
return matchQuery(
|
||||||
|
normalize(itemStr, options?.normalizeOptions),
|
||||||
|
query,
|
||||||
|
options?.matchOptions
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResult: ResultHandler<TSearchItem> = (results, query) =>
|
||||||
|
setResult({
|
||||||
|
query,
|
||||||
|
items: results,
|
||||||
|
});
|
||||||
|
|
||||||
|
return AsyncSearch(list, handleMatch, handleResult, options);
|
||||||
|
}, [list, options, getItemStr]);
|
||||||
|
|
||||||
|
const searchHandler: AsyncSearchHandler = useCallback(
|
||||||
|
(query) => {
|
||||||
|
const normalizedQuery = normalize(query, options?.normalizeOptions);
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
setResult(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchCallback(normalizedQuery);
|
||||||
|
},
|
||||||
|
[searchCallback, options?.normalizeOptions]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
// terminate any ongoing search request on unmount.
|
||||||
|
terminateSearch();
|
||||||
|
},
|
||||||
|
[terminateSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [result, searchHandler];
|
||||||
|
};
|
34
src/app/hooks/useDebounce.ts
Normal file
34
src/app/hooks/useDebounce.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
export interface DebounceOptions {
|
||||||
|
wait?: number;
|
||||||
|
immediate?: boolean;
|
||||||
|
}
|
||||||
|
export type DebounceCallback<T extends unknown[]> = (...args: T) => void;
|
||||||
|
|
||||||
|
export function useDebounce<T extends unknown[]>(
|
||||||
|
callback: DebounceCallback<T>,
|
||||||
|
options?: DebounceOptions
|
||||||
|
): DebounceCallback<T> {
|
||||||
|
const timeoutIdRef = useRef<number>();
|
||||||
|
const { wait, immediate } = options ?? {};
|
||||||
|
|
||||||
|
const debounceCallback = useCallback(
|
||||||
|
(...cbArgs: T) => {
|
||||||
|
if (timeoutIdRef.current) {
|
||||||
|
clearTimeout(timeoutIdRef.current);
|
||||||
|
timeoutIdRef.current = undefined;
|
||||||
|
} else if (immediate) {
|
||||||
|
callback(...cbArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutIdRef.current = window.setTimeout(() => {
|
||||||
|
callback(...cbArgs);
|
||||||
|
timeoutIdRef.current = undefined;
|
||||||
|
}, wait);
|
||||||
|
},
|
||||||
|
[callback, wait, immediate]
|
||||||
|
);
|
||||||
|
|
||||||
|
return debounceCallback;
|
||||||
|
}
|
66
src/app/hooks/useFileDrop.ts
Normal file
66
src/app/hooks/useFileDrop.ts
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import { useCallback, DragEventHandler, RefObject, useState, useEffect, useRef } from 'react';
|
||||||
|
import { getDataTransferFiles } from '../utils/dom';
|
||||||
|
|
||||||
|
export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHandler =>
|
||||||
|
useCallback(
|
||||||
|
(evt) => {
|
||||||
|
const files = getDataTransferFiles(evt.dataTransfer);
|
||||||
|
if (files) onDrop(files);
|
||||||
|
},
|
||||||
|
[onDrop]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useFileDropZone = (
|
||||||
|
zoneRef: RefObject<HTMLElement>,
|
||||||
|
onDrop: (file: File[]) => void
|
||||||
|
): boolean => {
|
||||||
|
const dragStateRef = useRef<'start' | 'leave' | 'over'>();
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const target = zoneRef.current;
|
||||||
|
const handleDrop = (evt: DragEvent) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
dragStateRef.current = undefined;
|
||||||
|
setActive(false);
|
||||||
|
if (!evt.dataTransfer) return;
|
||||||
|
const files = getDataTransferFiles(evt.dataTransfer);
|
||||||
|
if (files) onDrop(files);
|
||||||
|
};
|
||||||
|
|
||||||
|
target?.addEventListener('drop', handleDrop);
|
||||||
|
return () => {
|
||||||
|
target?.removeEventListener('drop', handleDrop);
|
||||||
|
};
|
||||||
|
}, [zoneRef, onDrop]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const target = zoneRef.current;
|
||||||
|
const handleDragEnter = (evt: DragEvent) => {
|
||||||
|
if (evt.dataTransfer?.types.includes('Files')) {
|
||||||
|
dragStateRef.current = 'start';
|
||||||
|
setActive(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
if (dragStateRef.current !== 'over') return;
|
||||||
|
dragStateRef.current = 'leave';
|
||||||
|
setActive(false);
|
||||||
|
};
|
||||||
|
const handleDragOver = (evt: DragEvent) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
dragStateRef.current = 'over';
|
||||||
|
};
|
||||||
|
|
||||||
|
target?.addEventListener('dragenter', handleDragEnter);
|
||||||
|
target?.addEventListener('dragleave', handleDragLeave);
|
||||||
|
target?.addEventListener('dragover', handleDragOver);
|
||||||
|
return () => {
|
||||||
|
target?.removeEventListener('dragenter', handleDragEnter);
|
||||||
|
target?.removeEventListener('dragleave', handleDragLeave);
|
||||||
|
target?.removeEventListener('dragover', handleDragOver);
|
||||||
|
};
|
||||||
|
}, [zoneRef]);
|
||||||
|
|
||||||
|
return active;
|
||||||
|
};
|
11
src/app/hooks/useFilePasteHandler.ts
Normal file
11
src/app/hooks/useFilePasteHandler.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { useCallback, ClipboardEventHandler } from 'react';
|
||||||
|
import { getDataTransferFiles } from '../utils/dom';
|
||||||
|
|
||||||
|
export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler =>
|
||||||
|
useCallback(
|
||||||
|
(evt) => {
|
||||||
|
const files = getDataTransferFiles(evt.clipboardData);
|
||||||
|
if (files) onPaste(files);
|
||||||
|
},
|
||||||
|
[onPaste]
|
||||||
|
);
|
15
src/app/hooks/useFilePicker.ts
Normal file
15
src/app/hooks/useFilePicker.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { selectFile } from '../utils/dom';
|
||||||
|
|
||||||
|
export const useFilePicker = <M extends boolean | undefined = undefined>(
|
||||||
|
onSelect: (file: M extends true ? File[] : File) => void,
|
||||||
|
multiple?: M
|
||||||
|
) =>
|
||||||
|
useCallback(
|
||||||
|
async (accept: string) => {
|
||||||
|
const file = await selectFile(accept, multiple);
|
||||||
|
if (!file) return;
|
||||||
|
onSelect(file);
|
||||||
|
},
|
||||||
|
[multiple, onSelect]
|
||||||
|
);
|
9
src/app/hooks/useForceUpdate.ts
Normal file
9
src/app/hooks/useForceUpdate.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { useReducer } from 'react';
|
||||||
|
|
||||||
|
const reducer = (prevCount: number): number => prevCount + 1;
|
||||||
|
|
||||||
|
export const useForceUpdate = (): [number, () => void] => {
|
||||||
|
const [state, dispatch] = useReducer<typeof reducer>(reducer, 0);
|
||||||
|
|
||||||
|
return [state, dispatch];
|
||||||
|
};
|
48
src/app/hooks/useImagePacks.ts
Normal file
48
src/app/hooks/useImagePacks.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { ClientEvent, MatrixClient, MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
|
||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { getRelevantPacks, ImagePack, PackUsage } from '../plugins/custom-emoji';
|
||||||
|
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||||
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
|
import { useForceUpdate } from './useForceUpdate';
|
||||||
|
|
||||||
|
export const useRelevantImagePacks = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
usage: PackUsage,
|
||||||
|
rooms: Room[]
|
||||||
|
): ImagePack[] => {
|
||||||
|
const [forceCount, forceUpdate] = useForceUpdate();
|
||||||
|
|
||||||
|
const relevantPacks = useMemo(
|
||||||
|
() => getRelevantPacks(mx, rooms).filter((pack) => pack.getImagesFor(usage).length > 0),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[mx, usage, rooms, forceCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUpdate = (event: MatrixEvent) => {
|
||||||
|
if (
|
||||||
|
event.getType() === AccountDataEvent.PoniesEmoteRooms ||
|
||||||
|
event.getType() === AccountDataEvent.PoniesUserEmotes
|
||||||
|
) {
|
||||||
|
forceUpdate();
|
||||||
|
}
|
||||||
|
const eventRoomId = event.getRoomId();
|
||||||
|
if (
|
||||||
|
eventRoomId &&
|
||||||
|
event.getType() === StateEvent.PoniesRoomEmotes &&
|
||||||
|
rooms.find((room) => room.roomId === eventRoomId)
|
||||||
|
) {
|
||||||
|
forceUpdate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on(ClientEvent.AccountData, handleUpdate);
|
||||||
|
mx.on(RoomStateEvent.Events, handleUpdate);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(ClientEvent.AccountData, handleUpdate);
|
||||||
|
mx.removeListener(RoomStateEvent.Events, handleUpdate);
|
||||||
|
};
|
||||||
|
}, [mx, rooms, forceUpdate]);
|
||||||
|
|
||||||
|
return relevantPacks;
|
||||||
|
};
|
10
src/app/hooks/useKeyDown.ts
Normal file
10
src/app/hooks/useKeyDown.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export const useKeyDown = (target: Window, callback: (evt: KeyboardEvent) => void) => {
|
||||||
|
useEffect(() => {
|
||||||
|
target.addEventListener('keydown', callback);
|
||||||
|
return () => {
|
||||||
|
target.removeEventListener('keydown', callback);
|
||||||
|
};
|
||||||
|
}, [target, callback]);
|
||||||
|
};
|
12
src/app/hooks/useMatrixClient.ts
Normal file
12
src/app/hooks/useMatrixClient.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
const MatrixClientContext = createContext<MatrixClient | null>(null);
|
||||||
|
|
||||||
|
export const MatrixClientProvider = MatrixClientContext.Provider;
|
||||||
|
|
||||||
|
export function useMatrixClient(): MatrixClient {
|
||||||
|
const mx = useContext(MatrixClientContext);
|
||||||
|
if (!mx) throw new Error('MatrixClient not initialized!');
|
||||||
|
return mx;
|
||||||
|
}
|
86
src/app/hooks/usePowerLevels.ts
Normal file
86
src/app/hooks/usePowerLevels.ts
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useStateEvent } from './useStateEvent';
|
||||||
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
|
|
||||||
|
enum DefaultPowerLevels {
|
||||||
|
usersDefault = 0,
|
||||||
|
stateDefault = 50,
|
||||||
|
eventsDefault = 0,
|
||||||
|
invite = 0,
|
||||||
|
redact = 50,
|
||||||
|
kick = 50,
|
||||||
|
ban = 50,
|
||||||
|
historical = 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPowerLevels {
|
||||||
|
users_default?: number;
|
||||||
|
state_default?: number;
|
||||||
|
events_default?: number;
|
||||||
|
historical?: number;
|
||||||
|
invite?: number;
|
||||||
|
redact?: number;
|
||||||
|
kick?: number;
|
||||||
|
ban?: number;
|
||||||
|
|
||||||
|
events?: Record<string, number>;
|
||||||
|
users?: Record<string, number>;
|
||||||
|
notifications?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePowerLevels(room: Room) {
|
||||||
|
const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels);
|
||||||
|
const powerLevels: IPowerLevels = powerLevelsEvent?.getContent() ?? DefaultPowerLevels;
|
||||||
|
|
||||||
|
const getPowerLevel = useCallback(
|
||||||
|
(userId: string) => {
|
||||||
|
const { users_default: usersDefault, users } = powerLevels;
|
||||||
|
if (users && typeof users[userId] === 'number') {
|
||||||
|
return users[userId];
|
||||||
|
}
|
||||||
|
return usersDefault ?? DefaultPowerLevels.usersDefault;
|
||||||
|
},
|
||||||
|
[powerLevels]
|
||||||
|
);
|
||||||
|
|
||||||
|
const canSendEvent = useCallback(
|
||||||
|
(eventType: string | undefined, powerLevel: number) => {
|
||||||
|
const { events, events_default: eventsDefault } = powerLevels;
|
||||||
|
if (events && eventType && typeof events[eventType] === 'string') {
|
||||||
|
return powerLevel >= events[eventType];
|
||||||
|
}
|
||||||
|
return powerLevel >= (eventsDefault ?? DefaultPowerLevels.eventsDefault);
|
||||||
|
},
|
||||||
|
[powerLevels]
|
||||||
|
);
|
||||||
|
|
||||||
|
const canSendStateEvent = useCallback(
|
||||||
|
(eventType: string | undefined, powerLevel: number) => {
|
||||||
|
const { events, state_default: stateDefault } = powerLevels;
|
||||||
|
if (events && eventType && typeof events[eventType] === 'number') {
|
||||||
|
return powerLevel >= events[eventType];
|
||||||
|
}
|
||||||
|
return powerLevel >= (stateDefault ?? DefaultPowerLevels.stateDefault);
|
||||||
|
},
|
||||||
|
[powerLevels]
|
||||||
|
);
|
||||||
|
|
||||||
|
const canDoAction = useCallback(
|
||||||
|
(action: 'invite' | 'redact' | 'kick' | 'ban' | 'historical', powerLevel: number) => {
|
||||||
|
const requiredPL = powerLevels[action];
|
||||||
|
if (typeof requiredPL === 'number') {
|
||||||
|
return powerLevel >= requiredPL;
|
||||||
|
}
|
||||||
|
return powerLevel >= DefaultPowerLevels[action];
|
||||||
|
},
|
||||||
|
[powerLevels]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getPowerLevel,
|
||||||
|
canSendEvent,
|
||||||
|
canSendStateEvent,
|
||||||
|
canDoAction,
|
||||||
|
};
|
||||||
|
}
|
23
src/app/hooks/useRecentEmoji.ts
Normal file
23
src/app/hooks/useRecentEmoji.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||||
|
import { getRecentEmojis } from '../plugins/recent-emoji';
|
||||||
|
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||||
|
import { IEmoji } from '../plugins/emoji';
|
||||||
|
|
||||||
|
export const useRecentEmoji = (mx: MatrixClient, limit?: number): IEmoji[] => {
|
||||||
|
const [recentEmoji, setRecentEmoji] = useState(() => getRecentEmojis(mx, limit));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleAccountData = (event: MatrixEvent) => {
|
||||||
|
if (event.getType() !== AccountDataEvent.ElementRecentEmoji) return;
|
||||||
|
setRecentEmoji(getRecentEmojis(mx, limit));
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on(ClientEvent.AccountData, handleAccountData);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(ClientEvent.AccountData, handleAccountData);
|
||||||
|
};
|
||||||
|
}, [mx, limit]);
|
||||||
|
|
||||||
|
return recentEmoji;
|
||||||
|
};
|
24
src/app/hooks/useResizeObserver.ts
Normal file
24
src/app/hooks/useResizeObserver.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
export type OnResizeCallback = (entries: ResizeObserverEntry[]) => void;
|
||||||
|
|
||||||
|
export const getResizeObserverEntry = (
|
||||||
|
target: Element,
|
||||||
|
entries: ResizeObserverEntry[]
|
||||||
|
): ResizeObserverEntry | undefined => entries.find((entry) => entry.target === target);
|
||||||
|
|
||||||
|
export const useResizeObserver = (
|
||||||
|
element: Element | null,
|
||||||
|
onResizeCallback: OnResizeCallback
|
||||||
|
): ResizeObserver => {
|
||||||
|
const resizeObserver = useMemo(() => new ResizeObserver(onResizeCallback), [onResizeCallback]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (element) resizeObserver.observe(element);
|
||||||
|
return () => {
|
||||||
|
if (element) resizeObserver.unobserve(element);
|
||||||
|
};
|
||||||
|
}, [resizeObserver, element]);
|
||||||
|
|
||||||
|
return resizeObserver;
|
||||||
|
};
|
34
src/app/hooks/useRoomMembers.ts
Normal file
34
src/app/hooks/useRoomMembers.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { MatrixClient, MatrixEvent, RoomMember, RoomMemberEvent } from 'matrix-js-sdk';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useAlive } from './useAlive';
|
||||||
|
|
||||||
|
export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] => {
|
||||||
|
const [members, setMembers] = useState<RoomMember[]>([]);
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
|
||||||
|
const updateMemberList = (event?: MatrixEvent) => {
|
||||||
|
if (!room || !alive || (event && event.getRoomId() !== roomId)) return;
|
||||||
|
setMembers(room.getMembers());
|
||||||
|
};
|
||||||
|
|
||||||
|
if (room) {
|
||||||
|
updateMemberList();
|
||||||
|
room.loadMembersIfNeeded().then(() => {
|
||||||
|
if (!alive) return;
|
||||||
|
updateMemberList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mx.on(RoomMemberEvent.Membership, updateMemberList);
|
||||||
|
mx.on(RoomMemberEvent.PowerLevel, updateMemberList);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(RoomMemberEvent.Membership, updateMemberList);
|
||||||
|
mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList);
|
||||||
|
};
|
||||||
|
}, [mx, roomId, alive]);
|
||||||
|
|
||||||
|
return members;
|
||||||
|
};
|
32
src/app/hooks/useStateEvent.ts
Normal file
32
src/app/hooks/useStateEvent.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useStateEventCallback } from './useStateEventCallback';
|
||||||
|
import { useForceUpdate } from './useForceUpdate';
|
||||||
|
import { getStateEvent } from '../utils/room';
|
||||||
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
|
|
||||||
|
export const useStateEvent = (room: Room, eventType: StateEvent, stateKey = '') => {
|
||||||
|
const [updateCount, forceUpdate] = useForceUpdate();
|
||||||
|
|
||||||
|
useStateEventCallback(
|
||||||
|
room.client,
|
||||||
|
useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (
|
||||||
|
event.getRoomId() === room.roomId &&
|
||||||
|
event.getType() === eventType &&
|
||||||
|
event.getStateKey() === stateKey
|
||||||
|
) {
|
||||||
|
forceUpdate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[room, eventType, stateKey, forceUpdate]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => getStateEvent(room, eventType, stateKey),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[room, eventType, stateKey, updateCount]
|
||||||
|
);
|
||||||
|
};
|
17
src/app/hooks/useStateEventCallback.ts
Normal file
17
src/app/hooks/useStateEventCallback.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { MatrixClient, MatrixEvent, RoomState, RoomStateEvent } from 'matrix-js-sdk';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export type StateEventCallback = (
|
||||||
|
event: MatrixEvent,
|
||||||
|
state: RoomState,
|
||||||
|
lastStateEvent: MatrixEvent | null
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export const useStateEventCallback = (mx: MatrixClient, onStateEvent: StateEventCallback) => {
|
||||||
|
useEffect(() => {
|
||||||
|
mx.on(RoomStateEvent.Events, onStateEvent);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(RoomStateEvent.Events, onStateEvent);
|
||||||
|
};
|
||||||
|
}, [mx, onStateEvent]);
|
||||||
|
};
|
28
src/app/hooks/useStateEvents.ts
Normal file
28
src/app/hooks/useStateEvents.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
|
import { useForceUpdate } from './useForceUpdate';
|
||||||
|
import { useStateEventCallback } from './useStateEventCallback';
|
||||||
|
import { getStateEvents } from '../utils/room';
|
||||||
|
|
||||||
|
export const useStateEvents = (room: Room, eventType: StateEvent) => {
|
||||||
|
const [updateCount, forceUpdate] = useForceUpdate();
|
||||||
|
|
||||||
|
useStateEventCallback(
|
||||||
|
room.client,
|
||||||
|
useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (event.getRoomId() === room.roomId && event.getType() === eventType) {
|
||||||
|
forceUpdate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[room, eventType, forceUpdate]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => getStateEvents(room, eventType),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[room, eventType, updateCount]
|
||||||
|
);
|
||||||
|
};
|
41
src/app/hooks/useThrottle.ts
Normal file
41
src/app/hooks/useThrottle.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
export interface ThrottleOptions {
|
||||||
|
wait?: number;
|
||||||
|
immediate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThrottleCallback<T extends unknown[]> = (...args: T) => void;
|
||||||
|
|
||||||
|
export function useThrottle<T extends unknown[]>(
|
||||||
|
callback: ThrottleCallback<T>,
|
||||||
|
options?: ThrottleOptions
|
||||||
|
): ThrottleCallback<T> {
|
||||||
|
const timeoutIdRef = useRef<number>();
|
||||||
|
const argsRef = useRef<T>();
|
||||||
|
const { wait, immediate } = options ?? {};
|
||||||
|
|
||||||
|
const debounceCallback = useCallback(
|
||||||
|
(...cbArgs: T) => {
|
||||||
|
argsRef.current = cbArgs;
|
||||||
|
|
||||||
|
if (timeoutIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (immediate) {
|
||||||
|
callback(...cbArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutIdRef.current = window.setTimeout(() => {
|
||||||
|
if (argsRef.current) {
|
||||||
|
callback(...argsRef.current);
|
||||||
|
}
|
||||||
|
argsRef.current = undefined;
|
||||||
|
timeoutIdRef.current = undefined;
|
||||||
|
}, wait);
|
||||||
|
},
|
||||||
|
[callback, wait, immediate]
|
||||||
|
);
|
||||||
|
|
||||||
|
return debounceCallback;
|
||||||
|
}
|
42
src/app/hooks/useTypingStatusUpdater.ts
Normal file
42
src/app/hooks/useTypingStatusUpdater.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
type TypingStatusUpdater = (typing: boolean) => void;
|
||||||
|
|
||||||
|
const TYPING_TIMEOUT_MS = 5000; // 5 seconds
|
||||||
|
|
||||||
|
export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): TypingStatusUpdater => {
|
||||||
|
const statusSentTsRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const sendTypingStatus: TypingStatusUpdater = useMemo(() => {
|
||||||
|
statusSentTsRef.current = 0;
|
||||||
|
return (typing) => {
|
||||||
|
if (typing) {
|
||||||
|
if (Date.now() - statusSentTsRef.current < TYPING_TIMEOUT_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mx.sendTyping(roomId, true, TYPING_TIMEOUT_MS);
|
||||||
|
const sentTs = Date.now();
|
||||||
|
statusSentTsRef.current = sentTs;
|
||||||
|
|
||||||
|
// Don't believe server will timeout typing status;
|
||||||
|
// Clear typing status after timeout if already not;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (statusSentTsRef.current === sentTs) {
|
||||||
|
mx.sendTyping(roomId, false, TYPING_TIMEOUT_MS);
|
||||||
|
statusSentTsRef.current = 0;
|
||||||
|
}
|
||||||
|
}, TYPING_TIMEOUT_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - statusSentTsRef.current < TYPING_TIMEOUT_MS) {
|
||||||
|
mx.sendTyping(roomId, false, TYPING_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
statusSentTsRef.current = 0;
|
||||||
|
};
|
||||||
|
}, [mx, roomId]);
|
||||||
|
|
||||||
|
return sendTypingStatus;
|
||||||
|
};
|
|
@ -17,38 +17,40 @@ function FollowingMembers({ roomTimeline }) {
|
||||||
const [followingMembers, setFollowingMembers] = useState([]);
|
const [followingMembers, setFollowingMembers] = useState([]);
|
||||||
const { roomId } = roomTimeline;
|
const { roomId } = roomTimeline;
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const { roomsInput } = initMatrix;
|
|
||||||
const myUserId = mx.getUserId();
|
const myUserId = mx.getUserId();
|
||||||
|
|
||||||
const handleOnMessageSent = () => setFollowingMembers([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateFollowingMembers = () => {
|
const updateFollowingMembers = () => {
|
||||||
setFollowingMembers(roomTimeline.getLiveReaders());
|
setFollowingMembers(roomTimeline.getLiveReaders());
|
||||||
};
|
};
|
||||||
|
const updateOnEvent = (event, room) => {
|
||||||
|
if (room.roomId !== roomId) return;
|
||||||
|
setFollowingMembers(roomTimeline.getLiveReaders());
|
||||||
|
};
|
||||||
updateFollowingMembers();
|
updateFollowingMembers();
|
||||||
roomTimeline.on(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
|
roomTimeline.on(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
|
||||||
roomsInput.on(cons.events.roomsInput.MESSAGE_SENT, handleOnMessageSent);
|
mx.on('Room.timeline', updateOnEvent);
|
||||||
return () => {
|
return () => {
|
||||||
roomTimeline.removeListener(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
|
roomTimeline.removeListener(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
|
||||||
roomsInput.removeListener(cons.events.roomsInput.MESSAGE_SENT, handleOnMessageSent);
|
mx.removeListener('Room.timeline', updateOnEvent);
|
||||||
};
|
};
|
||||||
}, [roomTimeline]);
|
}, [roomTimeline, roomId]);
|
||||||
|
|
||||||
const filteredM = followingMembers.filter((userId) => userId !== myUserId);
|
const filteredM = followingMembers.filter((userId) => userId !== myUserId);
|
||||||
|
|
||||||
return filteredM.length !== 0 && (
|
return (
|
||||||
<button
|
filteredM.length !== 0 && (
|
||||||
className="following-members"
|
<button
|
||||||
onClick={() => openReadReceipts(roomId, followingMembers)}
|
className="following-members"
|
||||||
type="button"
|
onClick={() => openReadReceipts(roomId, followingMembers)}
|
||||||
>
|
type="button"
|
||||||
<RawIcon
|
>
|
||||||
size="extra-small"
|
<RawIcon size="extra-small" src={TickMarkIC} />
|
||||||
src={TickMarkIC}
|
<Text variant="b2">
|
||||||
/>
|
{getUsersActionJsx(roomId, filteredM, 'following the conversation.')}
|
||||||
<Text variant="b2">{getUsersActionJsx(roomId, filteredM, 'following the conversation.')}</Text>
|
</Text>
|
||||||
</button>
|
</button>
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
|
@ -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);
|
|
||||||
}
|
|
|
@ -7,10 +7,7 @@
|
||||||
width: var(--navigation-sidebar-width);
|
width: var(--navigation-sidebar-width);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: var(--bg-surface-extra-low);
|
background-color: var(--bg-surface-extra-low);
|
||||||
@include dir.side(border,
|
@include dir.side(border, none, 1px solid var(--bg-surface-border));
|
||||||
none,
|
|
||||||
1px solid var(--bg-surface-border),
|
|
||||||
);
|
|
||||||
|
|
||||||
&__scrollable,
|
&__scrollable,
|
||||||
&__sticky {
|
&__sticky {
|
||||||
|
@ -24,7 +21,7 @@
|
||||||
|
|
||||||
.scrollable-content {
|
.scrollable-content {
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
content: '';
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
|
@ -33,7 +30,8 @@
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
to top,
|
to top,
|
||||||
var(--bg-surface-extra-low),
|
var(--bg-surface-extra-low),
|
||||||
var(--bg-surface-extra-low-transparent));
|
var(--bg-surface-extra-low-transparent)
|
||||||
|
);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: -1px;
|
bottom: -1px;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
@ -63,7 +61,7 @@
|
||||||
box-shadow: var(--bs-danger-border);
|
box-shadow: var(--bs-danger-border);
|
||||||
animation-name: pushRight;
|
animation-name: pushRight;
|
||||||
animation-duration: 400ms;
|
animation-duration: 400ms;
|
||||||
animation-iteration-count: infinite;
|
animation-iteration-count: 30;
|
||||||
animation-direction: alternate;
|
animation-direction: alternate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
125
src/app/organisms/navigation/Sidebar1.tsx
Normal file
125
src/app/organisms/navigation/Sidebar1.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -15,6 +15,7 @@ import PeopleDrawer from './PeopleDrawer';
|
||||||
|
|
||||||
function Room() {
|
function Room() {
|
||||||
const [roomInfo, setRoomInfo] = useState({
|
const [roomInfo, setRoomInfo] = useState({
|
||||||
|
room: null,
|
||||||
roomTimeline: null,
|
roomTimeline: null,
|
||||||
eventId: null,
|
eventId: null,
|
||||||
});
|
});
|
||||||
|
@ -25,14 +26,17 @@ function Room() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleRoomSelected = (rId, pRoomId, eId) => {
|
const handleRoomSelected = (rId, pRoomId, eId) => {
|
||||||
roomInfo.roomTimeline?.removeInternalListeners();
|
roomInfo.roomTimeline?.removeInternalListeners();
|
||||||
if (mx.getRoom(rId)) {
|
const r = mx.getRoom(rId);
|
||||||
|
if (r) {
|
||||||
setRoomInfo({
|
setRoomInfo({
|
||||||
|
room: r,
|
||||||
roomTimeline: new RoomTimeline(rId),
|
roomTimeline: new RoomTimeline(rId),
|
||||||
eventId: eId ?? null,
|
eventId: eId ?? null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// TODO: add ability to join room if roomId is invalid
|
// TODO: add ability to join room if roomId is invalid
|
||||||
setRoomInfo({
|
setRoomInfo({
|
||||||
|
room: r,
|
||||||
roomTimeline: null,
|
roomTimeline: null,
|
||||||
eventId: null,
|
eventId: null,
|
||||||
});
|
});
|
||||||
|
@ -43,7 +47,7 @@ function Room() {
|
||||||
return () => {
|
return () => {
|
||||||
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
|
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
|
||||||
};
|
};
|
||||||
}, [roomInfo]);
|
}, [roomInfo, mx]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDrawerToggling = (visiblity) => setIsDrawer(visiblity);
|
const handleDrawerToggling = (visiblity) => setIsDrawer(visiblity);
|
||||||
|
@ -53,7 +57,7 @@ function Room() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { roomTimeline, eventId } = roomInfo;
|
const { room, roomTimeline, eventId } = roomInfo;
|
||||||
if (roomTimeline === null) {
|
if (roomTimeline === null) {
|
||||||
setTimeout(() => openNavigation());
|
setTimeout(() => openNavigation());
|
||||||
return <Welcome />;
|
return <Welcome />;
|
||||||
|
@ -63,7 +67,7 @@ function Room() {
|
||||||
<div className="room">
|
<div className="room">
|
||||||
<div className="room__content">
|
<div className="room__content">
|
||||||
<RoomSettings roomId={roomTimeline.roomId} />
|
<RoomSettings roomId={roomTimeline.roomId} />
|
||||||
<RoomView roomTimeline={roomTimeline} eventId={eventId} />
|
<RoomView room={room} roomTimeline={roomTimeline} eventId={eventId} />
|
||||||
</div>
|
</div>
|
||||||
{isDrawer && <PeopleDrawer roomId={roomTimeline.roomId} />}
|
{isDrawer && <PeopleDrawer roomId={roomTimeline.roomId} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
539
src/app/organisms/room/RoomInput.tsx
Normal file
539
src/app/organisms/room/RoomInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
10
src/app/organisms/room/RoomInputPlaceholder.css.ts
Normal file
10
src/app/organisms/room/RoomInputPlaceholder.css.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const RoomInputPlaceholder = style({
|
||||||
|
minHeight: toRem(48),
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
});
|
11
src/app/organisms/room/RoomInputPlaceholder.tsx
Normal file
11
src/app/organisms/room/RoomInputPlaceholder.tsx
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import React, { ComponentProps } from 'react';
|
||||||
|
import { Box, as } from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import * as css from './RoomInputPlaceholder.css';
|
||||||
|
|
||||||
|
export const RoomInputPlaceholder = as<'div', ComponentProps<typeof Box>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<Box className={classNames(css.RoomInputPlaceholder, className)} {...props} ref={ref} />
|
||||||
|
)
|
||||||
|
);
|
7
src/app/organisms/room/RoomTombstone.css.ts
Normal file
7
src/app/organisms/room/RoomTombstone.css.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { config } from 'folds';
|
||||||
|
|
||||||
|
export const RoomTombstone = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
paddingLeft: config.space.S400,
|
||||||
|
});
|
67
src/app/organisms/room/RoomTombstone.tsx
Normal file
67
src/app/organisms/room/RoomTombstone.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './RoomView.scss';
|
import './RoomView.scss';
|
||||||
|
import { Text, config } from 'folds';
|
||||||
|
|
||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
|
|
||||||
|
@ -10,16 +11,29 @@ import navigation from '../../../client/state/navigation';
|
||||||
import RoomViewHeader from './RoomViewHeader';
|
import RoomViewHeader from './RoomViewHeader';
|
||||||
import RoomViewContent from './RoomViewContent';
|
import RoomViewContent from './RoomViewContent';
|
||||||
import RoomViewFloating from './RoomViewFloating';
|
import RoomViewFloating from './RoomViewFloating';
|
||||||
import RoomViewInput from './RoomViewInput';
|
|
||||||
import RoomViewCmdBar from './RoomViewCmdBar';
|
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();
|
const viewEvent = new EventEmitter();
|
||||||
|
|
||||||
function RoomView({ roomTimeline, eventId }) {
|
function RoomView({ room, roomTimeline, eventId }) {
|
||||||
|
const roomInputRef = useRef(null);
|
||||||
const roomViewRef = useRef(null);
|
const roomViewRef = useRef(null);
|
||||||
// eslint-disable-next-line react/prop-types
|
// eslint-disable-next-line react/prop-types
|
||||||
const { roomId } = roomTimeline;
|
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(() => {
|
useEffect(() => {
|
||||||
const settingsToggle = (isVisible) => {
|
const settingsToggle = (isVisible) => {
|
||||||
const roomView = roomViewRef.current;
|
const roomView = roomViewRef.current;
|
||||||
|
@ -47,23 +61,36 @@ function RoomView({ roomTimeline, eventId }) {
|
||||||
<RoomViewContent
|
<RoomViewContent
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
roomTimeline={roomTimeline}
|
roomTimeline={roomTimeline}
|
||||||
|
roomInputRef={roomInputRef}
|
||||||
/>
|
/>
|
||||||
<RoomViewFloating
|
<RoomViewFloating roomId={roomId} roomTimeline={roomTimeline} />
|
||||||
roomId={roomId}
|
|
||||||
roomTimeline={roomTimeline}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="room-view__sticky">
|
<div className="room-view__sticky">
|
||||||
<RoomViewInput
|
<div className="room-view__editor">
|
||||||
roomId={roomId}
|
{tombstoneEvent ? (
|
||||||
roomTimeline={roomTimeline}
|
<RoomTombstone
|
||||||
viewEvent={viewEvent}
|
roomId={roomId}
|
||||||
/>
|
body={tombstoneEvent.getContent().body}
|
||||||
<RoomViewCmdBar
|
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
||||||
roomId={roomId}
|
/>
|
||||||
roomTimeline={roomTimeline}
|
) : (
|
||||||
viewEvent={viewEvent}
|
<>
|
||||||
/>
|
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -74,6 +101,7 @@ RoomView.defaultProps = {
|
||||||
eventId: null,
|
eventId: null,
|
||||||
};
|
};
|
||||||
RoomView.propTypes = {
|
RoomView.propTypes = {
|
||||||
|
room: PropTypes.shape({}).isRequired,
|
||||||
roomTimeline: PropTypes.shape({}).isRequired,
|
roomTimeline: PropTypes.shape({}).isRequired,
|
||||||
eventId: PropTypes.string,
|
eventId: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
|
@ -37,9 +37,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&__sticky {
|
&__sticky {
|
||||||
min-height: 85px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border-top: 1px solid var(--bg-surface-border);
|
}
|
||||||
|
&__editor {
|
||||||
|
padding: 0 var(--sp-normal);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -28,6 +28,7 @@ import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||||
import { parseTimelineChange } from './common';
|
import { parseTimelineChange } from './common';
|
||||||
import TimelineScroll from './TimelineScroll';
|
import TimelineScroll from './TimelineScroll';
|
||||||
import EventLimit from './EventLimit';
|
import EventLimit from './EventLimit';
|
||||||
|
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||||
|
|
||||||
const PAG_LIMIT = 30;
|
const PAG_LIMIT = 30;
|
||||||
const MAX_MSG_DIFF_MINUTES = 5;
|
const MAX_MSG_DIFF_MINUTES = 5;
|
||||||
|
@ -392,7 +393,7 @@ function useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, event
|
||||||
|
|
||||||
let jumpToItemIndex = -1;
|
let jumpToItemIndex = -1;
|
||||||
|
|
||||||
function RoomViewContent({ eventId, roomTimeline }) {
|
function RoomViewContent({ roomInputRef, eventId, roomTimeline }) {
|
||||||
const [throttle] = useState(new Throttle());
|
const [throttle] = useState(new Throttle());
|
||||||
|
|
||||||
const timelineSVRef = useRef(null);
|
const timelineSVRef = useRef(null);
|
||||||
|
@ -484,6 +485,21 @@ function RoomViewContent({ eventId, roomTimeline }) {
|
||||||
}
|
}
|
||||||
}, [newEvent]);
|
}, [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) => {
|
const listenKeyboard = useCallback((event) => {
|
||||||
if (event.ctrlKey || event.altKey || event.metaKey) return;
|
if (event.ctrlKey || event.altKey || event.metaKey) return;
|
||||||
if (event.key !== 'ArrowUp') return;
|
if (event.key !== 'ArrowUp') return;
|
||||||
|
@ -620,6 +636,9 @@ RoomViewContent.defaultProps = {
|
||||||
RoomViewContent.propTypes = {
|
RoomViewContent.propTypes = {
|
||||||
eventId: PropTypes.string,
|
eventId: PropTypes.string,
|
||||||
roomTimeline: PropTypes.shape({}).isRequired,
|
roomTimeline: PropTypes.shape({}).isRequired,
|
||||||
|
roomInputRef: PropTypes.shape({
|
||||||
|
current: PropTypes.shape({})
|
||||||
|
}).isRequired
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RoomViewContent;
|
export default RoomViewContent;
|
||||||
|
|
148
src/app/organisms/room/msgContent.ts
Normal file
148
src/app/organisms/room/msgContent.ts
Normal 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;
|
||||||
|
};
|
|
@ -1,4 +1,5 @@
|
||||||
import React from 'react';
|
import React, { StrictMode } from 'react';
|
||||||
|
import { Provider } from 'jotai';
|
||||||
|
|
||||||
import { isAuthenticated } from '../../client/state/auth';
|
import { isAuthenticated } from '../../client/state/auth';
|
||||||
|
|
||||||
|
@ -6,7 +7,11 @@ import Auth from '../templates/auth/Auth';
|
||||||
import Client from '../templates/client/Client';
|
import Client from '../templates/client/Client';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return isAuthenticated() ? <Client /> : <Auth />;
|
return (
|
||||||
|
<StrictMode>
|
||||||
|
<Provider>{isAuthenticated() ? <Client /> : <Auth />}</Provider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
293
src/app/plugins/custom-emoji.ts
Normal file
293
src/app/plugins/custom-emoji.ts
Normal file
|
@ -0,0 +1,293 @@
|
||||||
|
import { IImageInfo, MatrixClient, Room } from 'matrix-js-sdk';
|
||||||
|
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||||
|
import { getAccountData, getStateEvents } from '../utils/room';
|
||||||
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
|
|
||||||
|
// https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
|
||||||
|
|
||||||
|
export type PackEventIdToUnknown = Record<string, unknown>;
|
||||||
|
export type EmoteRoomIdToPackEvents = Record<string, PackEventIdToUnknown>;
|
||||||
|
export type EmoteRoomsContent = {
|
||||||
|
rooms?: EmoteRoomIdToPackEvents;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum PackUsage {
|
||||||
|
Emoticon = 'emoticon',
|
||||||
|
Sticker = 'sticker',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PackImage = {
|
||||||
|
url: string;
|
||||||
|
body?: string;
|
||||||
|
usage?: PackUsage[];
|
||||||
|
info?: IImageInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PackImages = Record<string, PackImage>;
|
||||||
|
|
||||||
|
export type PackMeta = {
|
||||||
|
display_name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
attribution?: string;
|
||||||
|
usage?: PackUsage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExtendedPackImage = PackImage & {
|
||||||
|
shortcode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PackContent = {
|
||||||
|
pack?: PackMeta;
|
||||||
|
images?: PackImages;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ImagePack {
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
public content: PackContent;
|
||||||
|
|
||||||
|
public displayName?: string;
|
||||||
|
|
||||||
|
public avatarUrl?: string;
|
||||||
|
|
||||||
|
public usage?: PackUsage[];
|
||||||
|
|
||||||
|
public attribution?: string;
|
||||||
|
|
||||||
|
public images: Map<string, ExtendedPackImage>;
|
||||||
|
|
||||||
|
public emoticons: ExtendedPackImage[];
|
||||||
|
|
||||||
|
public stickers: ExtendedPackImage[];
|
||||||
|
|
||||||
|
static parsePack(eventId: string, packContent: PackContent) {
|
||||||
|
if (!eventId || typeof packContent?.images !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ImagePack(eventId, packContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(eventId: string, content: PackContent) {
|
||||||
|
this.id = eventId;
|
||||||
|
this.content = JSON.parse(JSON.stringify(content));
|
||||||
|
|
||||||
|
this.images = new Map();
|
||||||
|
this.emoticons = [];
|
||||||
|
this.stickers = [];
|
||||||
|
|
||||||
|
this.applyPackMeta(content);
|
||||||
|
this.applyImages(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPackMeta(content: PackContent) {
|
||||||
|
const pack = content.pack ?? {};
|
||||||
|
|
||||||
|
this.displayName = pack.display_name;
|
||||||
|
this.avatarUrl = pack.avatar_url;
|
||||||
|
this.usage = pack.usage ?? [PackUsage.Emoticon, PackUsage.Sticker];
|
||||||
|
this.attribution = pack.attribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyImages(content: PackContent) {
|
||||||
|
this.images = new Map();
|
||||||
|
this.emoticons = [];
|
||||||
|
this.stickers = [];
|
||||||
|
if (!content.images) return;
|
||||||
|
|
||||||
|
Object.entries(content.images).forEach(([shortcode, data]) => {
|
||||||
|
const { url } = data;
|
||||||
|
const body = data.body ?? shortcode;
|
||||||
|
const usage = data.usage ?? this.usage;
|
||||||
|
const { info } = data;
|
||||||
|
|
||||||
|
if (!url) return;
|
||||||
|
const image: ExtendedPackImage = {
|
||||||
|
shortcode,
|
||||||
|
url,
|
||||||
|
body,
|
||||||
|
usage,
|
||||||
|
info,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.images.set(shortcode, image);
|
||||||
|
if (usage && usage.includes(PackUsage.Emoticon)) {
|
||||||
|
this.emoticons.push(image);
|
||||||
|
}
|
||||||
|
if (usage && usage.includes(PackUsage.Sticker)) {
|
||||||
|
this.stickers.push(image);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getImages() {
|
||||||
|
return this.images;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEmojis() {
|
||||||
|
return this.emoticons;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStickers() {
|
||||||
|
return this.stickers;
|
||||||
|
}
|
||||||
|
|
||||||
|
getImagesFor(usage: PackUsage) {
|
||||||
|
if (usage === PackUsage.Emoticon) return this.getEmojis();
|
||||||
|
if (usage === PackUsage.Sticker) return this.getStickers();
|
||||||
|
return this.getEmojis();
|
||||||
|
}
|
||||||
|
|
||||||
|
getContent() {
|
||||||
|
return this.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPackAvatarUrl(usage: PackUsage): string | undefined {
|
||||||
|
return this.avatarUrl || this.getImagesFor(usage)[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePackProperty<K extends keyof PackMeta>(property: K, value: PackMeta[K]) {
|
||||||
|
if (this.content.pack === undefined) {
|
||||||
|
this.content.pack = {};
|
||||||
|
}
|
||||||
|
this.content.pack[property] = value;
|
||||||
|
this.applyPackMeta(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvatarUrl(avatarUrl?: string) {
|
||||||
|
this.updatePackProperty('avatar_url', avatarUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisplayName(displayName?: string) {
|
||||||
|
this.updatePackProperty('display_name', displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribution(attribution?: string) {
|
||||||
|
this.updatePackProperty('attribution', attribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUsage(usage?: PackUsage[]) {
|
||||||
|
this.updatePackProperty('usage', usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
addImage(key: string, imgContent: PackImage) {
|
||||||
|
this.content.images = {
|
||||||
|
[key]: imgContent,
|
||||||
|
...this.content.images,
|
||||||
|
};
|
||||||
|
this.applyImages(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeImage(key: string) {
|
||||||
|
if (!this.content.images) return;
|
||||||
|
if (this.content.images[key] === undefined) return;
|
||||||
|
delete this.content.images[key];
|
||||||
|
this.applyImages(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateImageKey(key: string, newKey: string) {
|
||||||
|
const { images } = this.content;
|
||||||
|
if (!images) return;
|
||||||
|
if (images[key] === undefined) return;
|
||||||
|
const copyImages: PackImages = {};
|
||||||
|
Object.keys(images).forEach((imgKey) => {
|
||||||
|
copyImages[imgKey === key ? newKey : imgKey] = images[imgKey];
|
||||||
|
});
|
||||||
|
this.content.images = copyImages;
|
||||||
|
this.applyImages(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateImageProperty<K extends keyof PackImage>(
|
||||||
|
key: string,
|
||||||
|
property: K,
|
||||||
|
value: PackImage[K]
|
||||||
|
) {
|
||||||
|
if (!this.content.images) return;
|
||||||
|
if (this.content.images[key] === undefined) return;
|
||||||
|
this.content.images[key][property] = value;
|
||||||
|
this.applyImages(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageUrl(key: string, url: string) {
|
||||||
|
this.updateImageProperty(key, 'url', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageBody(key: string, body?: string) {
|
||||||
|
this.updateImageProperty(key, 'body', body);
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageInfo(key: string, info?: IImageInfo) {
|
||||||
|
this.updateImageProperty(key, 'info', info);
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageUsage(key: string, usage?: PackUsage[]) {
|
||||||
|
this.updateImageProperty(key, 'usage', usage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoomImagePacks(room: Room): ImagePack[] {
|
||||||
|
const dataEvents = getStateEvents(room, StateEvent.PoniesRoomEmotes);
|
||||||
|
|
||||||
|
return dataEvents.reduce<ImagePack[]>((roomPacks, packEvent) => {
|
||||||
|
const packId = packEvent?.getId();
|
||||||
|
const content = packEvent?.getContent() as PackContent | undefined;
|
||||||
|
if (!packId || !content) return roomPacks;
|
||||||
|
const pack = ImagePack.parsePack(packId, content);
|
||||||
|
if (pack) {
|
||||||
|
roomPacks.push(pack);
|
||||||
|
}
|
||||||
|
return roomPacks;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGlobalImagePacks(mx: MatrixClient): ImagePack[] {
|
||||||
|
const emoteRoomsContent = getAccountData(mx, AccountDataEvent.PoniesEmoteRooms)?.getContent() as
|
||||||
|
| EmoteRoomsContent
|
||||||
|
| undefined;
|
||||||
|
if (typeof emoteRoomsContent !== 'object') return [];
|
||||||
|
|
||||||
|
const { rooms } = emoteRoomsContent;
|
||||||
|
if (typeof rooms !== 'object') return [];
|
||||||
|
|
||||||
|
const roomIds = Object.keys(rooms);
|
||||||
|
|
||||||
|
const packs = roomIds.flatMap((roomId) => {
|
||||||
|
if (typeof rooms[roomId] !== 'object') return [];
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return [];
|
||||||
|
return getRoomImagePacks(room);
|
||||||
|
});
|
||||||
|
|
||||||
|
return packs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserImagePack(mx: MatrixClient): ImagePack | undefined {
|
||||||
|
const userPackContent = getAccountData(mx, AccountDataEvent.PoniesUserEmotes)?.getContent() as
|
||||||
|
| PackContent
|
||||||
|
| undefined;
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
if (!userPackContent || !userId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userImagePack = ImagePack.parsePack(userId, userPackContent);
|
||||||
|
return userImagePack;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {MatrixClient} mx Provide if you want to include user personal/global pack
|
||||||
|
* @param {Room[]} rooms Provide rooms if you want to include rooms pack
|
||||||
|
* @returns {ImagePack[]} packs
|
||||||
|
*/
|
||||||
|
export function getRelevantPacks(mx?: MatrixClient, rooms?: Room[]): ImagePack[] {
|
||||||
|
const userPack = mx && getUserImagePack(mx);
|
||||||
|
const userPacks = userPack ? [userPack] : [];
|
||||||
|
const globalPacks = mx ? getGlobalImagePacks(mx) : [];
|
||||||
|
const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
|
||||||
|
const roomsPack = rooms?.flatMap(getRoomImagePacks) ?? [];
|
||||||
|
|
||||||
|
return userPacks.concat(
|
||||||
|
globalPacks,
|
||||||
|
roomsPack.filter((pack) => !globalPackIds.has(pack.id))
|
||||||
|
);
|
||||||
|
}
|
104
src/app/plugins/emoji.ts
Normal file
104
src/app/plugins/emoji.ts
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
import { CompactEmoji } from 'emojibase';
|
||||||
|
import emojisData from 'emojibase-data/en/compact.json';
|
||||||
|
import joypixels from 'emojibase-data/en/shortcodes/joypixels.json';
|
||||||
|
import emojibase from 'emojibase-data/en/shortcodes/emojibase.json';
|
||||||
|
|
||||||
|
export type IEmoji = CompactEmoji & {
|
||||||
|
shortcode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum EmojiGroupId {
|
||||||
|
People = 'People',
|
||||||
|
Nature = 'Nature',
|
||||||
|
Food = 'Food',
|
||||||
|
Activity = 'Activity',
|
||||||
|
Travel = 'Travel',
|
||||||
|
Object = 'Object',
|
||||||
|
Symbol = 'Symbol',
|
||||||
|
Flag = 'Flag',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IEmojiGroup = {
|
||||||
|
id: EmojiGroupId;
|
||||||
|
order: number;
|
||||||
|
emojis: IEmoji[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emojiGroups: IEmojiGroup[] = [
|
||||||
|
{
|
||||||
|
id: EmojiGroupId.People,
|
||||||
|
order: 0,
|
||||||
|
emojis: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: EmojiGroupId.Nature,
|
||||||
|
order: 1,
|
||||||
|
emojis: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: EmojiGroupId.Food,
|
||||||
|
order: 2,
|
||||||
|
emojis: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: EmojiGroupId.Activity,
|
||||||
|
order: 3,
|
||||||
|
emojis: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: EmojiGroupId.Travel,
|
||||||
|
order: 4,
|
||||||
|
emojis: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: EmojiGroupId.Object,
|
||||||
|
order: 5,
|
||||||
|
emojis: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: EmojiGroupId.Symbol,
|
||||||
|
order: 6,
|
||||||
|
emojis: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: EmojiGroupId.Flag,
|
||||||
|
order: 7,
|
||||||
|
emojis: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const emojis: IEmoji[] = [];
|
||||||
|
|
||||||
|
function addEmojiToGroup(groupIndex: number, emoji: IEmoji) {
|
||||||
|
emojiGroups[groupIndex].emojis.push(emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupIndex(emoji: IEmoji): number | undefined {
|
||||||
|
if (emoji.group === 0 || emoji.group === 1) return 0;
|
||||||
|
if (emoji.group === 3) return 1;
|
||||||
|
if (emoji.group === 4) return 2;
|
||||||
|
if (emoji.group === 6) return 3;
|
||||||
|
if (emoji.group === 5) return 4;
|
||||||
|
if (emoji.group === 7) return 5;
|
||||||
|
if (emoji.group === 8 || typeof emoji.group === 'undefined') return 6;
|
||||||
|
if (emoji.group === 9) return 7;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
emojisData.forEach((emoji) => {
|
||||||
|
const myShortCodes = joypixels[emoji.hexcode] || emojibase[emoji.hexcode];
|
||||||
|
if (!myShortCodes) return;
|
||||||
|
if (Array.isArray(myShortCodes) && myShortCodes.length === 0) return;
|
||||||
|
|
||||||
|
const em: IEmoji = {
|
||||||
|
...emoji,
|
||||||
|
shortcode: Array.isArray(myShortCodes) ? myShortCodes[0] : myShortCodes,
|
||||||
|
shortcodes: Array.isArray(myShortCodes) ? myShortCodes : emoji.shortcodes,
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupIndex = getGroupIndex(em);
|
||||||
|
if (groupIndex !== undefined) {
|
||||||
|
addEmojiToGroup(groupIndex, em);
|
||||||
|
emojis.push(em);
|
||||||
|
}
|
||||||
|
});
|
44
src/app/plugins/recent-emoji.ts
Normal file
44
src/app/plugins/recent-emoji.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { getAccountData } from '../utils/room';
|
||||||
|
import { IEmoji, emojis } from './emoji';
|
||||||
|
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||||
|
|
||||||
|
type EmojiUnicode = string;
|
||||||
|
type EmojiUsageCount = number;
|
||||||
|
|
||||||
|
export type IRecentEmojiContent = {
|
||||||
|
recent_emoji?: [EmojiUnicode, EmojiUsageCount][];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRecentEmojis = (mx: MatrixClient, limit?: number): IEmoji[] => {
|
||||||
|
const recentEmojiEvent = getAccountData(mx, AccountDataEvent.ElementRecentEmoji);
|
||||||
|
const recentEmoji = recentEmojiEvent?.getContent<IRecentEmojiContent>().recent_emoji;
|
||||||
|
if (!Array.isArray(recentEmoji)) return [];
|
||||||
|
|
||||||
|
return recentEmoji
|
||||||
|
.sort((e1, e2) => e2[1] - e1[1])
|
||||||
|
.slice(0, limit)
|
||||||
|
.reduce<IEmoji[]>((list, [unicode]) => {
|
||||||
|
const emoji = emojis.find((e) => e.unicode === unicode);
|
||||||
|
if (emoji) list.push(emoji);
|
||||||
|
return list;
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function addRecentEmoji(mx: MatrixClient, unicode: string) {
|
||||||
|
const recentEmojiEvent = getAccountData(mx, AccountDataEvent.ElementRecentEmoji);
|
||||||
|
const recentEmoji = recentEmojiEvent?.getContent<IRecentEmojiContent>().recent_emoji ?? [];
|
||||||
|
|
||||||
|
const emojiIndex = recentEmoji.findIndex(([u]) => u === unicode);
|
||||||
|
let entry: [EmojiUnicode, EmojiUsageCount];
|
||||||
|
if (emojiIndex < 0) {
|
||||||
|
entry = [unicode, 1];
|
||||||
|
} else {
|
||||||
|
[entry] = recentEmoji.splice(emojiIndex, 1);
|
||||||
|
entry[1] += 1;
|
||||||
|
}
|
||||||
|
recentEmoji.unshift(entry);
|
||||||
|
mx.setAccountData(AccountDataEvent.ElementRecentEmoji, {
|
||||||
|
recent_emoji: recentEmoji.slice(0, 100),
|
||||||
|
});
|
||||||
|
}
|
63
src/app/state/hooks/inviteList.ts
Normal file
63
src/app/state/hooks/inviteList.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { useAtomValue, WritableAtom } from 'jotai';
|
||||||
|
import { selectAtom } from 'jotai/utils';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { isDirectInvite, isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
|
||||||
|
import { compareRoomsEqual, RoomsAction } from '../utils';
|
||||||
|
import { MDirectAction } from '../mDirectList';
|
||||||
|
|
||||||
|
export const useSpaceInvites = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
allInvitesAtom: WritableAtom<string[], RoomsAction>
|
||||||
|
) => {
|
||||||
|
const selector = useCallback(
|
||||||
|
(rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))),
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRoomInvites = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
allInvitesAtom: WritableAtom<string[], RoomsAction>,
|
||||||
|
mDirectAtom: WritableAtom<Set<string>, MDirectAction>
|
||||||
|
) => {
|
||||||
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const selector = useCallback(
|
||||||
|
(rooms: string[]) =>
|
||||||
|
rooms.filter(
|
||||||
|
(roomId) =>
|
||||||
|
isRoom(mx.getRoom(roomId)) &&
|
||||||
|
!(mDirects.has(roomId) || isDirectInvite(mx.getRoom(roomId), mx.getUserId()))
|
||||||
|
),
|
||||||
|
[mx, mDirects]
|
||||||
|
);
|
||||||
|
return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDirectInvites = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
allInvitesAtom: WritableAtom<string[], RoomsAction>,
|
||||||
|
mDirectAtom: WritableAtom<Set<string>, MDirectAction>
|
||||||
|
) => {
|
||||||
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const selector = useCallback(
|
||||||
|
(rooms: string[]) =>
|
||||||
|
rooms.filter(
|
||||||
|
(roomId) => mDirects.has(roomId) || isDirectInvite(mx.getRoom(roomId), mx.getUserId())
|
||||||
|
),
|
||||||
|
[mx, mDirects]
|
||||||
|
);
|
||||||
|
return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUnsupportedInvites = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
allInvitesAtom: WritableAtom<string[], RoomsAction>
|
||||||
|
) => {
|
||||||
|
const selector = useCallback(
|
||||||
|
(rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
|
||||||
|
};
|
54
src/app/state/hooks/roomList.ts
Normal file
54
src/app/state/hooks/roomList.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import { useAtomValue, WritableAtom } from 'jotai';
|
||||||
|
import { selectAtom } from 'jotai/utils';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
|
||||||
|
import { compareRoomsEqual, RoomsAction } from '../utils';
|
||||||
|
import { MDirectAction } from '../mDirectList';
|
||||||
|
|
||||||
|
export const useSpaces = (mx: MatrixClient, allRoomsAtom: WritableAtom<string[], RoomsAction>) => {
|
||||||
|
const selector = useCallback(
|
||||||
|
(rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))),
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRooms = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
allRoomsAtom: WritableAtom<string[], RoomsAction>,
|
||||||
|
mDirectAtom: WritableAtom<Set<string>, MDirectAction>
|
||||||
|
) => {
|
||||||
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const selector = useCallback(
|
||||||
|
(rooms: string[]) =>
|
||||||
|
rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && !mDirects.has(roomId)),
|
||||||
|
[mx, mDirects]
|
||||||
|
);
|
||||||
|
return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDirects = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
allRoomsAtom: WritableAtom<string[], RoomsAction>,
|
||||||
|
mDirectAtom: WritableAtom<Set<string>, MDirectAction>
|
||||||
|
) => {
|
||||||
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const selector = useCallback(
|
||||||
|
(rooms: string[]) =>
|
||||||
|
rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && mDirects.has(roomId)),
|
||||||
|
[mx, mDirects]
|
||||||
|
);
|
||||||
|
return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUnsupportedRooms = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
allRoomsAtom: WritableAtom<string[], RoomsAction>
|
||||||
|
) => {
|
||||||
|
const selector = useCallback(
|
||||||
|
(rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
|
||||||
|
};
|
34
src/app/state/hooks/settings.ts
Normal file
34
src/app/state/hooks/settings.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { atom, useAtomValue, useSetAtom, WritableAtom } from 'jotai';
|
||||||
|
import { SetAtom } from 'jotai/core/atom';
|
||||||
|
import { selectAtom } from 'jotai/utils';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Settings } from '../settings';
|
||||||
|
|
||||||
|
export const useSetSetting = <K extends keyof Settings>(
|
||||||
|
settingsAtom: WritableAtom<Settings, Settings>,
|
||||||
|
key: K
|
||||||
|
) => {
|
||||||
|
const setterAtom = useMemo(
|
||||||
|
() =>
|
||||||
|
atom<null, Settings[K]>(null, (get, set, value) => {
|
||||||
|
const s = { ...get(settingsAtom) };
|
||||||
|
s[key] = value;
|
||||||
|
set(settingsAtom, s);
|
||||||
|
}),
|
||||||
|
[settingsAtom, key]
|
||||||
|
);
|
||||||
|
|
||||||
|
return useSetAtom(setterAtom);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSetting = <K extends keyof Settings>(
|
||||||
|
settingsAtom: WritableAtom<Settings, Settings>,
|
||||||
|
key: K
|
||||||
|
): [Settings[K], SetAtom<Settings[K], void>] => {
|
||||||
|
const selector = useMemo(() => (s: Settings) => s[key], [key]);
|
||||||
|
const setting = useAtomValue(selectAtom(settingsAtom, selector));
|
||||||
|
|
||||||
|
const setter = useSetSetting(settingsAtom, key);
|
||||||
|
|
||||||
|
return [setting, setter];
|
||||||
|
};
|
16
src/app/state/hooks/useBindAtoms.ts
Normal file
16
src/app/state/hooks/useBindAtoms.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { allInvitesAtom, useBindAllInvitesAtom } from '../inviteList';
|
||||||
|
import { allRoomsAtom, useBindAllRoomsAtom } from '../roomList';
|
||||||
|
import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
|
||||||
|
import { muteChangesAtom, mutedRoomsAtom, useBindMutedRoomsAtom } from '../mutedRoomList';
|
||||||
|
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../roomToUnread';
|
||||||
|
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../roomToParents';
|
||||||
|
|
||||||
|
export const useBindAtoms = (mx: MatrixClient) => {
|
||||||
|
useBindMDirectAtom(mx, mDirectAtom);
|
||||||
|
useBindAllInvitesAtom(mx, allInvitesAtom);
|
||||||
|
useBindAllRoomsAtom(mx, allRoomsAtom);
|
||||||
|
useBindRoomToParentsAtom(mx, roomToParentsAtom);
|
||||||
|
useBindMutedRoomsAtom(mx, mutedRoomsAtom);
|
||||||
|
useBindRoomToUnreadAtom(mx, roomToUnreadAtom, muteChangesAtom);
|
||||||
|
};
|
32
src/app/state/inviteList.ts
Normal file
32
src/app/state/inviteList.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { atom, WritableAtom } from 'jotai';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Membership } from '../../types/matrix/room';
|
||||||
|
import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
|
||||||
|
|
||||||
|
const baseRoomsAtom = atom<string[]>([]);
|
||||||
|
export const allInvitesAtom = atom<string[], RoomsAction>(
|
||||||
|
(get) => get(baseRoomsAtom),
|
||||||
|
(get, set, action) => {
|
||||||
|
if (action.type === 'INITIALIZE') {
|
||||||
|
set(baseRoomsAtom, action.rooms);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set(baseRoomsAtom, (ids) => {
|
||||||
|
const newIds = ids.filter((id) => id !== action.roomId);
|
||||||
|
if (action.type === 'PUT') newIds.push(action.roomId);
|
||||||
|
return newIds;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useBindAllInvitesAtom = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
allRooms: WritableAtom<string[], RoomsAction>
|
||||||
|
) => {
|
||||||
|
useBindRoomsWithMembershipsAtom(
|
||||||
|
mx,
|
||||||
|
allRooms,
|
||||||
|
useMemo(() => [Membership.Invite], [])
|
||||||
|
);
|
||||||
|
};
|
33
src/app/state/list.ts
Normal file
33
src/app/state/list.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
export type ListAction<T> =
|
||||||
|
| {
|
||||||
|
type: 'PUT';
|
||||||
|
item: T | T[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'DELETE';
|
||||||
|
item: T | T[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createListAtom = <T>() => {
|
||||||
|
const baseListAtom = atom<T[]>([]);
|
||||||
|
return atom<T[], ListAction<T>>(
|
||||||
|
(get) => get(baseListAtom),
|
||||||
|
(get, set, action) => {
|
||||||
|
const items = get(baseListAtom);
|
||||||
|
const newItems = Array.isArray(action.item) ? action.item : [action.item];
|
||||||
|
if (action.type === 'DELETE') {
|
||||||
|
set(
|
||||||
|
baseListAtom,
|
||||||
|
items.filter((item) => !newItems.includes(item))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type === 'PUT') {
|
||||||
|
set(baseListAtom, [...items, ...newItems]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export type TListAtom<T> = ReturnType<typeof createListAtom<T>>;
|
47
src/app/state/mDirectList.ts
Normal file
47
src/app/state/mDirectList.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { atom, useSetAtom, WritableAtom } from 'jotai';
|
||||||
|
import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||||
|
import { getAccountData, getMDirects } from '../utils/room';
|
||||||
|
|
||||||
|
export type MDirectAction = {
|
||||||
|
type: 'INITIALIZE' | 'UPDATE';
|
||||||
|
rooms: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseMDirectAtom = atom(new Set<string>());
|
||||||
|
export const mDirectAtom = atom<Set<string>, MDirectAction>(
|
||||||
|
(get) => get(baseMDirectAtom),
|
||||||
|
(get, set, action) => {
|
||||||
|
set(baseMDirectAtom, action.rooms);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useBindMDirectAtom = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
mDirect: WritableAtom<Set<string>, MDirectAction>
|
||||||
|
) => {
|
||||||
|
const setMDirect = useSetAtom(mDirect);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mDirectEvent = getAccountData(mx, AccountDataEvent.Direct);
|
||||||
|
if (mDirectEvent) {
|
||||||
|
setMDirect({
|
||||||
|
type: 'INITIALIZE',
|
||||||
|
rooms: getMDirects(mDirectEvent),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAccountData = (event: MatrixEvent) => {
|
||||||
|
setMDirect({
|
||||||
|
type: 'UPDATE',
|
||||||
|
rooms: getMDirects(event),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on(ClientEvent.AccountData, handleAccountData);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(ClientEvent.AccountData, handleAccountData);
|
||||||
|
};
|
||||||
|
}, [mx, setMDirect]);
|
||||||
|
};
|
101
src/app/state/mutedRoomList.ts
Normal file
101
src/app/state/mutedRoomList.ts
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
import { atom, WritableAtom, useSetAtom } from 'jotai';
|
||||||
|
import { ClientEvent, IPushRule, IPushRules, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { MuteChanges } from '../../types/matrix/room';
|
||||||
|
import { findMutedRule, isMutedRule } from '../utils/room';
|
||||||
|
|
||||||
|
export type MutedRoomsUpdate =
|
||||||
|
| {
|
||||||
|
type: 'INITIALIZE';
|
||||||
|
addRooms: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'UPDATE';
|
||||||
|
addRooms: string[];
|
||||||
|
removeRooms: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const muteChangesAtom = atom<MuteChanges>({
|
||||||
|
added: [],
|
||||||
|
removed: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseMutedRoomsAtom = atom(new Set<string>());
|
||||||
|
export const mutedRoomsAtom = atom<Set<string>, MutedRoomsUpdate>(
|
||||||
|
(get) => get(baseMutedRoomsAtom),
|
||||||
|
(get, set, action) => {
|
||||||
|
const mutedRooms = new Set([...get(mutedRoomsAtom)]);
|
||||||
|
if (action.type === 'INITIALIZE') {
|
||||||
|
set(baseMutedRoomsAtom, new Set([...action.addRooms]));
|
||||||
|
set(muteChangesAtom, {
|
||||||
|
added: [...action.addRooms],
|
||||||
|
removed: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type === 'UPDATE') {
|
||||||
|
action.removeRooms.forEach((roomId) => mutedRooms.delete(roomId));
|
||||||
|
action.addRooms.forEach((roomId) => mutedRooms.add(roomId));
|
||||||
|
set(baseMutedRoomsAtom, mutedRooms);
|
||||||
|
set(muteChangesAtom, {
|
||||||
|
added: [...action.addRooms],
|
||||||
|
removed: [...action.removeRooms],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useBindMutedRoomsAtom = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
mutedAtom: WritableAtom<Set<string>, MutedRoomsUpdate>
|
||||||
|
) => {
|
||||||
|
const setMuted = useSetAtom(mutedAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const overrideRules = mx.getAccountData('m.push_rules')?.getContent<IPushRules>()
|
||||||
|
?.global?.override;
|
||||||
|
if (overrideRules) {
|
||||||
|
const mutedRooms = overrideRules.reduce<string[]>((rooms, rule) => {
|
||||||
|
if (isMutedRule(rule)) rooms.push(rule.rule_id);
|
||||||
|
return rooms;
|
||||||
|
}, []);
|
||||||
|
setMuted({
|
||||||
|
type: 'INITIALIZE',
|
||||||
|
addRooms: mutedRooms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [mx, setMuted]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePushRules = (mEvent: MatrixEvent, oldMEvent?: MatrixEvent) => {
|
||||||
|
if (mEvent.getType() === 'm.push_rules') {
|
||||||
|
const override = mEvent?.getContent()?.global?.override as IPushRule[] | undefined;
|
||||||
|
const oldOverride = oldMEvent?.getContent()?.global?.override as IPushRule[] | undefined;
|
||||||
|
if (!override || !oldOverride) return;
|
||||||
|
|
||||||
|
const isMuteToggled = (rule: IPushRule, otherOverride: IPushRule[]) => {
|
||||||
|
const roomId = rule.rule_id;
|
||||||
|
|
||||||
|
const isMuted = isMutedRule(rule);
|
||||||
|
if (!isMuted) return false;
|
||||||
|
const isOtherMuted = findMutedRule(otherOverride, roomId);
|
||||||
|
if (isOtherMuted) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutedRules = override.filter((rule) => isMuteToggled(rule, oldOverride));
|
||||||
|
const unMutedRules = oldOverride.filter((rule) => isMuteToggled(rule, override));
|
||||||
|
|
||||||
|
setMuted({
|
||||||
|
type: 'UPDATE',
|
||||||
|
addRooms: mutedRules.map((rule) => rule.rule_id),
|
||||||
|
removeRooms: unMutedRules.map((rule) => rule.rule_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mx.on(ClientEvent.AccountData, handlePushRules);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(ClientEvent.AccountData, handlePushRules);
|
||||||
|
};
|
||||||
|
}, [mx, setMuted]);
|
||||||
|
};
|
48
src/app/state/roomInputDrafts.ts
Normal file
48
src/app/state/roomInputDrafts.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
import { atomFamily } from 'jotai/utils';
|
||||||
|
import { Descendant } from 'slate';
|
||||||
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
|
import { TListAtom, createListAtom } from './list';
|
||||||
|
import { createUploadAtomFamily } from './upload';
|
||||||
|
import { TUploadContent } from '../utils/matrix';
|
||||||
|
|
||||||
|
export const roomUploadAtomFamily = createUploadAtomFamily();
|
||||||
|
|
||||||
|
export type TUploadItem = {
|
||||||
|
file: TUploadContent;
|
||||||
|
originalFile: TUploadContent;
|
||||||
|
encInfo: EncryptedAttachmentInfo | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const roomIdToUploadItemsAtomFamily = atomFamily<string, TListAtom<TUploadItem>>(
|
||||||
|
createListAtom
|
||||||
|
);
|
||||||
|
|
||||||
|
export type RoomIdToMsgAction =
|
||||||
|
| {
|
||||||
|
type: 'PUT';
|
||||||
|
roomId: string;
|
||||||
|
msg: Descendant[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'DELETE';
|
||||||
|
roomId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMsgDraftAtom = () => atom<Descendant[]>([]);
|
||||||
|
export type TMsgDraftAtom = ReturnType<typeof createMsgDraftAtom>;
|
||||||
|
export const roomIdToMsgDraftAtomFamily = atomFamily<string, TMsgDraftAtom>(() =>
|
||||||
|
createMsgDraftAtom()
|
||||||
|
);
|
||||||
|
|
||||||
|
export type IReplyDraft = {
|
||||||
|
userId: string;
|
||||||
|
eventId: string;
|
||||||
|
body: string;
|
||||||
|
formattedBody?: string;
|
||||||
|
};
|
||||||
|
const createReplyDraftAtom = () => atom<IReplyDraft | undefined>(undefined);
|
||||||
|
export type TReplyDraftAtom = ReturnType<typeof createReplyDraftAtom>;
|
||||||
|
export const roomIdToReplyDraftAtomFamily = atomFamily<string, TReplyDraftAtom>(() =>
|
||||||
|
createReplyDraftAtom()
|
||||||
|
);
|
31
src/app/state/roomList.ts
Normal file
31
src/app/state/roomList.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import { atom, WritableAtom } from 'jotai';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Membership } from '../../types/matrix/room';
|
||||||
|
import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
|
||||||
|
|
||||||
|
const baseRoomsAtom = atom<string[]>([]);
|
||||||
|
export const allRoomsAtom = atom<string[], RoomsAction>(
|
||||||
|
(get) => get(baseRoomsAtom),
|
||||||
|
(get, set, action) => {
|
||||||
|
if (action.type === 'INITIALIZE') {
|
||||||
|
set(baseRoomsAtom, action.rooms);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set(baseRoomsAtom, (ids) => {
|
||||||
|
const newIds = ids.filter((id) => id !== action.roomId);
|
||||||
|
if (action.type === 'PUT') newIds.push(action.roomId);
|
||||||
|
return newIds;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
export const useBindAllRoomsAtom = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
allRooms: WritableAtom<string[], RoomsAction>
|
||||||
|
) => {
|
||||||
|
useBindRoomsWithMembershipsAtom(
|
||||||
|
mx,
|
||||||
|
allRooms,
|
||||||
|
useMemo(() => [Membership.Join], [])
|
||||||
|
);
|
||||||
|
};
|
120
src/app/state/roomToParents.ts
Normal file
120
src/app/state/roomToParents.ts
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
import produce from 'immer';
|
||||||
|
import { atom, useSetAtom, WritableAtom } from 'jotai';
|
||||||
|
import {
|
||||||
|
ClientEvent,
|
||||||
|
MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomStateEvent,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Membership, RoomToParents, StateEvent } from '../../types/matrix/room';
|
||||||
|
import {
|
||||||
|
getRoomToParents,
|
||||||
|
getSpaceChildren,
|
||||||
|
isSpace,
|
||||||
|
isValidChild,
|
||||||
|
mapParentWithChildren,
|
||||||
|
} from '../utils/room';
|
||||||
|
|
||||||
|
export type RoomToParentsAction =
|
||||||
|
| {
|
||||||
|
type: 'INITIALIZE';
|
||||||
|
roomToParents: RoomToParents;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'PUT';
|
||||||
|
parent: string;
|
||||||
|
children: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'DELETE';
|
||||||
|
roomId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseRoomToParents = atom<RoomToParents>(new Map());
|
||||||
|
export const roomToParentsAtom = atom<RoomToParents, RoomToParentsAction>(
|
||||||
|
(get) => get(baseRoomToParents),
|
||||||
|
(get, set, action) => {
|
||||||
|
if (action.type === 'INITIALIZE') {
|
||||||
|
set(baseRoomToParents, action.roomToParents);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type === 'PUT') {
|
||||||
|
set(
|
||||||
|
baseRoomToParents,
|
||||||
|
produce(get(baseRoomToParents), (draftRoomToParents) => {
|
||||||
|
mapParentWithChildren(draftRoomToParents, action.parent, action.children);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type === 'DELETE') {
|
||||||
|
set(
|
||||||
|
baseRoomToParents,
|
||||||
|
produce(get(baseRoomToParents), (draftRoomToParents) => {
|
||||||
|
const noParentRooms: string[] = [];
|
||||||
|
draftRoomToParents.delete(action.roomId);
|
||||||
|
draftRoomToParents.forEach((parents, child) => {
|
||||||
|
parents.delete(action.roomId);
|
||||||
|
if (parents.size === 0) noParentRooms.push(child);
|
||||||
|
});
|
||||||
|
noParentRooms.forEach((room) => draftRoomToParents.delete(room));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useBindRoomToParentsAtom = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomToParents: WritableAtom<RoomToParents, RoomToParentsAction>
|
||||||
|
) => {
|
||||||
|
const setRoomToParents = useSetAtom(roomToParents);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRoomToParents({ type: 'INITIALIZE', roomToParents: getRoomToParents(mx) });
|
||||||
|
|
||||||
|
const handleAddRoom = (room: Room) => {
|
||||||
|
if (isSpace(room) && room.getMyMembership() !== Membership.Invite) {
|
||||||
|
setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMembershipChange = (room: Room, membership: string) => {
|
||||||
|
if (isSpace(room) && membership === Membership.Join) {
|
||||||
|
setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStateChange = (mEvent: MatrixEvent) => {
|
||||||
|
if (mEvent.getType() === StateEvent.SpaceChild) {
|
||||||
|
const childId = mEvent.getStateKey();
|
||||||
|
const roomId = mEvent.getRoomId();
|
||||||
|
if (childId && roomId) {
|
||||||
|
if (isValidChild(mEvent)) {
|
||||||
|
setRoomToParents({ type: 'PUT', parent: roomId, children: [childId] });
|
||||||
|
} else {
|
||||||
|
setRoomToParents({ type: 'DELETE', roomId: childId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRoom = (roomId: string) => {
|
||||||
|
setRoomToParents({ type: 'DELETE', roomId });
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on(ClientEvent.Room, handleAddRoom);
|
||||||
|
mx.on(RoomEvent.MyMembership, handleMembershipChange);
|
||||||
|
mx.on(RoomStateEvent.Events, handleStateChange);
|
||||||
|
mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(ClientEvent.Room, handleAddRoom);
|
||||||
|
mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
|
||||||
|
mx.removeListener(RoomStateEvent.Events, handleStateChange);
|
||||||
|
mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
|
||||||
|
};
|
||||||
|
}, [mx, setRoomToParents]);
|
||||||
|
};
|
219
src/app/state/roomToUnread.ts
Normal file
219
src/app/state/roomToUnread.ts
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
import produce from 'immer';
|
||||||
|
import { atom, useSetAtom, PrimitiveAtom, WritableAtom, useAtomValue } from 'jotai';
|
||||||
|
import { IRoomTimelineData, MatrixClient, MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
|
||||||
|
import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
MuteChanges,
|
||||||
|
Membership,
|
||||||
|
NotificationType,
|
||||||
|
RoomToUnread,
|
||||||
|
UnreadInfo,
|
||||||
|
} from '../../types/matrix/room';
|
||||||
|
import {
|
||||||
|
getAllParents,
|
||||||
|
getNotificationType,
|
||||||
|
getUnreadInfo,
|
||||||
|
getUnreadInfos,
|
||||||
|
isNotificationEvent,
|
||||||
|
roomHaveUnread,
|
||||||
|
} from '../utils/room';
|
||||||
|
import { roomToParentsAtom } from './roomToParents';
|
||||||
|
|
||||||
|
export type RoomToUnreadAction =
|
||||||
|
| {
|
||||||
|
type: 'RESET';
|
||||||
|
unreadInfos: UnreadInfo[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'PUT';
|
||||||
|
unreadInfo: UnreadInfo;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'DELETE';
|
||||||
|
roomId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const putUnreadInfo = (
|
||||||
|
roomToUnread: RoomToUnread,
|
||||||
|
allParents: Set<string>,
|
||||||
|
unreadInfo: UnreadInfo
|
||||||
|
) => {
|
||||||
|
const oldUnread = roomToUnread.get(unreadInfo.roomId) ?? { highlight: 0, total: 0, from: null };
|
||||||
|
roomToUnread.set(unreadInfo.roomId, {
|
||||||
|
highlight: unreadInfo.highlight,
|
||||||
|
total: unreadInfo.total,
|
||||||
|
from: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newH = unreadInfo.highlight - oldUnread.highlight;
|
||||||
|
const newT = unreadInfo.total - oldUnread.total;
|
||||||
|
|
||||||
|
allParents.forEach((parentId) => {
|
||||||
|
const oldParentUnread = roomToUnread.get(parentId) ?? { highlight: 0, total: 0, from: null };
|
||||||
|
roomToUnread.set(parentId, {
|
||||||
|
highlight: (oldParentUnread.highlight += newH),
|
||||||
|
total: (oldParentUnread.total += newT),
|
||||||
|
from: new Set([...(oldParentUnread.from ?? []), unreadInfo.roomId]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, roomId: string) => {
|
||||||
|
const oldUnread = roomToUnread.get(roomId);
|
||||||
|
if (!oldUnread) return;
|
||||||
|
roomToUnread.delete(roomId);
|
||||||
|
|
||||||
|
allParents.forEach((parentId) => {
|
||||||
|
const oldParentUnread = roomToUnread.get(parentId);
|
||||||
|
if (!oldParentUnread) return;
|
||||||
|
const newFrom = new Set([...(oldParentUnread.from ?? roomId)]);
|
||||||
|
newFrom.delete(roomId);
|
||||||
|
if (newFrom.size === 0) {
|
||||||
|
roomToUnread.delete(parentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
roomToUnread.set(parentId, {
|
||||||
|
highlight: oldParentUnread.highlight - oldUnread.highlight,
|
||||||
|
total: oldParentUnread.total - oldUnread.total,
|
||||||
|
from: newFrom,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseRoomToUnread = atom<RoomToUnread>(new Map());
|
||||||
|
export const roomToUnreadAtom = atom<RoomToUnread, RoomToUnreadAction>(
|
||||||
|
(get) => get(baseRoomToUnread),
|
||||||
|
(get, set, action) => {
|
||||||
|
if (action.type === 'RESET') {
|
||||||
|
const draftRoomToUnread: RoomToUnread = new Map();
|
||||||
|
action.unreadInfos.forEach((unreadInfo) => {
|
||||||
|
putUnreadInfo(
|
||||||
|
draftRoomToUnread,
|
||||||
|
getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
|
||||||
|
unreadInfo
|
||||||
|
);
|
||||||
|
});
|
||||||
|
set(baseRoomToUnread, draftRoomToUnread);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type === 'PUT') {
|
||||||
|
set(
|
||||||
|
baseRoomToUnread,
|
||||||
|
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
|
||||||
|
putUnreadInfo(
|
||||||
|
draftRoomToUnread,
|
||||||
|
getAllParents(get(roomToParentsAtom), action.unreadInfo.roomId),
|
||||||
|
action.unreadInfo
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type === 'DELETE' && get(baseRoomToUnread).has(action.roomId)) {
|
||||||
|
set(
|
||||||
|
baseRoomToUnread,
|
||||||
|
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
|
||||||
|
deleteUnreadInfo(
|
||||||
|
draftRoomToUnread,
|
||||||
|
getAllParents(get(roomToParentsAtom), action.roomId),
|
||||||
|
action.roomId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useBindRoomToUnreadAtom = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
unreadAtom: WritableAtom<RoomToUnread, RoomToUnreadAction>,
|
||||||
|
muteChangesAtom: PrimitiveAtom<MuteChanges>
|
||||||
|
) => {
|
||||||
|
const setUnreadAtom = useSetAtom(unreadAtom);
|
||||||
|
const muteChanges = useAtomValue(muteChangesAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUnreadAtom({
|
||||||
|
type: 'RESET',
|
||||||
|
unreadInfos: getUnreadInfos(mx),
|
||||||
|
});
|
||||||
|
}, [mx, setUnreadAtom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTimelineEvent = (
|
||||||
|
mEvent: MatrixEvent,
|
||||||
|
room: Room | undefined,
|
||||||
|
toStartOfTimeline: boolean | undefined,
|
||||||
|
removed: boolean,
|
||||||
|
data: IRoomTimelineData
|
||||||
|
) => {
|
||||||
|
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
|
||||||
|
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
|
||||||
|
setUnreadAtom({
|
||||||
|
type: 'DELETE',
|
||||||
|
roomId: room.roomId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mEvent.getSender() === mx.getUserId()) return;
|
||||||
|
setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
|
||||||
|
};
|
||||||
|
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
|
};
|
||||||
|
}, [mx, setUnreadAtom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleReceipt = (mEvent: MatrixEvent, room: Room) => {
|
||||||
|
if (mEvent.getType() === 'm.receipt') {
|
||||||
|
const myUserId = mx.getUserId();
|
||||||
|
if (!myUserId) return;
|
||||||
|
if (room.isSpaceRoom()) return;
|
||||||
|
const content = mEvent.getContent<ReceiptContent>();
|
||||||
|
|
||||||
|
const isMyReceipt = Object.keys(content).find((eventId) =>
|
||||||
|
(Object.keys(content[eventId]) as ReceiptType[]).find(
|
||||||
|
(receiptType) => content[eventId][receiptType][myUserId]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (isMyReceipt) {
|
||||||
|
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mx.on(RoomEvent.Receipt, handleReceipt);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(RoomEvent.Receipt, handleReceipt);
|
||||||
|
};
|
||||||
|
}, [mx, setUnreadAtom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
muteChanges.removed.forEach((roomId) => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return;
|
||||||
|
if (!roomHaveUnread(mx, room)) return;
|
||||||
|
setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
|
||||||
|
});
|
||||||
|
muteChanges.added.forEach((roomId) => {
|
||||||
|
setUnreadAtom({ type: 'DELETE', roomId });
|
||||||
|
});
|
||||||
|
}, [mx, setUnreadAtom, muteChanges]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMembershipChange = (room: Room, membership: string) => {
|
||||||
|
if (membership !== Membership.Join) {
|
||||||
|
setUnreadAtom({
|
||||||
|
type: 'DELETE',
|
||||||
|
roomId: room.roomId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mx.on(RoomEvent.MyMembership, handleMembershipChange);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
|
||||||
|
};
|
||||||
|
}, [mx, setUnreadAtom]);
|
||||||
|
};
|
3
src/app/state/selectedRoom.ts
Normal file
3
src/app/state/selectedRoom.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
export const selectedRoomAtom = atom<string | undefined>(undefined);
|
8
src/app/state/selectedTab.ts
Normal file
8
src/app/state/selectedTab.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
export enum SidebarTab {
|
||||||
|
Home = 'Home',
|
||||||
|
People = 'People',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selectedTabAtom = atom<SidebarTab | string>(SidebarTab.Home);
|
49
src/app/state/settings.ts
Normal file
49
src/app/state/settings.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'settings';
|
||||||
|
export interface Settings {
|
||||||
|
themeIndex: number;
|
||||||
|
useSystemTheme: boolean;
|
||||||
|
isMarkdown: boolean;
|
||||||
|
editorToolbar: boolean;
|
||||||
|
isPeopleDrawer: boolean;
|
||||||
|
|
||||||
|
hideMembershipEvents: boolean;
|
||||||
|
hideNickAvatarEvents: boolean;
|
||||||
|
|
||||||
|
showNotifications: boolean;
|
||||||
|
isNotificationSounds: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSettings: Settings = {
|
||||||
|
themeIndex: 0,
|
||||||
|
useSystemTheme: true,
|
||||||
|
isMarkdown: true,
|
||||||
|
editorToolbar: false,
|
||||||
|
isPeopleDrawer: true,
|
||||||
|
|
||||||
|
hideMembershipEvents: false,
|
||||||
|
hideNickAvatarEvents: true,
|
||||||
|
|
||||||
|
showNotifications: true,
|
||||||
|
isNotificationSounds: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSettings = () => {
|
||||||
|
const settings = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (settings === null) return defaultSettings;
|
||||||
|
return JSON.parse(settings) as Settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setSettings = (settings: Settings) => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseSettings = atom<Settings>(getSettings());
|
||||||
|
export const settingsAtom = atom<Settings, Settings>(
|
||||||
|
(get) => get(baseSettings),
|
||||||
|
(get, set, update) => {
|
||||||
|
set(baseSettings, update);
|
||||||
|
setSettings(update);
|
||||||
|
}
|
||||||
|
);
|
34
src/app/state/tabToRoom.ts
Normal file
34
src/app/state/tabToRoom.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import produce from 'immer';
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
type RoomInfo = {
|
||||||
|
roomId: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
type TabToRoom = Map<string, RoomInfo>;
|
||||||
|
|
||||||
|
type TabToRoomAction = {
|
||||||
|
type: 'PUT';
|
||||||
|
tabInfo: { tabId: string; roomInfo: RoomInfo };
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseTabToRoom = atom<TabToRoom>(new Map());
|
||||||
|
export const tabToRoomAtom = atom<TabToRoom, TabToRoomAction>(
|
||||||
|
(get) => get(baseTabToRoom),
|
||||||
|
(get, set, action) => {
|
||||||
|
if (action.type === 'PUT') {
|
||||||
|
set(
|
||||||
|
baseTabToRoom,
|
||||||
|
produce(get(baseTabToRoom), (draft) => {
|
||||||
|
draft.set(action.tabInfo.tabId, action.tabInfo.roomInfo);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useBindTabToRoomAtom = (mx: MatrixClient) => {
|
||||||
|
console.log(mx);
|
||||||
|
// TODO:
|
||||||
|
};
|
146
src/app/state/upload.ts
Normal file
146
src/app/state/upload.ts
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
import { atom, useAtom } from 'jotai';
|
||||||
|
import { atomFamily } from 'jotai/utils';
|
||||||
|
import { MatrixClient, UploadResponse, UploadProgress, MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useThrottle } from '../hooks/useThrottle';
|
||||||
|
import { uploadContent, TUploadContent } from '../utils/matrix';
|
||||||
|
|
||||||
|
export enum UploadStatus {
|
||||||
|
Idle = 'idle',
|
||||||
|
Loading = 'loading',
|
||||||
|
Success = 'success',
|
||||||
|
Error = 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UploadIdle = {
|
||||||
|
file: TUploadContent;
|
||||||
|
status: UploadStatus.Idle;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadLoading = {
|
||||||
|
file: TUploadContent;
|
||||||
|
status: UploadStatus.Loading;
|
||||||
|
promise: Promise<UploadResponse>;
|
||||||
|
progress: UploadProgress;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadSuccess = {
|
||||||
|
file: TUploadContent;
|
||||||
|
status: UploadStatus.Success;
|
||||||
|
mxc: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadError = {
|
||||||
|
file: TUploadContent;
|
||||||
|
status: UploadStatus.Error;
|
||||||
|
error: MatrixError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Upload = UploadIdle | UploadLoading | UploadSuccess | UploadError;
|
||||||
|
|
||||||
|
export type UploadAtomAction =
|
||||||
|
| {
|
||||||
|
promise: Promise<UploadResponse>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
progress: UploadProgress;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mxc: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
error: MatrixError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUploadAtom = (file: TUploadContent) => {
|
||||||
|
const baseUploadAtom = atom<Upload>({
|
||||||
|
file,
|
||||||
|
status: UploadStatus.Idle,
|
||||||
|
});
|
||||||
|
return atom<Upload, UploadAtomAction>(
|
||||||
|
(get) => get(baseUploadAtom),
|
||||||
|
(get, set, update) => {
|
||||||
|
const uploadState = get(baseUploadAtom);
|
||||||
|
if ('promise' in update) {
|
||||||
|
set(baseUploadAtom, {
|
||||||
|
status: UploadStatus.Loading,
|
||||||
|
file,
|
||||||
|
promise: update.promise,
|
||||||
|
progress: { loaded: 0, total: file.size },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ('progress' in update && uploadState.status === UploadStatus.Loading) {
|
||||||
|
set(baseUploadAtom, {
|
||||||
|
...uploadState,
|
||||||
|
progress: update.progress,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ('mxc' in update) {
|
||||||
|
set(baseUploadAtom, {
|
||||||
|
status: UploadStatus.Success,
|
||||||
|
file,
|
||||||
|
mxc: update.mxc,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ('error' in update) {
|
||||||
|
set(baseUploadAtom, {
|
||||||
|
status: UploadStatus.Error,
|
||||||
|
file,
|
||||||
|
error: update.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export type TUploadAtom = ReturnType<typeof createUploadAtom>;
|
||||||
|
|
||||||
|
export const useBindUploadAtom = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
file: TUploadContent,
|
||||||
|
uploadAtom: TUploadAtom,
|
||||||
|
hideFilename?: boolean
|
||||||
|
) => {
|
||||||
|
const [upload, setUpload] = useAtom(uploadAtom);
|
||||||
|
|
||||||
|
const handleProgress = useThrottle(
|
||||||
|
useCallback((progress: UploadProgress) => setUpload({ progress }), [setUpload]),
|
||||||
|
{ immediate: true, wait: 200 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const startUpload = useCallback(
|
||||||
|
() =>
|
||||||
|
uploadContent(mx, file, {
|
||||||
|
hideFilename,
|
||||||
|
onPromise: (promise: Promise<UploadResponse>) => setUpload({ promise }),
|
||||||
|
onProgress: handleProgress,
|
||||||
|
onSuccess: (mxc) => setUpload({ mxc }),
|
||||||
|
onError: (error) => setUpload({ error }),
|
||||||
|
}),
|
||||||
|
[mx, file, hideFilename, setUpload, handleProgress]
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelUpload = useCallback(async () => {
|
||||||
|
if (upload.status === UploadStatus.Loading) {
|
||||||
|
await mx.cancelUpload(upload.promise);
|
||||||
|
}
|
||||||
|
}, [mx, upload]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
upload,
|
||||||
|
startUpload,
|
||||||
|
cancelUpload,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUploadAtomFamily = () =>
|
||||||
|
atomFamily<TUploadContent, TUploadAtom>(createUploadAtom);
|
||||||
|
export type TUploadAtomFamily = ReturnType<typeof createUploadAtomFamily>;
|
||||||
|
|
||||||
|
export const createUploadFamilyObserverAtom = (
|
||||||
|
uploadFamily: TUploadAtomFamily,
|
||||||
|
uploads: TUploadContent[]
|
||||||
|
) => atom<Upload[]>((get) => uploads.map((upload) => get(uploadFamily(upload))));
|
||||||
|
export type TUploadFamilyObserverAtom = ReturnType<typeof createUploadFamilyObserverAtom>;
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue