Refactor state & Custom editor (#1190)

* Fix eslint

* Enable ts strict mode

* install folds, jotai & immer

* Enable immer map/set

* change cross-signing alert anim to 30 iteration

* Add function to access matrix client

* Add new types

* Add disposable util

* Add room utils

* Add mDirect list atom

* Add invite list atom

* add room list atom

* add utils for jotai atoms

* Add room id to parents atom

* Add mute list atom

* Add room to unread atom

* Use hook to bind atoms with sdk

* Add settings atom

* Add settings hook

* Extract set settings hook

* Add Sidebar components

* WIP

* Add bind atoms hook

* Fix init muted room list atom

* add navigation atoms

* Add custom editor

* Fix hotkeys

* Update folds

* Add editor output function

* Add matrix client context

* Add tooltip to editor toolbar items

* WIP - Add editor to room input

* Refocus editor on toolbar item click

* Add Mentions - WIP

* update folds

* update mention focus outline

* rename emoji element type

* Add auto complete menu

* add autocomplete query functions

* add index file for editor

* fix bug in getPrevWord function

* Show room mention autocomplete

* Add async search function

* add use async search hook

* use async search in room mention autocomplete

* remove folds prefer font for now

* allow number array in async search

* reset search with empty query

* Autocomplete unknown room mention

* Autocomplete first room mention on tab

* fix roomAliasFromQueryText

* change mention color to primary

* add isAlive hook

* add getMxIdLocalPart to mx utils

* fix getRoomAvatarUrl size

* fix types

* add room members hook

* fix bug in room mention

* add user mention autocomplete

* Fix async search giving prev result after no match

* update folds

* add twemoji font

* add use state provider hook

* add prevent scroll with arrow key util

* add ts to custom-emoji and emoji files

* add types

* add hook for emoji group labels

* add hook for emoji group icons

* add emoji board with basic emoji

* add emojiboard in room input

* select multiple emoji with shift press

* display custom emoji in emojiboard

* Add emoji preview

* focus element on hover

* update folds

* position emojiboard properly

* convert recent-emoji.js to ts

* add use recent emoji hook

* add io.element.recent_emoji to account data evt

* Render recent emoji in emoji board

* show custom emoji from parent spaces

* show room emoji

* improve emoji sidebar

* update folds

* fix pack avatar and name fallback in emoji board

* add stickers to emoji board

* fix bug in emoji preview

* Add sticker icon in room input

* add debounce hook

* add search in emoji board

* Optimize emoji board

* fix emoji board sidebar divider

* sync emojiboard sidebar with scroll & update ui

* Add use throttle hook

* support custom emoji in editor

* remove duplicate emoji selection function

* fix emoji and mention spacing

* add emoticon autocomplete in editor

* fix string

* makes emoji size relative to font size in editor

* add option to render link element

* add spoiler in editor

* fix sticker in emoji board search using wrong type

* render custom placeholder

* update hotkey for block quote and block code

* add terminate search function in async search

* add getImageInfo to matrix utils

* send stickers

* add resize observer hook

* move emoji board component hooks in hooks dir

* prevent editor expand hides room timeline

* send typing notifications

* improve emoji style and performance

* fix imports

* add on paste param to editor

* add selectFile utils

* add file picker hook

* add file paste handler hook

* add file drop handler

* update folds

* Add file upload card

* add bytes to size util

* add blurHash util

* add await to js lib

* add browser-encrypt-attachment types

* add list atom

* convert mimetype file to ts

* add matrix types

* add matrix file util

* add file related dom utils

* add common utils

* add upload atom

* add room input draft atom

* add upload card renderer component

* add upload board component

* add support for file upload in editor

* send files with message / enter

* fix circular deps

* store editor toolbar state in local store

* move msg content util to separate file

* store msg draft on room switch

* fix following member not updating on msg sent

* add theme for folds component

* fix system default theme

* Add reply support in editor

* prevent initMatrix to init multiple time

* add state event hooks

* add async callback hook

* Show tombstone info for tombstone room

* fix room tombstone component border

* add power level hook

* Add room input placeholder component

* Show input placeholder for muted member
This commit is contained in:
Ajay Bura 2023-06-12 21:15:23 +10:00 committed by GitHub
parent 2055d7a07f
commit 0b06bed1db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
128 changed files with 8799 additions and 409 deletions

View File

@ -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"
},
};

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

