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: {
|
||||
'linebreak-style': 0,
|
||||
'no-underscore-dangle': 0,
|
||||
"no-shadow": "off",
|
||||
|
||||
"import/prefer-default-export": "off",
|
||||
"import/extensions": "off",
|
||||
|
@ -55,5 +56,6 @@ module.exports = {
|
|||
"react-hooks/exhaustive-deps": "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 rel="manifest" href="./manifest.json" />
|
||||
<link rel="manifest" href="./public/manifest.json" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="application-name" 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",
|
||||
"@matrix-org/olm": "3.2.14",
|
||||
"@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",
|
||||
"browser-encrypt-attachment": "0.3.0",
|
||||
"classnames": "2.3.2",
|
||||
"dateformat": "5.0.3",
|
||||
"emojibase": "6.1.0",
|
||||
"emojibase-data": "7.0.1",
|
||||
"file-saver": "2.0.5",
|
||||
"flux": "4.0.3",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "1.2.1",
|
||||
"formik": "2.2.9",
|
||||
"html-react-parser": "3.0.4",
|
||||
"immer": "9.0.16",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "1.12.0",
|
||||
"katex": "0.16.4",
|
||||
"linkify-html": "4.0.2",
|
||||
"linkifyjs": "4.0.2",
|
||||
|
@ -46,8 +57,11 @@
|
|||
"react-google-recaptcha": "2.1.0",
|
||||
"react-modal": "3.16.1",
|
||||
"sanitize-html": "2.8.0",
|
||||
"slate": "0.90.0",
|
||||
"slate-react": "0.90.0",
|
||||
"tippy.js": "6.3.7",
|
||||
"twemoji": "14.0.2"
|
||||
"twemoji": "14.0.2",
|
||||
"ua-parser-js": "1.0.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||
|
@ -56,6 +70,7 @@
|
|||
"@types/node": "18.11.18",
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.9",
|
||||
"@types/ua-parser-js": "0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "5.46.1",
|
||||
"@typescript-eslint/parser": "5.46.1",
|
||||
"@vitejs/plugin-react": "3.0.0",
|
||||
|
@ -71,7 +86,7 @@
|
|||
"prettier": "2.8.1",
|
||||
"sass": "1.56.2",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "4.0.1",
|
||||
"vite": "4.0.4",
|
||||
"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 { roomId } = roomTimeline;
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { roomsInput } = initMatrix;
|
||||
const myUserId = mx.getUserId();
|
||||
|
||||
const handleOnMessageSent = () => setFollowingMembers([]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateFollowingMembers = () => {
|
||||
setFollowingMembers(roomTimeline.getLiveReaders());
|
||||
};
|
||||
const updateOnEvent = (event, room) => {
|
||||
if (room.roomId !== roomId) return;
|
||||
setFollowingMembers(roomTimeline.getLiveReaders());
|
||||
};
|
||||
updateFollowingMembers();
|
||||
roomTimeline.on(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
|
||||
roomsInput.on(cons.events.roomsInput.MESSAGE_SENT, handleOnMessageSent);
|
||||
mx.on('Room.timeline', updateOnEvent);
|
||||
return () => {
|
||||
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);
|
||||
|
||||
return filteredM.length !== 0 && (
|
||||
<button
|
||||
className="following-members"
|
||||
onClick={() => openReadReceipts(roomId, followingMembers)}
|
||||
type="button"
|
||||
>
|
||||
<RawIcon
|
||||
size="extra-small"
|
||||
src={TickMarkIC}
|
||||
/>
|
||||
<Text variant="b2">{getUsersActionJsx(roomId, filteredM, 'following the conversation.')}</Text>
|
||||
</button>
|
||||
return (
|
||||
filteredM.length !== 0 && (
|
||||
<button
|
||||
className="following-members"
|
||||
onClick={() => openReadReceipts(roomId, followingMembers)}
|
||||
type="button"
|
||||
>
|
||||
<RawIcon size="extra-small" src={TickMarkIC} />
|
||||
<Text variant="b2">
|
||||
{getUsersActionJsx(roomId, filteredM, 'following the conversation.')}
|
||||
</Text>
|
||||
</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);
|
||||
height: 100%;
|
||||
background-color: var(--bg-surface-extra-low);
|
||||
@include dir.side(border,
|
||||
none,
|
||||
1px solid var(--bg-surface-border),
|
||||
);
|
||||
@include dir.side(border, none, 1px solid var(--bg-surface-border));
|
||||
|
||||
&__scrollable,
|
||||
&__sticky {
|
||||
|
@ -24,7 +21,7 @@
|
|||
|
||||
.scrollable-content {
|
||||
&::after {
|
||||
content: "";
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
|
@ -33,7 +30,8 @@
|
|||
background-image: linear-gradient(
|
||||
to top,
|
||||
var(--bg-surface-extra-low),
|
||||
var(--bg-surface-extra-low-transparent));
|
||||
var(--bg-surface-extra-low-transparent)
|
||||
);
|
||||
position: sticky;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
|
@ -63,7 +61,7 @@
|
|||
box-shadow: var(--bs-danger-border);
|
||||
animation-name: pushRight;
|
||||
animation-duration: 400ms;
|
||||
animation-iteration-count: infinite;
|
||||
animation-iteration-count: 30;
|
||||
animation-direction: alternate;
|
||||
}
|
||||
|
||||
|
|
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() {
|
||||
const [roomInfo, setRoomInfo] = useState({
|
||||
room: null,
|
||||
roomTimeline: null,
|
||||
eventId: null,
|
||||
});
|
||||
|
@ -25,14 +26,17 @@ function Room() {
|
|||
useEffect(() => {
|
||||
const handleRoomSelected = (rId, pRoomId, eId) => {
|
||||
roomInfo.roomTimeline?.removeInternalListeners();
|
||||
if (mx.getRoom(rId)) {
|
||||
const r = mx.getRoom(rId);
|
||||
if (r) {
|
||||
setRoomInfo({
|
||||
room: r,
|
||||
roomTimeline: new RoomTimeline(rId),
|
||||
eventId: eId ?? null,
|
||||
});
|
||||
} else {
|
||||
// TODO: add ability to join room if roomId is invalid
|
||||
setRoomInfo({
|
||||
room: r,
|
||||
roomTimeline: null,
|
||||
eventId: null,
|
||||
});
|
||||
|
@ -43,7 +47,7 @@ function Room() {
|
|||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
|
||||
};
|
||||
}, [roomInfo]);
|
||||
}, [roomInfo, mx]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDrawerToggling = (visiblity) => setIsDrawer(visiblity);
|
||||
|
@ -53,7 +57,7 @@ function Room() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const { roomTimeline, eventId } = roomInfo;
|
||||
const { room, roomTimeline, eventId } = roomInfo;
|
||||
if (roomTimeline === null) {
|
||||
setTimeout(() => openNavigation());
|
||||
return <Welcome />;
|
||||
|
@ -63,7 +67,7 @@ function Room() {
|
|||
<div className="room">
|
||||
<div className="room__content">
|
||||
<RoomSettings roomId={roomTimeline.roomId} />
|
||||
<RoomView roomTimeline={roomTimeline} eventId={eventId} />
|
||||
<RoomView room={room} roomTimeline={roomTimeline} eventId={eventId} />
|
||||
</div>
|
||||
{isDrawer && <PeopleDrawer roomId={roomTimeline.roomId} />}
|
||||
</div>
|
||||
|
|
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 PropTypes from 'prop-types';
|
||||
import './RoomView.scss';
|
||||
import { Text, config } from 'folds';
|
||||
|
||||
import EventEmitter from 'events';
|
||||
|
||||
|
@ -10,16 +11,29 @@ import navigation from '../../../client/state/navigation';
|
|||
import RoomViewHeader from './RoomViewHeader';
|
||||
import RoomViewContent from './RoomViewContent';
|
||||
import RoomViewFloating from './RoomViewFloating';
|
||||
import RoomViewInput from './RoomViewInput';
|
||||
import RoomViewCmdBar from './RoomViewCmdBar';
|
||||
import { RoomInput } from './RoomInput';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { RoomTombstone } from './RoomTombstone';
|
||||
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
||||
|
||||
const viewEvent = new EventEmitter();
|
||||
|
||||
function RoomView({ roomTimeline, eventId }) {
|
||||
function RoomView({ room, roomTimeline, eventId }) {
|
||||
const roomInputRef = useRef(null);
|
||||
const roomViewRef = useRef(null);
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const { roomId } = roomTimeline;
|
||||
|
||||
const mx = useMatrixClient();
|
||||
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
|
||||
const { getPowerLevel, canSendEvent } = usePowerLevels(room);
|
||||
const myUserId = mx.getUserId();
|
||||
const canMessage = myUserId ? canSendEvent(undefined, getPowerLevel(myUserId)) : false;
|
||||
|
||||
useEffect(() => {
|
||||
const settingsToggle = (isVisible) => {
|
||||
const roomView = roomViewRef.current;
|
||||
|
@ -47,23 +61,36 @@ function RoomView({ roomTimeline, eventId }) {
|
|||
<RoomViewContent
|
||||
eventId={eventId}
|
||||
roomTimeline={roomTimeline}
|
||||
roomInputRef={roomInputRef}
|
||||
/>
|
||||
<RoomViewFloating
|
||||
roomId={roomId}
|
||||
roomTimeline={roomTimeline}
|
||||
/>
|
||||
<RoomViewFloating roomId={roomId} roomTimeline={roomTimeline} />
|
||||
</div>
|
||||
<div className="room-view__sticky">
|
||||
<RoomViewInput
|
||||
roomId={roomId}
|
||||
roomTimeline={roomTimeline}
|
||||
viewEvent={viewEvent}
|
||||
/>
|
||||
<RoomViewCmdBar
|
||||
roomId={roomId}
|
||||
roomTimeline={roomTimeline}
|
||||
viewEvent={viewEvent}
|
||||
/>
|
||||
<div className="room-view__editor">
|
||||
{tombstoneEvent ? (
|
||||
<RoomTombstone
|
||||
roomId={roomId}
|
||||
body={tombstoneEvent.getContent().body}
|
||||
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{canMessage && (
|
||||
<RoomInput roomId={roomId} roomViewRef={roomViewRef} ref={roomInputRef} />
|
||||
)}
|
||||
{!canMessage && (
|
||||
<RoomInputPlaceholder
|
||||
style={{ padding: config.space.S200 }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text align="Center">You do not have permission to post in this room</Text>
|
||||
</RoomInputPlaceholder>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<RoomViewCmdBar roomId={roomId} roomTimeline={roomTimeline} viewEvent={viewEvent} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -74,6 +101,7 @@ RoomView.defaultProps = {
|
|||
eventId: null,
|
||||
};
|
||||
RoomView.propTypes = {
|
||||
room: PropTypes.shape({}).isRequired,
|
||||
roomTimeline: PropTypes.shape({}).isRequired,
|
||||
eventId: PropTypes.string,
|
||||
};
|
||||
|
|
|
@ -37,9 +37,10 @@
|
|||
}
|
||||
|
||||
&__sticky {
|
||||
min-height: 85px;
|
||||
position: relative;
|
||||
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 TimelineScroll from './TimelineScroll';
|
||||
import EventLimit from './EventLimit';
|
||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
|
||||
const PAG_LIMIT = 30;
|
||||
const MAX_MSG_DIFF_MINUTES = 5;
|
||||
|
@ -392,7 +393,7 @@ function useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, event
|
|||
|
||||
let jumpToItemIndex = -1;
|
||||
|
||||
function RoomViewContent({ eventId, roomTimeline }) {
|
||||
function RoomViewContent({ roomInputRef, eventId, roomTimeline }) {
|
||||
const [throttle] = useState(new Throttle());
|
||||
|
||||
const timelineSVRef = useRef(null);
|
||||
|
@ -484,6 +485,21 @@ function RoomViewContent({ eventId, roomTimeline }) {
|
|||
}
|
||||
}, [newEvent]);
|
||||
|
||||
useResizeObserver(
|
||||
roomInputRef.current,
|
||||
useCallback((entries) => {
|
||||
if (!roomInputRef.current) return;
|
||||
const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
|
||||
if (!editorBaseEntry) return;
|
||||
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
if (!roomTimeline.initialized) return;
|
||||
if (timelineScroll.bottom < 40 && !roomTimeline.canPaginateForward() && document.visibilityState === 'visible') {
|
||||
timelineScroll.scrollToBottom();
|
||||
}
|
||||
}, [roomInputRef])
|
||||
);
|
||||
|
||||
const listenKeyboard = useCallback((event) => {
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) return;
|
||||
if (event.key !== 'ArrowUp') return;
|
||||
|
@ -620,6 +636,9 @@ RoomViewContent.defaultProps = {
|
|||
RoomViewContent.propTypes = {
|
||||
eventId: PropTypes.string,
|
||||
roomTimeline: PropTypes.shape({}).isRequired,
|
||||
roomInputRef: PropTypes.shape({
|
||||
current: PropTypes.shape({})
|
||||
}).isRequired
|
||||
};
|
||||
|
||||
export default RoomViewContent;
|
||||
|
|
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';
|
||||
|
||||
|
@ -6,7 +7,11 @@ import Auth from '../templates/auth/Auth';
|
|||
import Client from '../templates/client/Client';
|
||||
|
||||
function App() {
|
||||
return isAuthenticated() ? <Client /> : <Auth />;
|
||||
return (
|
||||
<StrictMode>
|
||||
<Provider>{isAuthenticated() ? <Client /> : <Auth />}</Provider>
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
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