Binary file not shown.

Binary file not shown.

View 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));
}

View 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,
},
]);

View 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>
</>
);
}

View 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>
);
}
);

View 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',
},
]);

View 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>;
}

View 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>
);
}

View File

@ -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 },
]);

View 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>
);
}

View 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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View 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),
};
};

View File

@ -0,0 +1,5 @@
export * from './AutocompleteMenu';
export * from './autocompleteQuery';
export * from './RoomMentionAutocomplete';
export * from './UserMentionAutocomplete';
export * from './EmoticonAutocomplete';

View 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);
};

View 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';

View 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]);
}
});
};

View 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
View 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;
}
}

View 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),
},
]);

View 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>
);
}

View File

@ -0,0 +1 @@
export * from './EmojiBoard';

View 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,
}),
[]
);

View 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',
}),
[]
);

View 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}`,
});

View 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} />
));

View 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>
)
);

View 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>
</>
);
}

View 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} />
)
);

View 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"
/>
);
}

View File

@ -0,0 +1,5 @@
export * from './Sidebar';
export * from './SidebarAvatar';
export * from './SidebarContent';
export * from './SidebarStack';
export * from './SidebarStackSeparator';

View 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,
});

View 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>
));

View File

@ -0,0 +1 @@
export * from './UploadBoard';

View 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,
});

View 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>
);
}

View 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>
);
}

View File

@ -0,0 +1,2 @@
export * from './UploadCard';
export * from './UploadCardRenderer';

15
src/app/hooks/useAlive.ts Normal file
View 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;
};

View 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];
};

View 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];
};

View 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;
}

View 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;
};

View 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]
);

View 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]
);

View 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];
};

View 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;
};

View 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]);
};

View 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;
}

View 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,
};
}

View 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;
};

View 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;
};

View 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;
};

View 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]
);
};

View 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]);
};

View 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]
);
};

View 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;
}

View 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;
};

View File

@ -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>
)
);
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,125 @@
import React from 'react';
import { Icon, Icons, Badge, AvatarFallback, Text } from 'folds';
import { useAtom } from 'jotai';
import {
Sidebar,
SidebarContent,
SidebarStackSeparator,
SidebarStack,
SidebarAvatar,
} from '../../components/sidebar';
import { selectedTabAtom, SidebarTab } from '../../state/selectedTab';
export function Sidebar1() {
const [selectedTab, setSelectedTab] = useAtom(selectedTabAtom);
return (
<Sidebar>
<SidebarContent
scrollable={
<>
<SidebarStack>
<SidebarAvatar
active={selectedTab === SidebarTab.Home}
outlined
tooltip="Home"
avatarChildren={<Icon src={Icons.Home} filled />}
onClick={() => setSelectedTab(SidebarTab.Home)}
/>
<SidebarAvatar
active={selectedTab === SidebarTab.People}
outlined
tooltip="People"
avatarChildren={<Icon src={Icons.User} />}
onClick={() => setSelectedTab(SidebarTab.People)}
/>
</SidebarStack>
<SidebarStackSeparator />
<SidebarStack>
<SidebarAvatar
tooltip="Space A"
notificationBadge={(badgeClassName) => (
<Badge
className={badgeClassName}
size="200"
variant="Secondary"
fill="Solid"
radii="Pill"
/>
)}
avatarChildren={
<AvatarFallback
style={{
backgroundColor: 'red',
color: 'white',
}}
>
<Text size="T500">B</Text>
</AvatarFallback>
}
/>
<SidebarAvatar
tooltip="Space B"
hasCount
notificationBadge={(badgeClassName) => (
<Badge className={badgeClassName} radii="Pill" fill="Solid" variant="Secondary">
<Text size="L400">64</Text>
</Badge>
)}
avatarChildren={
<AvatarFallback
style={{
backgroundColor: 'green',
color: 'white',
}}
>
<Text size="T500">C</Text>
</AvatarFallback>
}
/>
</SidebarStack>
<SidebarStackSeparator />
<SidebarStack>
<SidebarAvatar
outlined
tooltip="Explore Community"
avatarChildren={<Icon src={Icons.Explore} />}
/>
<SidebarAvatar
outlined
tooltip="Create Space"
avatarChildren={<Icon src={Icons.Plus} />}
/>
</SidebarStack>
</>
}
sticky={
<>
<SidebarStackSeparator />
<SidebarStack>
<SidebarAvatar
outlined
tooltip="Search"
avatarChildren={<Icon src={Icons.Search} />}
/>
<SidebarAvatar
tooltip="User Settings"
avatarChildren={
<AvatarFallback
style={{
backgroundColor: 'blue',
color: 'white',
}}
>
<Text size="T500">A</Text>
</AvatarFallback>
}
/>
</SidebarStack>
</>
}
/>
</Sidebar>
);
}

View File

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

View File

@ -0,0 +1,539 @@
import React, {
KeyboardEventHandler,
RefObject,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useAtom } from 'jotai';
import isHotkey from 'is-hotkey';
import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { Transforms, Range, Editor, Element } from 'slate';
import {
Box,
Dialog,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
PopOut,
Scroll,
Text,
config,
toRem,
} from 'folds';
import to from 'await-to-js';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import {
CustomEditor,
EditorChangeHandler,
useEditor,
Toolbar,
toMatrixCustomHTML,
toPlainText,
AUTOCOMPLETE_PREFIXES,
AutocompletePrefix,
AutocompleteQuery,
getAutocompleteQuery,
getPrevWorldRange,
resetEditor,
RoomMentionAutocomplete,
UserMentionAutocomplete,
EmoticonAutocomplete,
createEmoticonElement,
moveCursor,
} from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider';
import initMatrix from '../../../client/initMatrix';
import { TUploadContent, encryptFile, getImageInfo } from '../../utils/matrix';
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
import { useFilePicker } from '../../hooks/useFilePicker';
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
import { useFileDropZone } from '../../hooks/useFileDrop';
import {
TUploadItem,
roomIdToMsgDraftAtomFamily,
roomIdToReplyDraftAtomFamily,
roomIdToUploadItemsAtomFamily,
roomUploadAtomFamily,
} from '../../state/roomInputDrafts';
import { UploadCardRenderer } from '../../components/upload-card';
import {
UploadBoard,
UploadBoardContent,
UploadBoardHeader,
UploadBoardImperativeHandlers,
} from '../../components/upload-board';
import {
Upload,
UploadStatus,
UploadSuccess,
createUploadFamilyObserverAtom,
} from '../../state/upload';
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
import { safeFile } from '../../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import {
getAudioMsgContent,
getFileMsgContent,
getImageMsgContent,
getVideoMsgContent,
} from './msgContent';
import navigation from '../../../client/state/navigation';
import cons from '../../../client/state/cons';
import { MessageReply } from '../../molecules/message/Message';
import colorMXID from '../../../util/colorMXID';
import { parseReplyBody, parseReplyFormattedBody } from '../../utils/room';
import { sanitizeText } from '../../utils/sanitize';
interface RoomInputProps {
roomViewRef: RefObject<HTMLElement>;
roomId: string;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ roomViewRef, roomId }, ref) => {
const mx = useMatrixClient();
const editor = useEditor();
const room = mx.getRoom(roomId);
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
const [uploadBoard, setUploadBoard] = useState(true);
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
roomUploadAtomFamily,
selectedFiles.map((f) => f.file)
);
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
const imagePackRooms: Room[] = useMemo(() => {
const allParentSpaces = [roomId, ...(initMatrix.roomList?.getAllParentSpaces(roomId) ?? [])];
return allParentSpaces.reduce<Room[]>((list, rId) => {
const r = mx.getRoom(rId);
if (r) list.push(r);
return list;
}, []);
}, [mx, roomId]);
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [autocompleteQuery, setAutocompleteQuery] =
useState<AutocompleteQuery<AutocompletePrefix>>();
const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
const handleFiles = useCallback(
async (files: File[]) => {
setUploadBoard(true);
const safeFiles = files.map(safeFile);
const fileItems: TUploadItem[] = [];
if (mx.isRoomEncrypted(roomId)) {
const encryptFiles = fulfilledPromiseSettledResult(
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
);
encryptFiles.forEach((ef) => fileItems.push(ef));
} else {
safeFiles.forEach((f) =>
fileItems.push({ file: f, originalFile: f, encInfo: undefined })
);
}
setSelectedFiles({
type: 'PUT',
item: fileItems,
});
},
[setSelectedFiles, roomId, mx]
);
const pickFile = useFilePicker(handleFiles, true);
const handlePaste = useFilePasteHandler(handleFiles);
const dropZoneVisible = useFileDropZone(roomViewRef, handleFiles);
useEffect(() => {
Transforms.insertFragment(editor, msgDraft);
}, [editor, msgDraft]);
useEffect(() => {
ReactEditor.focus(editor);
return () => {
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft);
resetEditor(editor);
};
}, [roomId, editor, setMsgDraft]);
useEffect(() => {
const handleReplyTo = (
userId: string,
eventId: string,
body: string,
formattedBody: string
) => {
setReplyDraft({
userId,
eventId,
body,
formattedBody,
});
};
navigation.on(cons.events.navigation.REPLY_TO_CLICKED, handleReplyTo);
return () => {
navigation.removeListener(cons.events.navigation.REPLY_TO_CLICKED, handleReplyTo);
};
}, [setReplyDraft]);
const handleRemoveUpload = useCallback(
(upload: TUploadContent | TUploadContent[]) => {
const uploads = Array.isArray(upload) ? upload : [upload];
setSelectedFiles({
type: 'DELETE',
item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)),
});
uploads.forEach((u) => roomUploadAtomFamily.remove(u));
},
[setSelectedFiles, selectedFiles]
);
const handleCancelUpload = (uploads: Upload[]) => {
uploads.forEach((upload) => {
if (upload.status === UploadStatus.Loading) {
mx.cancelUpload(upload.promise);
}
});
handleRemoveUpload(uploads.map((upload) => upload.file));
};
const handleSendUpload = async (uploads: UploadSuccess[]) => {
const sendPromises = uploads.map(async (upload) => {
const fileItem = selectedFiles.find((f) => f.file === upload.file);
if (fileItem && fileItem.file.type.startsWith('image')) {
const [imgError, imgContent] = await to(getImageMsgContent(fileItem, upload.mxc));
if (imgError) console.warn(imgError);
if (imgContent) mx.sendMessage(roomId, imgContent);
return;
}
if (fileItem && fileItem.file.type.startsWith('video')) {
const [videoError, videoContent] = await to(getVideoMsgContent(mx, fileItem, upload.mxc));
if (videoError) console.warn(videoError);
if (videoContent) mx.sendMessage(roomId, videoContent);
return;
}
if (fileItem && fileItem.file.type.startsWith('audio')) {
mx.sendMessage(roomId, getAudioMsgContent(fileItem, upload.mxc));
return;
}
if (fileItem) {
mx.sendMessage(roomId, getFileMsgContent(fileItem, upload.mxc));
}
});
handleCancelUpload(uploads);
await Promise.allSettled(sendPromises);
};
const submit = useCallback(() => {
uploadBoardHandlers.current?.handleSend();
const plainText = toPlainText(editor.children).trim();
const customHtml = toMatrixCustomHTML(editor.children);
if (plainText === '') return;
let body = plainText;
let formattedBody = customHtml;
if (replyDraft) {
body = parseReplyBody(replyDraft.userId, replyDraft.userId) + body;
formattedBody =
parseReplyFormattedBody(
roomId,
replyDraft.userId,
replyDraft.eventId,
replyDraft.formattedBody ?? sanitizeText(replyDraft.body)
) + formattedBody;
}
const content: IContent = {
msgtype: MsgType.Text,
body,
format: 'org.matrix.custom.html',
formatted_body: formattedBody,
};
if (replyDraft) {
content['m.relates_to'] = {
'm.in_reply_to': {
event_id: replyDraft.eventId,
},
};
}
mx.sendMessage(roomId, content);
resetEditor(editor);
setReplyDraft();
sendTypingStatus(false);
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft]);
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
const { selection } = editor;
if (isHotkey('enter', evt)) {
evt.preventDefault();
submit();
}
if (isHotkey('escape', evt)) {
evt.preventDefault();
setReplyDraft();
}
if (selection && Range.isCollapsed(selection)) {
if (isHotkey('arrowleft', evt)) {
evt.preventDefault();
Transforms.move(editor, { unit: 'offset', reverse: true });
}
if (isHotkey('arrowright', evt)) {
evt.preventDefault();
Transforms.move(editor, { unit: 'offset' });
}
}
},
[submit, editor, setReplyDraft]
);
const handleChange: EditorChangeHandler = (value) => {
const prevWordRange = getPrevWorldRange(editor);
const query = prevWordRange
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
: undefined;
setAutocompleteQuery(query);
const descendant = value[0];
if (descendant && Element.isElement(descendant)) {
const isEmpty = value.length === 1 && Editor.isEmpty(editor, descendant);
sendTypingStatus(!isEmpty);
}
};
const handleEmoticonSelect = (key: string, shortcode: string) => {
editor.insertNode(createEmoticonElement(key, shortcode));
moveCursor(editor);
};
const handleStickerSelect = async (mxc: string, shortcode: string) => {
const stickerUrl = mx.mxcUrlToHttp(mxc);
if (!stickerUrl) return;
const info = await getImageInfo(
await loadImageElement(stickerUrl),
await getImageUrlBlob(stickerUrl)
);
mx.sendEvent(roomId, EventType.Sticker, {
body: shortcode,
url: mxc,
info,
});
};
return (
<div ref={ref}>
{selectedFiles.length > 0 && (
<UploadBoard
header={
<UploadBoardHeader
open={uploadBoard}
onToggle={() => setUploadBoard(!uploadBoard)}
uploadFamilyObserverAtom={uploadFamilyObserverAtom}
onSend={handleSendUpload}
imperativeHandlerRef={uploadBoardHandlers}
onCancel={handleCancelUpload}
/>
}
>
{uploadBoard && (
<Scroll size="300" hideTrack visibility="Hover">
<UploadBoardContent>
{Array.from(selectedFiles)
.reverse()
.map((fileItem, index) => (
<UploadCardRenderer
// eslint-disable-next-line react/no-array-index-key
key={index}
file={fileItem.file}
isEncrypted={!!fileItem.encInfo}
uploadAtom={roomUploadAtomFamily(fileItem.file)}
onRemove={handleRemoveUpload}
/>
))}
</UploadBoardContent>
</Scroll>
)}
</UploadBoard>
)}
<Overlay
open={dropZoneVisible}
backdrop={<OverlayBackdrop />}
style={{ pointerEvents: 'none' }}
>
<OverlayCenter>
<Dialog variant="Primary">
<Box
direction="Column"
justifyContent="Center"
alignItems="Center"
gap="500"
style={{ padding: toRem(60) }}
>
<Icon size="600" src={Icons.File} />
<Text size="H4" align="Center">
{`Drop Files in "${room?.name || 'Room'}"`}
</Text>
<Text align="Center">Drag and drop files here or click for selection dialog</Text>
</Box>
</Dialog>
</OverlayCenter>
</Overlay>
{autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
<RoomMentionAutocomplete
roomId={roomId}
editor={editor}
query={autocompleteQuery}
requestClose={() => setAutocompleteQuery(undefined)}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
<UserMentionAutocomplete
roomId={roomId}
editor={editor}
query={autocompleteQuery}
requestClose={() => setAutocompleteQuery(undefined)}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
<EmoticonAutocomplete
imagePackRooms={imagePackRooms}
editor={editor}
query={autocompleteQuery}
requestClose={() => setAutocompleteQuery(undefined)}
/>
)}
<CustomEditor
editor={editor}
placeholder="Send a message..."
onKeyDown={handleKeyDown}
onChange={handleChange}
onPaste={handlePaste}
top={
replyDraft && (
<div>
<Box
alignItems="Center"
gap="300"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<IconButton
onClick={() => setReplyDraft()}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<MessageReply
color={colorMXID(replyDraft.userId)}
name={room?.getMember(replyDraft.userId)?.name ?? replyDraft.userId}
body={replyDraft.body}
/>
</Box>
</div>
)
}
before={
<IconButton
onClick={() => pickFile('*')}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.PlusCircle} />
</IconButton>
}
after={
<>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
onClick={() => setToolbar(!toolbar)}
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
<UseStateProvider initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
<PopOut
offset={16}
alignOffset={-44}
position="Top"
align="End"
open={!!emojiBoardTab}
content={
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab(undefined);
ReactEditor.focus(editor);
}}
/>
}
>
{(anchorRef) => (
<>
<IconButton
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon
src={Icons.Sticker}
filled={emojiBoardTab === EmojiBoardTab.Sticker}
/>
</IconButton>
<IconButton
ref={anchorRef}
aria-pressed={emojiBoardTab === EmojiBoardTab.Emoji}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Smile} filled={emojiBoardTab === EmojiBoardTab.Emoji} />
</IconButton>
</>
)}
</PopOut>
)}
</UseStateProvider>
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Send} />
</IconButton>
</>
}
bottom={toolbar && <Toolbar />}
/>
</div>
);
}
);

View File

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

View File

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

View File

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

View File

@ -0,0 +1,67 @@
import React, { useCallback } from 'react';
import { Box, Button, Spinner, Text, color } from 'folds';
import { selectRoom } from '../../../client/action/navigation';
import * as css from './RoomTombstone.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { genRoomVia } from '../../../util/matrixUtil';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { Membership } from '../../../types/matrix/room';
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string };
export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) {
const mx = useMatrixClient();
const [joinState, handleJoin] = useAsyncCallback(
useCallback(() => {
const currentRoom = mx.getRoom(roomId);
const via = currentRoom ? genRoomVia(currentRoom) : [];
return mx.joinRoom(replacementRoomId, {
viaServers: via,
});
}, [mx, roomId, replacementRoomId])
);
const replacementRoom = mx.getRoom(replacementRoomId);
const handleOpen = () => {
if (replacementRoom) selectRoom(replacementRoom.roomId);
if (joinState.status === AsyncStatus.Success) selectRoom(joinState.data.roomId);
};
return (
<RoomInputPlaceholder alignItems="Center" gap="600" className={css.RoomTombstone}>
<Box direction="Column" grow="Yes">
<Text size="T400">{body || 'This room has been replaced and is no longer active.'}</Text>
{joinState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(joinState.error as any)?.message ?? 'Failed to join replacement room!'}
</Text>
)}
</Box>
{replacementRoom?.getMyMembership() === Membership.Join ||
joinState.status === AsyncStatus.Success ? (
<Button onClick={handleOpen} size="300" variant="Success" fill="Solid" radii="300">
<Text size="B300">Open New Room</Text>
</Button>
) : (
<Button
onClick={handleJoin}
size="300"
variant="Primary"
fill="Solid"
radii="300"
before={
joinState.status === AsyncStatus.Loading && (
<Spinner size="100" variant="Primary" fill="Solid" />
)
}
disabled={joinState.status === AsyncStatus.Loading}
>
<Text size="B300">Join New Room</Text>
</Button>
)}
</RoomInputPlaceholder>
);
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,148 @@
import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
import to from 'await-to-js';
import { IThumbnailContent } from '../../../types/matrix/common';
import {
getImageFileUrl,
getThumbnail,
getThumbnailDimensions,
getVideoFileUrl,
loadImageElement,
loadVideoElement,
} from '../../utils/dom';
import { encryptFile, getImageInfo, getThumbnailContent, getVideoInfo } from '../../utils/matrix';
import { TUploadItem } from '../../state/roomInputDrafts';
import { MATRIX_BLUR_HASH_PROPERTY_NAME, encodeBlurHash } from '../../utils/blurHash';
const generateThumbnailContent = async (
mx: MatrixClient,
img: HTMLImageElement | HTMLVideoElement,
dimensions: [number, number],
encrypt: boolean
): Promise<IThumbnailContent> => {
const thumbnail = await getThumbnail(img, ...dimensions);
if (!thumbnail) throw new Error('Can not create thumbnail!');
const encThumbData = encrypt ? await encryptFile(thumbnail) : undefined;
const thumbnailFile = encThumbData?.file ?? thumbnail;
if (!thumbnailFile) throw new Error('Can not create thumbnail!');
const data = await mx.uploadContent(thumbnailFile);
const thumbMxc = data?.content_uri;
if (!thumbMxc) throw new Error('Failed when uploading thumbnail!');
const thumbnailContent = getThumbnailContent({
thumbnail: thumbnailFile,
encInfo: encThumbData?.encInfo,
mxc: thumbMxc,
width: dimensions[0],
height: dimensions[1],
});
return thumbnailContent;
};
export const getImageMsgContent = async (item: TUploadItem, mxc: string): Promise<IContent> => {
const { file, originalFile, encInfo } = item;
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
if (imgError) console.warn(imgError);
const content: IContent = {
msgtype: MsgType.Image,
body: file.name,
};
if (imgEl) {
content.info = {
...getImageInfo(imgEl, file),
[MATRIX_BLUR_HASH_PROPERTY_NAME]: encodeBlurHash(imgEl),
};
}
if (encInfo) {
content.file = {
...encInfo,
url: mxc,
};
} else {
content.url = mxc;
}
return content;
};
export const getVideoMsgContent = async (
mx: MatrixClient,
item: TUploadItem,
mxc: string
): Promise<IContent> => {
const { file, originalFile, encInfo } = item;
const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
if (videoError) console.warn(videoError);
const content: IContent = {
msgtype: MsgType.Video,
body: file.name,
};
if (videoEl) {
const [thumbError, thumbContent] = await to(
generateThumbnailContent(
mx,
videoEl,
getThumbnailDimensions(videoEl.videoWidth, videoEl.videoHeight),
!!encInfo
)
);
if (thumbError) console.warn(thumbError);
content.info = {
...getVideoInfo(videoEl, file),
...thumbContent,
};
}
if (encInfo) {
content.file = {
...encInfo,
url: mxc,
};
} else {
content.url = mxc;
}
return content;
};
export const getAudioMsgContent = (item: TUploadItem, mxc: string): IContent => {
const { file, encInfo } = item;
const content: IContent = {
msgtype: MsgType.Audio,
body: file.name,
info: {
mimetype: file.type,
size: file.size,
},
};
if (encInfo) {
content.file = {
...encInfo,
url: mxc,
};
} else {
content.url = mxc;
}
return content;
};
export const getFileMsgContent = (item: TUploadItem, mxc: string): IContent => {
const { file, encInfo } = item;
const content: IContent = {
msgtype: MsgType.File,
body: file.name,
filename: file.name,
info: {
mimetype: file.type,
size: file.size,
},
};
if (encInfo) {
content.file = {
...encInfo,
url: mxc,
};
} else {
content.url = mxc;
}
return content;
};

View File

@ -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;

View 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
View 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);
}
});

View 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),
});
}

View 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));
};

View 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));
};

View 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];
};

View 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);
};

View 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
View 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>>;

View 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]);
};

View 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]);
};

View 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
View 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], [])
);
};

View 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]);
};

View 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]);
};

View File

@ -0,0 +1,3 @@
import { atom } from 'jotai';
export const selectedRoomAtom = atom<string | undefined>(undefined);

View 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
View 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);
}
);

View 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
View 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