Refactor state & Custom editor (#1190)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Mon, 12 Jun 2023 11:15:23 +0000 (21:15 +1000)
committerGitHub <noreply@github.com>
Mon, 12 Jun 2023 11:15:23 +0000 (16:45 +0530)
* 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

128 files changed:
.eslintrc.js
index.html
package-lock.json
package.json
public/font/Twemoji.Mozilla.v.7.0.woff2 [new file with mode: 0644]
public/font/Twemoji.Mozilla.v0.7.0.ttf [new file with mode: 0644]
src/app/components/UseStateProvider.tsx [new file with mode: 0644]
src/app/components/editor/Editor.css.ts [new file with mode: 0644]
src/app/components/editor/Editor.preview.tsx [new file with mode: 0644]
src/app/components/editor/Editor.tsx [new file with mode: 0644]
src/app/components/editor/Elements.css.ts [new file with mode: 0644]
src/app/components/editor/Elements.tsx [new file with mode: 0644]
src/app/components/editor/Toolbar.tsx [new file with mode: 0644]
src/app/components/editor/autocomplete/AutocompleteMenu.css.tsx [new file with mode: 0644]
src/app/components/editor/autocomplete/AutocompleteMenu.tsx [new file with mode: 0644]
src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx [new file with mode: 0644]
src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx [new file with mode: 0644]
src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx [new file with mode: 0644]
src/app/components/editor/autocomplete/autocompleteQuery.ts [new file with mode: 0644]
src/app/components/editor/autocomplete/index.ts [new file with mode: 0644]
src/app/components/editor/common.ts [new file with mode: 0644]
src/app/components/editor/index.ts [new file with mode: 0644]
src/app/components/editor/keyboard.ts [new file with mode: 0644]
src/app/components/editor/output.ts [new file with mode: 0644]
src/app/components/editor/slate.d.ts [new file with mode: 0644]
src/app/components/emoji-board/EmojiBoard.css.tsx [new file with mode: 0644]
src/app/components/emoji-board/EmojiBoard.tsx [new file with mode: 0644]
src/app/components/emoji-board/index.ts [new file with mode: 0644]
src/app/components/emoji-board/useEmojiGroupIcons.ts [new file with mode: 0644]
src/app/components/emoji-board/useEmojiGroupLabels.ts [new file with mode: 0644]
src/app/components/sidebar/Sidebar.css.ts [new file with mode: 0644]
src/app/components/sidebar/Sidebar.tsx [new file with mode: 0644]
src/app/components/sidebar/SidebarAvatar.tsx [new file with mode: 0644]
src/app/components/sidebar/SidebarContent.tsx [new file with mode: 0644]
src/app/components/sidebar/SidebarStack.tsx [new file with mode: 0644]
src/app/components/sidebar/SidebarStackSeparator.tsx [new file with mode: 0644]
src/app/components/sidebar/index.ts [new file with mode: 0644]
src/app/components/upload-board/UploadBoard.css.ts [new file with mode: 0644]
src/app/components/upload-board/UploadBoard.tsx [new file with mode: 0644]
src/app/components/upload-board/index.ts [new file with mode: 0644]
src/app/components/upload-card/UploadCard.css.ts [new file with mode: 0644]
src/app/components/upload-card/UploadCard.tsx [new file with mode: 0644]
src/app/components/upload-card/UploadCardRenderer.tsx [new file with mode: 0644]
src/app/components/upload-card/index.ts [new file with mode: 0644]
src/app/hooks/useAlive.ts [new file with mode: 0644]
src/app/hooks/useAsyncCallback.ts [new file with mode: 0644]
src/app/hooks/useAsyncSearch.ts [new file with mode: 0644]
src/app/hooks/useDebounce.ts [new file with mode: 0644]
src/app/hooks/useFileDrop.ts [new file with mode: 0644]
src/app/hooks/useFilePasteHandler.ts [new file with mode: 0644]
src/app/hooks/useFilePicker.ts [new file with mode: 0644]
src/app/hooks/useForceUpdate.ts [new file with mode: 0644]
src/app/hooks/useImagePacks.ts [new file with mode: 0644]
src/app/hooks/useKeyDown.ts [new file with mode: 0644]
src/app/hooks/useMatrixClient.ts [new file with mode: 0644]
src/app/hooks/usePowerLevels.ts [new file with mode: 0644]
src/app/hooks/useRecentEmoji.ts [new file with mode: 0644]
src/app/hooks/useResizeObserver.ts [new file with mode: 0644]
src/app/hooks/useRoomMembers.ts [new file with mode: 0644]
src/app/hooks/useStateEvent.ts [new file with mode: 0644]
src/app/hooks/useStateEventCallback.ts [new file with mode: 0644]
src/app/hooks/useStateEvents.ts [new file with mode: 0644]
src/app/hooks/useThrottle.ts [new file with mode: 0644]
src/app/hooks/useTypingStatusUpdater.ts [new file with mode: 0644]
src/app/molecules/following-members/FollowingMembers.jsx
src/app/organisms/drag-drop/DragDrop.jsx [deleted file]
src/app/organisms/drag-drop/DragDrop.scss [deleted file]
src/app/organisms/navigation/SideBar.scss
src/app/organisms/navigation/Sidebar1.tsx [new file with mode: 0644]
src/app/organisms/room/Room.jsx
src/app/organisms/room/RoomInput.tsx [new file with mode: 0644]
src/app/organisms/room/RoomInputPlaceholder.css.ts [new file with mode: 0644]
src/app/organisms/room/RoomInputPlaceholder.tsx [new file with mode: 0644]
src/app/organisms/room/RoomTombstone.css.ts [new file with mode: 0644]
src/app/organisms/room/RoomTombstone.tsx [new file with mode: 0644]
src/app/organisms/room/RoomView.jsx
src/app/organisms/room/RoomView.scss
src/app/organisms/room/RoomViewContent.jsx
src/app/organisms/room/msgContent.ts [new file with mode: 0644]
src/app/pages/App.jsx
src/app/plugins/custom-emoji.ts [new file with mode: 0644]
src/app/plugins/emoji.ts [new file with mode: 0644]
src/app/plugins/recent-emoji.ts [new file with mode: 0644]
src/app/state/hooks/inviteList.ts [new file with mode: 0644]
src/app/state/hooks/roomList.ts [new file with mode: 0644]
src/app/state/hooks/settings.ts [new file with mode: 0644]
src/app/state/hooks/useBindAtoms.ts [new file with mode: 0644]
src/app/state/inviteList.ts [new file with mode: 0644]
src/app/state/list.ts [new file with mode: 0644]
src/app/state/mDirectList.ts [new file with mode: 0644]
src/app/state/mutedRoomList.ts [new file with mode: 0644]
src/app/state/roomInputDrafts.ts [new file with mode: 0644]
src/app/state/roomList.ts [new file with mode: 0644]
src/app/state/roomToParents.ts [new file with mode: 0644]
src/app/state/roomToUnread.ts [new file with mode: 0644]
src/app/state/selectedRoom.ts [new file with mode: 0644]
src/app/state/selectedTab.ts [new file with mode: 0644]
src/app/state/settings.ts [new file with mode: 0644]
src/app/state/tabToRoom.ts [new file with mode: 0644]
src/app/state/upload.ts [new file with mode: 0644]
src/app/state/utils.ts [new file with mode: 0644]
src/app/templates/client/Client.jsx
src/app/utils/AsyncSearch.ts [new file with mode: 0644]
src/app/utils/blurHash.ts [new file with mode: 0644]
src/app/utils/common.ts [new file with mode: 0644]
src/app/utils/disposable.ts [new file with mode: 0644]
src/app/utils/dom.ts [new file with mode: 0644]
src/app/utils/key-symbol.ts [new file with mode: 0644]
src/app/utils/keyboard.ts [new file with mode: 0644]
src/app/utils/matrix.ts [new file with mode: 0644]
src/app/utils/mimeTypes.ts [new file with mode: 0644]
src/app/utils/room.ts [new file with mode: 0644]
src/app/utils/sanitize.ts [new file with mode: 0644]
src/app/utils/user-agent.ts [new file with mode: 0644]
src/client/initMatrix.js
src/client/mx.ts [new file with mode: 0644]
src/client/state/RoomList.js
src/client/state/settings.js
src/colors.css.ts [new file with mode: 0644]
src/ext.d.ts [new file with mode: 0644]
src/index.jsx
src/index.scss
src/types/matrix/accountData.ts [new file with mode: 0644]
src/types/matrix/common.ts [new file with mode: 0644]
src/types/matrix/room.ts [new file with mode: 0644]
src/util/sanitize.js
tsconfig.json
vite.config.js

index e8f9224ea2bea64062a86308fc24ef9342fd2758..7043741823d097604eeb20450c165e8447e355fc 100644 (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"
   },
 };
index af1a6268f2d7cb9266a0c8eedead207afdd5ad9a..36c5740ae4c2f97261a7a3e6ccc9b482b63a8eb4 100644 (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" />
index 2cca1d0c652f8c03b3a7dd33c6e9cecfa4c7b59e..62675247704851fec64cb45c2792380c27a3270d 100644 (file)
         "@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",
         "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",
@@ -46,6 +60,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",
@@ -61,7 +76,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"
       },
       "engines": {
@@ -72,7 +87,6 @@
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
       "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==",
-      "dev": true,
       "dependencies": {
         "@jridgewell/gen-mapping": "^0.1.0",
         "@jridgewell/trace-mapping": "^0.3.9"
@@ -85,7 +99,6 @@
       "version": "7.18.6",
       "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz",
       "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==",
-      "dev": true,
       "dependencies": {
         "@babel/highlight": "^7.18.6"
       },
       }
     },
     "node_modules/@babel/compat-data": {
-      "version": "7.20.5",
-      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.20.5.tgz",
-      "integrity": "sha512-KZXo2t10+/jxmkhNXc7pZTqRvSOIvVv/+lJwHS+B2rErwOyjuVRh60yVpb7liQ1U5t7lLJ1bz+t8tSypUZdm0g==",
-      "dev": true,
+      "version": "7.20.10",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.20.10.tgz",
+      "integrity": "sha512-sEnuDPpOJR/fcafHMjpcpGN5M2jbUGUHwmuWKM/YdPzeEDJg8bgmbcWQFUfE32MQjti1koACvoPVsDe8Uq+idg==",
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/core": {
-      "version": "7.20.5",
-      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.5.tgz",
-      "integrity": "sha512-UdOWmk4pNWTm/4DlPUl/Pt4Gz4rcEMb7CY0Y3eJl5Yz1vI8ZJGmHWaVE55LoxRjdpx0z259GE9U5STA9atUinQ==",
-      "dev": true,
+      "version": "7.20.12",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz",
+      "integrity": "sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==",
       "dependencies": {
         "@ampproject/remapping": "^2.1.0",
         "@babel/code-frame": "^7.18.6",
-        "@babel/generator": "^7.20.5",
-        "@babel/helper-compilation-targets": "^7.20.0",
-        "@babel/helper-module-transforms": "^7.20.2",
-        "@babel/helpers": "^7.20.5",
-        "@babel/parser": "^7.20.5",
-        "@babel/template": "^7.18.10",
-        "@babel/traverse": "^7.20.5",
-        "@babel/types": "^7.20.5",
+        "@babel/generator": "^7.20.7",
+        "@babel/helper-compilation-targets": "^7.20.7",
+        "@babel/helper-module-transforms": "^7.20.11",
+        "@babel/helpers": "^7.20.7",
+        "@babel/parser": "^7.20.7",
+        "@babel/template": "^7.20.7",
+        "@babel/traverse": "^7.20.12",
+        "@babel/types": "^7.20.7",
         "convert-source-map": "^1.7.0",
         "debug": "^4.1.0",
         "gensync": "^1.0.0-beta.2",
-        "json5": "^2.2.1",
+        "json5": "^2.2.2",
         "semver": "^6.3.0"
       },
       "engines": {
       }
     },
     "node_modules/@babel/generator": {
-      "version": "7.20.5",
-      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.20.5.tgz",
-      "integrity": "sha512-jl7JY2Ykn9S0yj4DQP82sYvPU+T3g0HFcWTqDLqiuA9tGRNIj9VfbtXGAYTTkyNEnQk1jkMGOdYka8aG/lulCA==",
-      "dev": true,
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.20.7.tgz",
+      "integrity": "sha512-7wqMOJq8doJMZmP4ApXTzLxSr7+oO2jroJURrVEp6XShrQUObV8Tq/D0NCcoYg2uHqUrjzO0zwBjoYzelxK+sw==",
       "dependencies": {
-        "@babel/types": "^7.20.5",
+        "@babel/types": "^7.20.7",
         "@jridgewell/gen-mapping": "^0.3.2",
         "jsesc": "^2.5.1"
       },
       "version": "0.3.2",
       "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
       "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
-      "dev": true,
       "dependencies": {
         "@jridgewell/set-array": "^1.0.1",
         "@jridgewell/sourcemap-codec": "^1.4.10",
       }
     },
     "node_modules/@babel/helper-compilation-targets": {
-      "version": "7.20.0",
-      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.0.tgz",
-      "integrity": "sha512-0jp//vDGp9e8hZzBc6N/KwA5ZK3Wsm/pfm4CrY7vzegkVxc65SgSn6wYOnwHe9Js9HRQ1YTCKLGPzDtaS3RoLQ==",
-      "dev": true,
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.7.tgz",
+      "integrity": "sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ==",
       "dependencies": {
-        "@babel/compat-data": "^7.20.0",
+        "@babel/compat-data": "^7.20.5",
         "@babel/helper-validator-option": "^7.18.6",
         "browserslist": "^4.21.3",
+        "lru-cache": "^5.1.1",
         "semver": "^6.3.0"
       },
       "engines": {
         "@babel/core": "^7.0.0"
       }
     },
+    "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "dependencies": {
+        "yallist": "^3.0.2"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets/node_modules/yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
+    },
     "node_modules/@babel/helper-environment-visitor": {
       "version": "7.18.9",
       "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz",
       "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==",
-      "dev": true,
       "engines": {
         "node": ">=6.9.0"
       }
       "version": "7.19.0",
       "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz",
       "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==",
-      "dev": true,
       "dependencies": {
         "@babel/template": "^7.18.10",
         "@babel/types": "^7.19.0"
       "version": "7.18.6",
       "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz",
       "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==",
-      "dev": true,
       "dependencies": {
         "@babel/types": "^7.18.6"
       },
       "version": "7.18.6",
       "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
       "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
-      "dev": true,
       "dependencies": {
         "@babel/types": "^7.18.6"
       },
       }
     },
     "node_modules/@babel/helper-module-transforms": {
-      "version": "7.20.2",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.20.2.tgz",
-      "integrity": "sha512-zvBKyJXRbmK07XhMuujYoJ48B5yvvmM6+wcpv6Ivj4Yg6qO7NOZOSnvZN9CRl1zz1Z4cKf8YejmCMh8clOoOeA==",
-      "dev": true,
+      "version": "7.20.11",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.20.11.tgz",
+      "integrity": "sha512-uRy78kN4psmji1s2QtbtcCSaj/LILFDp0f/ymhpQH5QY3nljUZCaNWz9X1dEj/8MBdBEFECs7yRhKn8i7NjZgg==",
       "dependencies": {
         "@babel/helper-environment-visitor": "^7.18.9",
         "@babel/helper-module-imports": "^7.18.6",
         "@babel/helper-simple-access": "^7.20.2",
         "@babel/helper-split-export-declaration": "^7.18.6",
         "@babel/helper-validator-identifier": "^7.19.1",
-        "@babel/template": "^7.18.10",
-        "@babel/traverse": "^7.20.1",
-        "@babel/types": "^7.20.2"
+        "@babel/template": "^7.20.7",
+        "@babel/traverse": "^7.20.10",
+        "@babel/types": "^7.20.7"
       },
       "engines": {
         "node": ">=6.9.0"
       "version": "7.20.2",
       "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.20.2.tgz",
       "integrity": "sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ==",
-      "dev": true,
       "engines": {
         "node": ">=6.9.0"
       }
       "version": "7.20.2",
       "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz",
       "integrity": "sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==",
-      "dev": true,
       "dependencies": {
         "@babel/types": "^7.20.2"
       },
       "version": "7.18.6",
       "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz",
       "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==",
-      "dev": true,
       "dependencies": {
         "@babel/types": "^7.18.6"
       },
       "version": "7.19.4",
       "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz",
       "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==",
-      "dev": true,
       "engines": {
         "node": ">=6.9.0"
       }
       "version": "7.19.1",
       "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
       "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
-      "dev": true,
       "engines": {
         "node": ">=6.9.0"
       }
       "version": "7.18.6",
       "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz",
       "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==",
-      "dev": true,
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helpers": {
-      "version": "7.20.6",
-      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.20.6.tgz",
-      "integrity": "sha512-Pf/OjgfgFRW5bApskEz5pvidpim7tEDPlFtKcNRXWmfHGn9IEI2W2flqRQXTFb7gIPTyK++N6rVHuwKut4XK6w==",
-      "dev": true,
+      "version": "7.20.13",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.20.13.tgz",
+      "integrity": "sha512-nzJ0DWCL3gB5RCXbUO3KIMMsBY2Eqbx8mBpKGE/02PgyRQFcPQLbkQ1vyy596mZLaP+dAfD+R4ckASzNVmW3jg==",
       "dependencies": {
-        "@babel/template": "^7.18.10",
-        "@babel/traverse": "^7.20.5",
-        "@babel/types": "^7.20.5"
+        "@babel/template": "^7.20.7",
+        "@babel/traverse": "^7.20.13",
+        "@babel/types": "^7.20.7"
       },
       "engines": {
         "node": ">=6.9.0"
       "version": "7.18.6",
       "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz",
       "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==",
-      "dev": true,
       "dependencies": {
         "@babel/helper-validator-identifier": "^7.18.6",
         "chalk": "^2.0.0",
       }
     },
     "node_modules/@babel/parser": {
-      "version": "7.20.5",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.5.tgz",
-      "integrity": "sha512-r27t/cy/m9uKLXQNWWebeCUHgnAZq0CpG1OwKRxzJMP1vpSU4bSIK2hq+/cp0bQxetkXx38n09rNu8jVkcK/zA==",
-      "dev": true,
+      "version": "7.20.13",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.13.tgz",
+      "integrity": "sha512-gFDLKMfpiXCsjt4za2JA9oTMn70CeseCehb11kRZgvd7+F67Hih3OHOK24cRrWECJ/ljfPGac6ygXAs/C8kIvw==",
       "bin": {
         "parser": "bin/babel-parser.js"
       },
         "node": ">=6.0.0"
       }
     },
+    "node_modules/@babel/plugin-syntax-typescript": {
+      "version": "7.20.0",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.20.0.tgz",
+      "integrity": "sha512-rd9TkG+u1CExzS4SM1BlMEhMXwFLKVjOAFFCDx9PbX5ycJWDoWMcwdJH9RhkPu1dOgn5TrxLot/Gx6lWFuAUNQ==",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.19.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
     "node_modules/@babel/plugin-transform-react-jsx-self": {
       "version": "7.18.6",
       "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.18.6.tgz",
       "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
     },
     "node_modules/@babel/template": {
-      "version": "7.18.10",
-      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz",
-      "integrity": "sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==",
-      "dev": true,
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz",
+      "integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==",
       "dependencies": {
         "@babel/code-frame": "^7.18.6",
-        "@babel/parser": "^7.18.10",
-        "@babel/types": "^7.18.10"
+        "@babel/parser": "^7.20.7",
+        "@babel/types": "^7.20.7"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/traverse": {
-      "version": "7.20.5",
-      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.5.tgz",
-      "integrity": "sha512-WM5ZNN3JITQIq9tFZaw1ojLU3WgWdtkxnhM1AegMS+PvHjkM5IXjmYEGY7yukz5XS4sJyEf2VzWjI8uAavhxBQ==",
-      "dev": true,
+      "version": "7.20.13",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.13.tgz",
+      "integrity": "sha512-kMJXfF0T6DIS9E8cgdLCSAL+cuCK+YEZHWiLK0SXpTo8YRj5lpJu3CDNKiIBCne4m9hhTIqUg6SYTAI39tAiVQ==",
       "dependencies": {
         "@babel/code-frame": "^7.18.6",
-        "@babel/generator": "^7.20.5",
+        "@babel/generator": "^7.20.7",
         "@babel/helper-environment-visitor": "^7.18.9",
         "@babel/helper-function-name": "^7.19.0",
         "@babel/helper-hoist-variables": "^7.18.6",
         "@babel/helper-split-export-declaration": "^7.18.6",
-        "@babel/parser": "^7.20.5",
-        "@babel/types": "^7.20.5",
+        "@babel/parser": "^7.20.13",
+        "@babel/types": "^7.20.7",
         "debug": "^4.1.0",
         "globals": "^11.1.0"
       },
       }
     },
     "node_modules/@babel/types": {
-      "version": "7.20.5",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.20.5.tgz",
-      "integrity": "sha512-c9fst/h2/dcF7H+MJKZ2T0KjEQ8hY/BNnDk/H3XY8C4Aw/eWQXWn/lWntHF9ooUBnGmEvbfGrTgLWc+um0YDUg==",
-      "dev": true,
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.20.7.tgz",
+      "integrity": "sha512-69OnhBxSSgK0OzTJai4kyPDiKTIe3j+ctaHdIGVbRahTLAT7L3R9oeXHC2aVSuGYt3cVnoAMDmOCgJ2yaiLMvg==",
       "dependencies": {
         "@babel/helper-string-parser": "^7.19.4",
         "@babel/helper-validator-identifier": "^7.19.1",
         "node": ">=6.9.0"
       }
     },
+    "node_modules/@emotion/hash": {
+      "version": "0.9.0",
+      "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.0.tgz",
+      "integrity": "sha512-14FtKiHhy2QoPIzdTcvh//8OyBlknNs2nXRwIhG904opCby3l+9Xaf/wuPvICBF0rc1ZCNBd3nKe9cd2mecVkQ=="
+    },
     "node_modules/@esbuild-plugins/node-globals-polyfill": {
       "version": "0.2.3",
       "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz",
       "cpu": [
         "arm"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "android"
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "android"
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "android"
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "darwin"
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "darwin"
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "freebsd"
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "freebsd"
       "cpu": [
         "arm"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
       "cpu": [
         "ia32"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
       "cpu": [
         "loong64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
       "cpu": [
         "mips64el"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
       "cpu": [
         "ppc64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
       "cpu": [
         "riscv64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
       "cpu": [
         "s390x"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "netbsd"
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "openbsd"
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "sunos"
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "win32"
       "cpu": [
         "ia32"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "win32"
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "win32"
       "version": "0.1.1",
       "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
       "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==",
-      "dev": true,
       "dependencies": {
         "@jridgewell/set-array": "^1.0.0",
         "@jridgewell/sourcemap-codec": "^1.4.10"
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
       "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
-      "dev": true,
       "engines": {
         "node": ">=6.0.0"
       }
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
       "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
-      "dev": true,
       "engines": {
         "node": ">=6.0.0"
       }
     "node_modules/@jridgewell/sourcemap-codec": {
       "version": "1.4.14",
       "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
-      "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
-      "dev": true
+      "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw=="
     },
     "node_modules/@jridgewell/trace-mapping": {
       "version": "0.3.17",
       "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz",
       "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==",
-      "dev": true,
       "dependencies": {
         "@jridgewell/resolve-uri": "3.1.0",
         "@jridgewell/sourcemap-codec": "1.4.14"
       }
     },
+    "node_modules/@juggle/resize-observer": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
+      "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
+    },
     "node_modules/@khanacademy/simple-markdown": {
       "version": "0.8.6",
       "resolved": "https://registry.npmjs.org/@khanacademy/simple-markdown/-/simple-markdown-0.8.6.tgz",
       "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
       "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g=="
     },
+    "node_modules/@types/is-hotkey": {
+      "version": "0.1.7",
+      "resolved": "https://registry.npmjs.org/@types/is-hotkey/-/is-hotkey-0.1.7.tgz",
+      "integrity": "sha512-yB5C7zcOM7idwYZZ1wKQ3pTfjA9BbvFqRWvKB46GFddxnJtHwi/b9y84ykQtxQPg5qhdpg4Q/kWU3EGoCTmLzQ=="
+    },
     "node_modules/@types/json-schema": {
       "version": "7.0.11",
       "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
       "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
       "dev": true
     },
+    "node_modules/@types/lodash": {
+      "version": "4.14.191",
+      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz",
+      "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ=="
+    },
     "node_modules/@types/node": {
       "version": "18.11.18",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
       "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==",
       "dev": true
     },
+    "node_modules/@types/ua-parser-js": {
+      "version": "0.7.36",
+      "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz",
+      "integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==",
+      "dev": true
+    },
     "node_modules/@typescript-eslint/eslint-plugin": {
       "version": "5.46.1",
       "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.46.1.tgz",
         "url": "https://opencollective.com/typescript-eslint"
       }
     },
+    "node_modules/@vanilla-extract/babel-plugin-debug-ids": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@vanilla-extract/babel-plugin-debug-ids/-/babel-plugin-debug-ids-1.0.1.tgz",
+      "integrity": "sha512-ynyKqsJiMzM1/yiIJ6QdqpWKlK4IMJJWREpPtaemZrE1xG1B4E/Nfa6YazuDWjDkCJC1tRIpEGnVs+pMIjUxyw==",
+      "dependencies": {
+        "@babel/core": "^7.20.7"
+      }
+    },
+    "node_modules/@vanilla-extract/css": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/@vanilla-extract/css/-/css-1.9.3.tgz",
+      "integrity": "sha512-vitcD8usEOTWDLAnbtnZ46YbHADAp3Es+3xyHsMDMZOEWk03FhD+PbR58kdwtGpr258+hMryCYtQPeFh5lWFbA==",
+      "dependencies": {
+        "@emotion/hash": "^0.9.0",
+        "@vanilla-extract/private": "^1.0.3",
+        "ahocorasick": "1.0.2",
+        "chalk": "^4.1.1",
+        "css-what": "^5.0.1",
+        "cssesc": "^3.0.0",
+        "csstype": "^3.0.7",
+        "deep-object-diff": "^1.1.0",
+        "deepmerge": "^4.2.2",
+        "media-query-parser": "^2.0.2",
+        "outdent": "^0.8.0"
+      }
+    },
+    "node_modules/@vanilla-extract/css/node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/@vanilla-extract/css/node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/@vanilla-extract/css/node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/@vanilla-extract/css/node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+    },
+    "node_modules/@vanilla-extract/css/node_modules/deepmerge": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
+      "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/@vanilla-extract/css/node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@vanilla-extract/css/node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@vanilla-extract/integration": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-6.0.2.tgz",
+      "integrity": "sha512-LwfXlh0THeNvVXdA3iWFYvJs1mvEP1PkfQD/7S6Purry7L8iDizDV/87FgWBJ79FnTmYIvMrc7BOQsUajNj9VQ==",
+      "dependencies": {
+        "@babel/core": "^7.20.7",
+        "@babel/plugin-syntax-typescript": "^7.20.0",
+        "@vanilla-extract/babel-plugin-debug-ids": "^1.0.1",
+        "@vanilla-extract/css": "^1.9.3",
+        "esbuild": "^0.16.3",
+        "eval": "0.1.6",
+        "find-up": "^5.0.0",
+        "javascript-stringify": "^2.0.1",
+        "lodash": "^4.17.21",
+        "outdent": "^0.8.0"
+      }
+    },
+    "node_modules/@vanilla-extract/private": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@vanilla-extract/private/-/private-1.0.3.tgz",
+      "integrity": "sha512-17kVyLq3ePTKOkveHxXuIJZtGYs+cSoev7BlP+Lf4916qfDhk/HBjvlYDe8egrea7LNPHKwSZJK/bzZC+Q6AwQ=="
+    },
+    "node_modules/@vanilla-extract/recipes": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@vanilla-extract/recipes/-/recipes-0.3.0.tgz",
+      "integrity": "sha512-7wXrgfq1oldKdBfCKen4XmSlDmQR+4o0CQ3WnnLfhQaEtI65xJ774yyQF6dD2CC+hHdW2LFKVXgH5NZRbMQ8Sg==",
+      "peerDependencies": {
+        "@vanilla-extract/css": "^1.0.0"
+      }
+    },
+    "node_modules/@vanilla-extract/vite-plugin": {
+      "version": "3.7.1",
+      "resolved": "https://registry.npmjs.org/@vanilla-extract/vite-plugin/-/vite-plugin-3.7.1.tgz",
+      "integrity": "sha512-KFeTSEJKtJDfQhUJh4jGmrJDLCU59DSA3YKZSdys4jTOLZ1ZFsKzDP2pnFwH/24Oc2ebK+EV5x3OPlWxvRYthg==",
+      "dependencies": {
+        "@vanilla-extract/integration": "^6.0.2",
+        "outdent": "^0.8.0",
+        "postcss": "^8.3.6",
+        "postcss-load-config": "^3.1.0"
+      },
+      "peerDependencies": {
+        "vite": "^2.2.3 || ^3.0.0 || ^4.0.3"
+      }
+    },
     "node_modules/@vitejs/plugin-react": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-3.0.0.tgz",
         "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
       }
     },
+    "node_modules/ahocorasick": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/ahocorasick/-/ahocorasick-1.0.2.tgz",
+      "integrity": "sha512-hCOfMzbFx5IDutmWLAt6MZwOUjIfSM9G9FyVxytmE4Rs/5YDPWQrD/+IR1w+FweD9H2oOZEnv36TmkjhNURBVA=="
+    },
     "node_modules/ajv": {
       "version": "6.12.6",
       "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
       "version": "3.2.1",
       "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
       "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
-      "dev": true,
       "dependencies": {
         "color-convert": "^1.9.0"
       },
       "resolved": "https://registry.npmjs.org/autosize/-/autosize-4.0.4.tgz",
       "integrity": "sha512-5yxLQ22O0fCRGoxGfeLSNt3J8LB1v+umtpMnPW6XjkTWXKoN0AmXAIhelJcDtFT/Y/wYWmfE+oqU10Q0b8FhaQ=="
     },
+    "node_modules/await-to-js": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz",
+      "integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
     "node_modules/axe-core": {
       "version": "4.6.0",
       "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.6.0.tgz",
       "version": "4.21.4",
       "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
       "integrity": "sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==",
-      "dev": true,
       "funding": [
         {
           "type": "opencollective",
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001439",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001439.tgz",
-      "integrity": "sha512-1MgUzEkoMO6gKfXflStpYgZDlFM7M/ck/bgfVCACO5vnAf0fXoNVHdWtqGU+MYca+4bL9Z5bpOVmR33cWW9G2A==",
-      "dev": true,
+      "version": "1.0.30001446",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001446.tgz",
+      "integrity": "sha512-fEoga4PrImGcwUUGEol/PoFCSBnSkA9drgdkxXkJLsUBOnJ8rs3zDv6ApqYXGQFOyMPsjh79naWhF4DAxbF8rw==",
       "funding": [
         {
           "type": "opencollective",
       "version": "2.4.2",
       "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
       "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
-      "dev": true,
       "dependencies": {
         "ansi-styles": "^3.2.1",
         "escape-string-regexp": "^1.0.5",
         "node": ">= 6"
       }
     },
+    "node_modules/classnames": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
+      "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
+    },
     "node_modules/color-convert": {
       "version": "1.9.3",
       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
       "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
-      "dev": true,
       "dependencies": {
         "color-name": "1.1.3"
       }
     "node_modules/color-name": {
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
-      "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
-      "dev": true
+      "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
+    },
+    "node_modules/compute-scroll-into-view": {
+      "version": "1.0.20",
+      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
+      "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="
     },
     "node_modules/computed-style": {
       "version": "0.1.4",
     "node_modules/convert-source-map": {
       "version": "1.9.0",
       "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
-      "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
-      "dev": true
+      "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
     },
     "node_modules/core-js-pure": {
       "version": "3.26.1",
         "node": ">= 8"
       }
     },
+    "node_modules/css-what": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/css-what/-/css-what-5.1.0.tgz",
+      "integrity": "sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw==",
+      "engines": {
+        "node": ">= 6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/fb55"
+      }
+    },
+    "node_modules/cssesc": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+      "bin": {
+        "cssesc": "bin/cssesc"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/csstype": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz",
       "version": "4.3.4",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
       "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
-      "dev": true,
       "dependencies": {
         "ms": "2.1.2"
       },
       "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
       "dev": true
     },
+    "node_modules/deep-object-diff": {
+      "version": "1.1.9",
+      "resolved": "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.9.tgz",
+      "integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA=="
+    },
     "node_modules/deepmerge": {
       "version": "2.2.1",
       "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
         "node": ">=8"
       }
     },
+    "node_modules/direction": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz",
+      "integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==",
+      "bin": {
+        "direction": "cli.js"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/dnd-core": {
       "version": "15.1.2",
       "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-15.1.2.tgz",
     "node_modules/electron-to-chromium": {
       "version": "1.4.284",
       "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz",
-      "integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==",
-      "dev": true
+      "integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA=="
     },
     "node_modules/emoji-regex": {
       "version": "9.2.2",
       "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
       "dev": true
     },
+    "node_modules/emojibase": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/emojibase/-/emojibase-6.1.0.tgz",
+      "integrity": "sha512-1GkKJPXP6tVkYJHOBSJHoGOr/6uaDxZ9xJ6H7m6PfdGXTmQgbALHLWaVRY4Gi/qf5x/gT/NUXLPuSHYLqtLtrQ==",
+      "funding": {
+        "type": "ko-fi",
+        "url": "https://ko-fi.com/milesjohnson"
+      }
+    },
     "node_modules/emojibase-data": {
       "version": "7.0.1",
       "resolved": "https://registry.npmjs.org/emojibase-data/-/emojibase-data-7.0.1.tgz",
       "version": "0.16.9",
       "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.16.9.tgz",
       "integrity": "sha512-gkH83yHyijMSZcZFs1IWew342eMdFuWXmQo3zkDPTre25LIPBJsXryg02M3u8OpTwCJdBkdaQwqKkDLnAsAeLQ==",
-      "dev": true,
       "hasInstallScript": true,
       "bin": {
         "esbuild": "bin/esbuild"
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
       "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
-      "dev": true,
       "engines": {
         "node": ">=6"
       }
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
       "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
-      "dev": true,
       "engines": {
         "node": ">=0.8.0"
       }
         "node": ">=0.10.0"
       }
     },
+    "node_modules/eval": {
+      "version": "0.1.6",
+      "resolved": "https://registry.npmjs.org/eval/-/eval-0.1.6.tgz",
+      "integrity": "sha512-o0XUw+5OGkXw4pJZzQoXUk+H87DHuC+7ZE//oSrRGtatTmr12oTnLfg6QOq9DyTt0c/p4TwzgmkKrBzWTSizyQ==",
+      "dependencies": {
+        "require-like": ">= 0.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/events": {
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
       "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
       "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ=="
     },
+    "node_modules/fbjs/node_modules/ua-parser-js": {
+      "version": "0.7.35",
+      "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.35.tgz",
+      "integrity": "sha512-veRf7dawaj9xaWEu9HoTVn5Pggtc/qj+kqTOFvNiN1l0YdxwC1kvel57UCjThjGa3BHBihE8/UJAHI+uQHmd/g==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/ua-parser-js"
+        },
+        {
+          "type": "paypal",
+          "url": "https://paypal.me/faisalman"
+        }
+      ],
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/file-entry-cache": {
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
       "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
-      "dev": true,
       "dependencies": {
         "locate-path": "^6.0.0",
         "path-exists": "^4.0.0"
         "react": "^15.0.2 || ^16.0.0 || ^17.0.0"
       }
     },
+    "node_modules/focus-trap": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.2.0.tgz",
+      "integrity": "sha512-v4wY6HDDYvzkBy4735kW5BUEuw6Yz9ABqMYLuTNbzAFPcBOGiGHwwcNVMvUz4G0kgSYh13wa/7TG3XwTeT4O/A==",
+      "dependencies": {
+        "tabbable": "^6.0.1"
+      }
+    },
+    "node_modules/focus-trap-react": {
+      "version": "10.0.2",
+      "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.0.2.tgz",
+      "integrity": "sha512-MnN2cmdgpY7NY74ePOio4kbO5A3ILhrg1g5OGbgIQjcWEv1hhcbh6e98K0a+df88hNbE+4i9r8ji9aQnHou6GA==",
+      "dependencies": {
+        "focus-trap": "^7.2.0",
+        "tabbable": "^6.0.1"
+      },
+      "peerDependencies": {
+        "prop-types": "^15.8.1",
+        "react": ">=16.3.0",
+        "react-dom": ">=16.3.0"
+      }
+    },
+    "node_modules/folds": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/folds/-/folds-1.2.1.tgz",
+      "integrity": "sha512-BCV5oFCndiGFp1HyeSnbDKmTSbu1yfAtAIF6znPvLthuI/QG4516bBUr6+MyNUQWx/IAkj1bdQL/cdD+jEZWCw==",
+      "peerDependencies": {
+        "@vanilla-extract/css": "^1.9.2",
+        "@vanilla-extract/recipes": "^0.3.0",
+        "classnames": "^2.3.2",
+        "react": "^17.0.0",
+        "react-dom": "^17.0.0"
+      }
+    },
     "node_modules/formik": {
       "version": "2.2.9",
       "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz",
       "version": "1.0.0-beta.2",
       "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
       "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
-      "dev": true,
       "engines": {
         "node": ">=6.9.0"
       }
       "version": "11.12.0",
       "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
       "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
-      "dev": true,
       "engines": {
         "node": ">=4"
       }
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
       "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
-      "dev": true,
       "engines": {
         "node": ">=4"
       }
         "node": ">= 4"
       }
     },
+    "node_modules/immer": {
+      "version": "9.0.16",
+      "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.16.tgz",
+      "integrity": "sha512-qenGE7CstVm1NrHQbMh8YaSzTZTFNP3zPqr3YU0S0UY441j4bJTg4A2Hh5KAhwgaiU6ZZ1Ar6y/2f4TblnMReQ==",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/immer"
+      }
+    },
     "node_modules/immutable": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz",
         "node": ">=0.10.0"
       }
     },
+    "node_modules/is-hotkey": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
+      "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="
+    },
     "node_modules/is-negative-zero": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
       "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
       "dev": true
     },
+    "node_modules/javascript-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz",
+      "integrity": "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="
+    },
+    "node_modules/jotai": {
+      "version": "1.12.0",
+      "resolved": "https://registry.npmjs.org/jotai/-/jotai-1.12.0.tgz",
+      "integrity": "sha512-IhyBmjxU1sE2Ni/MUK7gQAb8QvCM6yd1/K5jtQzgQBmmjCjgfXZkkk1rYlQAIRp2KoQk0Y+yzhm1f5cZ7kegnw==",
+      "engines": {
+        "node": ">=12.20.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "*",
+        "@babel/template": "*",
+        "jotai-immer": "*",
+        "jotai-optics": "*",
+        "jotai-redux": "*",
+        "jotai-tanstack-query": "*",
+        "jotai-urql": "*",
+        "jotai-valtio": "*",
+        "jotai-xstate": "*",
+        "jotai-zustand": "*",
+        "react": ">=16.8"
+      },
+      "peerDependenciesMeta": {
+        "@babel/core": {
+          "optional": true
+        },
+        "@babel/template": {
+          "optional": true
+        },
+        "jotai-immer": {
+          "optional": true
+        },
+        "jotai-optics": {
+          "optional": true
+        },
+        "jotai-redux": {
+          "optional": true
+        },
+        "jotai-tanstack-query": {
+          "optional": true
+        },
+        "jotai-urql": {
+          "optional": true
+        },
+        "jotai-valtio": {
+          "optional": true
+        },
+        "jotai-xstate": {
+          "optional": true
+        },
+        "jotai-zustand": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/js-sdsl": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz",
       "version": "2.5.2",
       "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
       "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
-      "dev": true,
       "bin": {
         "jsesc": "bin/jsesc"
       },
       "dev": true
     },
     "node_modules/json5": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
-      "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
-      "dev": true,
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
       "bin": {
         "json5": "lib/cli.js"
       },
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/lilconfig": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz",
+      "integrity": "sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==",
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/line-height": {
       "version": "0.3.1",
       "resolved": "https://registry.npmjs.org/line-height/-/line-height-0.3.1.tgz",
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
       "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
-      "dev": true,
       "dependencies": {
         "p-locate": "^5.0.0"
       },
         "events": "^3.2.0"
       }
     },
+    "node_modules/media-query-parser": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/media-query-parser/-/media-query-parser-2.0.2.tgz",
+      "integrity": "sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==",
+      "dependencies": {
+        "@babel/runtime": "^7.12.5"
+      }
+    },
     "node_modules/merge2": {
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
     "node_modules/ms": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
-      "dev": true
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
     },
     "node_modules/nanoid": {
       "version": "3.3.4",
       }
     },
     "node_modules/node-releases": {
-      "version": "2.0.7",
-      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.7.tgz",
-      "integrity": "sha512-EJ3rzxL9pTWPjk5arA0s0dgXpnyiAbJDE6wHT62g7VsgrgQgmmZ+Ru++M1BFofncWja+Pnn3rEr3fieRySAdKQ==",
-      "dev": true
+      "version": "2.0.8",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.8.tgz",
+      "integrity": "sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A=="
     },
     "node_modules/normalize-path": {
       "version": "3.0.0",
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/outdent": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.8.0.tgz",
+      "integrity": "sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A=="
+    },
     "node_modules/p-limit": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
       "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
-      "dev": true,
       "dependencies": {
         "yocto-queue": "^0.1.0"
       },
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
       "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
-      "dev": true,
       "dependencies": {
         "p-limit": "^3.0.2"
       },
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
       "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
-      "dev": true,
       "engines": {
         "node": ">=8"
       }
         "node": "^10 || ^12 || >=14"
       }
     },
+    "node_modules/postcss-load-config": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz",
+      "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==",
+      "dependencies": {
+        "lilconfig": "^2.0.5",
+        "yaml": "^1.10.2"
+      },
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/postcss/"
+      },
+      "peerDependencies": {
+        "postcss": ">=8.0.9",
+        "ts-node": ">=9.0.0"
+      },
+      "peerDependenciesMeta": {
+        "postcss": {
+          "optional": true
+        },
+        "ts-node": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/prelude-ls": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
         "url": "https://github.com/sponsors/mysticatea"
       }
     },
+    "node_modules/require-like": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz",
+      "integrity": "sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==",
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/resolve": {
       "version": "1.22.1",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
         "object-assign": "^4.1.1"
       }
     },
+    "node_modules/scroll-into-view-if-needed": {
+      "version": "2.2.31",
+      "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
+      "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
+      "dependencies": {
+        "compute-scroll-into-view": "^1.0.20"
+      }
+    },
     "node_modules/sdp-transform": {
       "version": "2.14.1",
       "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.14.1.tgz",
       "version": "6.3.0",
       "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
       "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
-      "dev": true,
       "bin": {
         "semver": "bin/semver.js"
       }
         "node": ">=8"
       }
     },
+    "node_modules/slate": {
+      "version": "0.90.0",
+      "resolved": "https://registry.npmjs.org/slate/-/slate-0.90.0.tgz",
+      "integrity": "sha512-dv8idv0JjYyHiAJcVKf5yWKPDMTDi+PSZyfjsnquEI8VB5nmTVGjeJab06lc3o69O7aN05ROwO9/OY8mU1IUPA==",
+      "dependencies": {
+        "immer": "^9.0.6",
+        "is-plain-object": "^5.0.0",
+        "tiny-warning": "^1.0.3"
+      }
+    },
+    "node_modules/slate-react": {
+      "version": "0.90.0",
+      "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.90.0.tgz",
+      "integrity": "sha512-z6pGd6jjU5VazLxlDi6zL3a6yaPBPJ+A2VyIlE/h/rvDywaLYGvk0xcrA9NrK71Dr47HK5ZN2zFEZNleh6wlPA==",
+      "dependencies": {
+        "@juggle/resize-observer": "^3.4.0",
+        "@types/is-hotkey": "^0.1.1",
+        "@types/lodash": "^4.14.149",
+        "direction": "^1.0.3",
+        "is-hotkey": "^0.1.6",
+        "is-plain-object": "^5.0.0",
+        "lodash": "^4.17.4",
+        "scroll-into-view-if-needed": "^2.2.20",
+        "tiny-invariant": "1.0.6"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0",
+        "react-dom": ">=16.8.0",
+        "slate": ">=0.65.3"
+      }
+    },
+    "node_modules/slate-react/node_modules/is-hotkey": {
+      "version": "0.1.8",
+      "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.1.8.tgz",
+      "integrity": "sha512-qs3NZ1INIS+H+yeo7cD9pDfwYV/jqRh1JG9S9zYrNudkoUQg7OL7ziXqRKu+InFjUIDoP2o6HIkLYMh1pcWgyQ=="
+    },
     "node_modules/source-map-js": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
       "version": "5.5.0",
       "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
       "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
-      "dev": true,
       "dependencies": {
         "has-flag": "^3.0.0"
       },
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/tabbable": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.0.1.tgz",
+      "integrity": "sha512-SYJSIgeyXW7EuX1ytdneO5e8jip42oHWg9xl/o3oTYhmXusZVgiA+VlPvjIN+kHii9v90AmzTZEBcsEvuAY+TA=="
+    },
     "node_modules/text-table": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
       "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
       "dev": true
     },
+    "node_modules/tiny-invariant": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.0.6.tgz",
+      "integrity": "sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA=="
+    },
     "node_modules/tiny-warning": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
       "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
-      "dev": true,
       "engines": {
         "node": ">=4"
       }
       }
     },
     "node_modules/ua-parser-js": {
-      "version": "0.7.32",
-      "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.32.tgz",
-      "integrity": "sha512-f9BESNVhzlhEFf2CHMSj40NWOjYPl1YKYbrvIr/hFTDEmLq7SRbWvm7FcdcpCYT95zrOhC7gZSxjdnnTpBcwVw==",
+      "version": "1.0.35",
+      "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz",
+      "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==",
       "funding": [
         {
           "type": "opencollective",
       "version": "1.0.10",
       "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz",
       "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==",
-      "dev": true,
       "funding": [
         {
           "type": "opencollective",
       }
     },
     "node_modules/vite": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-4.0.1.tgz",
-      "integrity": "sha512-kZQPzbDau35iWOhy3CpkrRC7It+HIHtulAzBhMqzGHKRf/4+vmh8rPDDdv98SWQrFWo6//3ozwsRmwQIPZsK9g==",
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-4.0.4.tgz",
+      "integrity": "sha512-xevPU7M8FU0i/80DMR+YhgrzR5KS2ORy1B4xcX/cXLsvnUWvfHuqMmVU6N0YiJ4JWGRJJsLCgjEzKjG9/GKoSw==",
       "dev": true,
       "dependencies": {
         "esbuild": "^0.16.3",
       "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
       "dev": true
     },
+    "node_modules/yaml": {
+      "version": "1.10.2",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+      "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/yocto-queue": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
       "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
-      "dev": true,
       "engines": {
         "node": ">=10"
       },
index a37b6242c64f48e7794506c1763e9870ae3a5f85..38be28dff23c7ec58ef21cc190deb0482d20d8a3 100644 (file)
     "@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",
     "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"
   }
 }
diff --git a/public/font/Twemoji.Mozilla.v.7.0.woff2 b/public/font/Twemoji.Mozilla.v.7.0.woff2
new file mode 100644 (file)
index 0000000..b3b20e9
Binary files /dev/null and b/public/font/Twemoji.Mozilla.v.7.0.woff2 differ
diff --git a/public/font/Twemoji.Mozilla.v0.7.0.ttf b/public/font/Twemoji.Mozilla.v0.7.0.ttf
new file mode 100644 (file)
index 0000000..9f45178
Binary files /dev/null and b/public/font/Twemoji.Mozilla.v0.7.0.ttf differ
diff --git a/src/app/components/UseStateProvider.tsx b/src/app/components/UseStateProvider.tsx
new file mode 100644 (file)
index 0000000..21e5b3c
--- /dev/null
@@ -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));
+}
diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts
new file mode 100644 (file)
index 0000000..034ded7
--- /dev/null
@@ -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,
+  },
+]);
diff --git a/src/app/components/editor/Editor.preview.tsx b/src/app/components/editor/Editor.preview.tsx
new file mode 100644 (file)
index 0000000..ad67dc1
--- /dev/null
@@ -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>
+    </>
+  );
+}
diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx
new file mode 100644 (file)
index 0000000..edf1ac6
--- /dev/null
@@ -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>
+    );
+  }
+);
diff --git a/src/app/components/editor/Elements.css.ts b/src/app/components/editor/Elements.css.ts
new file mode 100644 (file)
index 0000000..99d037d
--- /dev/null
@@ -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',
+  },
+]);
diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx
new file mode 100644 (file)
index 0000000..59893e5
--- /dev/null
@@ -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>;
+}
diff --git a/src/app/components/editor/Toolbar.tsx b/src/app/components/editor/Toolbar.tsx
new file mode 100644 (file)
index 0000000..a84fca2
--- /dev/null
@@ -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>
+  );
+}
diff --git a/src/app/components/editor/autocomplete/AutocompleteMenu.css.tsx b/src/app/components/editor/autocomplete/AutocompleteMenu.css.tsx
new file mode 100644 (file)
index 0000000..98f653e
--- /dev/null
@@ -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 },
+]);
diff --git a/src/app/components/editor/autocomplete/AutocompleteMenu.tsx b/src/app/components/editor/autocomplete/AutocompleteMenu.tsx
new file mode 100644 (file)
index 0000000..d89cda0
--- /dev/null
@@ -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>
+  );
+}
diff --git a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx
new file mode 100644 (file)
index 0000000..e5af3fa
--- /dev/null
@@ -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>
+  );
+}
diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx
new file mode 100644 (file)
index 0000000..2edfb8b
--- /dev/null
@@ -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>
+  );
+}
diff --git a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx
new file mode 100644 (file)
index 0000000..10088ad
--- /dev/null
@@ -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>
+  );
+}
diff --git a/src/app/components/editor/autocomplete/autocompleteQuery.ts b/src/app/components/editor/autocomplete/autocompleteQuery.ts
new file mode 100644 (file)
index 0000000..348b446
--- /dev/null
@@ -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),
+  };
+};
diff --git a/src/app/components/editor/autocomplete/index.ts b/src/app/components/editor/autocomplete/index.ts
new file mode 100644 (file)
index 0000000..be6a744
--- /dev/null
@@ -0,0 +1,5 @@
+export * from './AutocompleteMenu';
+export * from './autocompleteQuery';
+export * from './RoomMentionAutocomplete';
+export * from './UserMentionAutocomplete';
+export * from './EmoticonAutocomplete';
diff --git a/src/app/components/editor/common.ts b/src/app/components/editor/common.ts
new file mode 100644 (file)
index 0000000..c9cf086
--- /dev/null
@@ -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);
+};
diff --git a/src/app/components/editor/index.ts b/src/app/components/editor/index.ts
new file mode 100644 (file)
index 0000000..76ccf56
--- /dev/null
@@ -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';
diff --git a/src/app/components/editor/keyboard.ts b/src/app/components/editor/keyboard.ts
new file mode 100644 (file)
index 0000000..52217dd
--- /dev/null
@@ -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]);
+      }
+    });
+};
diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts
new file mode 100644 (file)
index 0000000..091dab7
--- /dev/null
@@ -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);
+};
diff --git a/src/app/components/editor/slate.d.ts b/src/app/components/editor/slate.d.ts
new file mode 100644 (file)
index 0000000..a321904
--- /dev/null
@@ -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;
+  }
+}
diff --git a/src/app/components/emoji-board/EmojiBoard.css.tsx b/src/app/components/emoji-board/EmojiBoard.css.tsx
new file mode 100644 (file)
index 0000000..0fefc5b
--- /dev/null
@@ -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),
+  },
+]);
diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx
new file mode 100644 (file)
index 0000000..c5f5038
--- /dev/null
@@ -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>
+  );
+}
diff --git a/src/app/components/emoji-board/index.ts b/src/app/components/emoji-board/index.ts
new file mode 100644 (file)
index 0000000..430cec0
--- /dev/null
@@ -0,0 +1 @@
+export * from './EmojiBoard';
diff --git a/src/app/components/emoji-board/useEmojiGroupIcons.ts b/src/app/components/emoji-board/useEmojiGroupIcons.ts
new file mode 100644 (file)
index 0000000..bef7374
--- /dev/null
@@ -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,
+    }),
+    []
+  );
diff --git a/src/app/components/emoji-board/useEmojiGroupLabels.ts b/src/app/components/emoji-board/useEmojiGroupLabels.ts
new file mode 100644 (file)
index 0000000..a1e5cf2
--- /dev/null
@@ -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',
+    }),
+    []
+  );
diff --git a/src/app/components/sidebar/Sidebar.css.ts b/src/app/components/sidebar/Sidebar.css.ts
new file mode 100644 (file)
index 0000000..e62ed6f
--- /dev/null
@@ -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}`,
+});
diff --git a/src/app/components/sidebar/Sidebar.tsx b/src/app/components/sidebar/Sidebar.tsx
new file mode 100644 (file)
index 0000000..7caf1b2
--- /dev/null
@@ -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} />
+));
diff --git a/src/app/components/sidebar/SidebarAvatar.tsx b/src/app/components/sidebar/SidebarAvatar.tsx
new file mode 100644 (file)
index 0000000..86665f7
--- /dev/null
@@ -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>
+  )
+);
diff --git a/src/app/components/sidebar/SidebarContent.tsx b/src/app/components/sidebar/SidebarContent.tsx
new file mode 100644 (file)
index 0000000..4f40587
--- /dev/null
@@ -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>
+    </>
+  );
+}
diff --git a/src/app/components/sidebar/SidebarStack.tsx b/src/app/components/sidebar/SidebarStack.tsx
new file mode 100644 (file)
index 0000000..c0e976c
--- /dev/null
@@ -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} />
+  )
+);
diff --git a/src/app/components/sidebar/SidebarStackSeparator.tsx b/src/app/components/sidebar/SidebarStackSeparator.tsx
new file mode 100644 (file)
index 0000000..110341c
--- /dev/null
@@ -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"
+    />
+  );
+}
diff --git a/src/app/components/sidebar/index.ts b/src/app/components/sidebar/index.ts
new file mode 100644 (file)
index 0000000..f744628
--- /dev/null
@@ -0,0 +1,5 @@
+export * from './Sidebar';
+export * from './SidebarAvatar';
+export * from './SidebarContent';
+export * from './SidebarStack';
+export * from './SidebarStackSeparator';
diff --git a/src/app/components/upload-board/UploadBoard.css.ts b/src/app/components/upload-board/UploadBoard.css.ts
new file mode 100644 (file)
index 0000000..80c1b26
--- /dev/null
@@ -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,
+});
diff --git a/src/app/components/upload-board/UploadBoard.tsx b/src/app/components/upload-board/UploadBoard.tsx
new file mode 100644 (file)
index 0000000..42f3899
--- /dev/null
@@ -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>
+));
diff --git a/src/app/components/upload-board/index.ts b/src/app/components/upload-board/index.ts
new file mode 100644 (file)
index 0000000..24ae780
--- /dev/null
@@ -0,0 +1 @@
+export * from './UploadBoard';
diff --git a/src/app/components/upload-card/UploadCard.css.ts b/src/app/components/upload-card/UploadCard.css.ts
new file mode 100644 (file)
index 0000000..20ac00e
--- /dev/null
@@ -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,
+});
diff --git a/src/app/components/upload-card/UploadCard.tsx b/src/app/components/upload-card/UploadCard.tsx
new file mode 100644 (file)
index 0000000..ae7b71b
--- /dev/null
@@ -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>
+  );
+}
diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx
new file mode 100644 (file)
index 0000000..949f5d6
--- /dev/null
@@ -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>
+  );
+}
diff --git a/src/app/components/upload-card/index.ts b/src/app/components/upload-card/index.ts
new file mode 100644 (file)
index 0000000..3e7a5e3
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './UploadCard';
+export * from './UploadCardRenderer';
diff --git a/src/app/hooks/useAlive.ts b/src/app/hooks/useAlive.ts
new file mode 100644 (file)
index 0000000..0bfbce8
--- /dev/null
@@ -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;
+};
diff --git a/src/app/hooks/useAsyncCallback.ts b/src/app/hooks/useAsyncCallback.ts
new file mode 100644 (file)
index 0000000..18b63ec
--- /dev/null
@@ -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];
+};
diff --git a/src/app/hooks/useAsyncSearch.ts b/src/app/hooks/useAsyncSearch.ts
new file mode 100644 (file)
index 0000000..b083a19
--- /dev/null
@@ -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];
+};
diff --git a/src/app/hooks/useDebounce.ts b/src/app/hooks/useDebounce.ts
new file mode 100644 (file)
index 0000000..5f33976
--- /dev/null
@@ -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;
+}
diff --git a/src/app/hooks/useFileDrop.ts b/src/app/hooks/useFileDrop.ts
new file mode 100644 (file)
index 0000000..bead203
--- /dev/null
@@ -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;
+};
diff --git a/src/app/hooks/useFilePasteHandler.ts b/src/app/hooks/useFilePasteHandler.ts
new file mode 100644 (file)
index 0000000..0f63b75
--- /dev/null
@@ -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]
+  );
diff --git a/src/app/hooks/useFilePicker.ts b/src/app/hooks/useFilePicker.ts
new file mode 100644 (file)
index 0000000..e772d66
--- /dev/null
@@ -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]
+  );
diff --git a/src/app/hooks/useForceUpdate.ts b/src/app/hooks/useForceUpdate.ts
new file mode 100644 (file)
index 0000000..0691aa9
--- /dev/null
@@ -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];
+};
diff --git a/src/app/hooks/useImagePacks.ts b/src/app/hooks/useImagePacks.ts
new file mode 100644 (file)
index 0000000..ab988e3
--- /dev/null
@@ -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;
+};
diff --git a/src/app/hooks/useKeyDown.ts b/src/app/hooks/useKeyDown.ts
new file mode 100644 (file)
index 0000000..c754d88
--- /dev/null
@@ -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]);
+};
diff --git a/src/app/hooks/useMatrixClient.ts b/src/app/hooks/useMatrixClient.ts
new file mode 100644 (file)
index 0000000..5ff6d90
--- /dev/null
@@ -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;
+}
diff --git a/src/app/hooks/usePowerLevels.ts b/src/app/hooks/usePowerLevels.ts
new file mode 100644 (file)
index 0000000..8f999d4
--- /dev/null
@@ -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,
+  };
+}
diff --git a/src/app/hooks/useRecentEmoji.ts b/src/app/hooks/useRecentEmoji.ts
new file mode 100644 (file)
index 0000000..926075f
--- /dev/null
@@ -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;
+};
diff --git a/src/app/hooks/useResizeObserver.ts b/src/app/hooks/useResizeObserver.ts
new file mode 100644 (file)
index 0000000..69ec65d
--- /dev/null
@@ -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;
+};
diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts
new file mode 100644 (file)
index 0000000..544d97a
--- /dev/null
@@ -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;
+};
diff --git a/src/app/hooks/useStateEvent.ts b/src/app/hooks/useStateEvent.ts
new file mode 100644 (file)
index 0000000..7bc66aa
--- /dev/null
@@ -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]
+  );
+};
diff --git a/src/app/hooks/useStateEventCallback.ts b/src/app/hooks/useStateEventCallback.ts
new file mode 100644 (file)
index 0000000..85be3ae
--- /dev/null
@@ -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]);
+};
diff --git a/src/app/hooks/useStateEvents.ts b/src/app/hooks/useStateEvents.ts
new file mode 100644 (file)
index 0000000..dd08569
--- /dev/null
@@ -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]
+  );
+};
diff --git a/src/app/hooks/useThrottle.ts b/src/app/hooks/useThrottle.ts
new file mode 100644 (file)
index 0000000..12b249f
--- /dev/null
@@ -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;
+}
diff --git a/src/app/hooks/useTypingStatusUpdater.ts b/src/app/hooks/useTypingStatusUpdater.ts
new file mode 100644 (file)
index 0000000..af76eae
--- /dev/null
@@ -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;
+};
index 65296535373e7bb732475cfb72620cb49119125f..949dac76a66e569ce91bf53168c2baa9c1178e9e 100644 (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>
+    )
   );
 }
 
diff --git a/src/app/organisms/drag-drop/DragDrop.jsx b/src/app/organisms/drag-drop/DragDrop.jsx
deleted file mode 100644 (file)
index e92f8a7..0000000
+++ /dev/null
@@ -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;
diff --git a/src/app/organisms/drag-drop/DragDrop.scss b/src/app/organisms/drag-drop/DragDrop.scss
deleted file mode 100644 (file)
index 2e5b4f5..0000000
+++ /dev/null
@@ -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);
-}
index 6ff66288a52deb5a5ba7813cbe717a261a8bfe42..401947a41ebfc9f2eea621695ecf7cacc42d8c1f 100644 (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);
   }
-}
\ No newline at end of file
+}
diff --git a/src/app/organisms/navigation/Sidebar1.tsx b/src/app/organisms/navigation/Sidebar1.tsx
new file mode 100644 (file)
index 0000000..d9ee466
--- /dev/null
@@ -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>
+  );
+}
index 447686af820990a76a566ad06640c184b87d5bfb..9d861c96296ea677eb05b91ed747716164107489 100644 (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>
diff --git a/src/app/organisms/room/RoomInput.tsx b/src/app/organisms/room/RoomInput.tsx
new file mode 100644 (file)
index 0000000..17830ad
--- /dev/null
@@ -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>
+    );
+  }
+);
diff --git a/src/app/organisms/room/RoomInputPlaceholder.css.ts b/src/app/organisms/room/RoomInputPlaceholder.css.ts
new file mode 100644 (file)
index 0000000..d0873da
--- /dev/null
@@ -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,
+});
diff --git a/src/app/organisms/room/RoomInputPlaceholder.tsx b/src/app/organisms/room/RoomInputPlaceholder.tsx
new file mode 100644 (file)
index 0000000..77c7ccf
--- /dev/null
@@ -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} />
+  )
+);
diff --git a/src/app/organisms/room/RoomTombstone.css.ts b/src/app/organisms/room/RoomTombstone.css.ts
new file mode 100644 (file)
index 0000000..c4c0461
--- /dev/null
@@ -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,
+});
diff --git a/src/app/organisms/room/RoomTombstone.tsx b/src/app/organisms/room/RoomTombstone.tsx
new file mode 100644 (file)
index 0000000..39f0e63
--- /dev/null
@@ -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>
+  );
+}
index b94c35c48827988456774c44d646043d4b2049b3..591fcceae6c3d909da1bd1e75ea635bc930deee9 100644 (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,
 };
index 4f06bf262dc9059589b8a90e7c5b19a91c8b19a7..c70c2b0924d901691e23680a7315fce99d324220 100644 (file)
     @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);
   }
-}
\ No newline at end of file
+  &__editor {
+    padding: 0 var(--sp-normal);
+  }
+}
index 0a9256cebca1070e520b865e7cf7be79bb698bd4..fe598bf62b2e0fe3451ba81e25da18c1ca26d02a 100644 (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;
diff --git a/src/app/organisms/room/msgContent.ts b/src/app/organisms/room/msgContent.ts
new file mode 100644 (file)
index 0000000..2b0c50e
--- /dev/null
@@ -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;
+};
index af7cc29b00a0a201e157ae39f49994cb81639afe..2828d7be80b92489cc385438c607875deec1e373 100644 (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;
diff --git a/src/app/plugins/custom-emoji.ts b/src/app/plugins/custom-emoji.ts
new file mode 100644 (file)
index 0000000..daceef4
--- /dev/null
@@ -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))
+  );
+}
diff --git a/src/app/plugins/emoji.ts b/src/app/plugins/emoji.ts
new file mode 100644 (file)
index 0000000..36bd044
--- /dev/null
@@ -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);
+  }
+});
diff --git a/src/app/plugins/recent-emoji.ts b/src/app/plugins/recent-emoji.ts
new file mode 100644 (file)
index 0000000..3634538
--- /dev/null
@@ -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),
+  });
+}
diff --git a/src/app/state/hooks/inviteList.ts b/src/app/state/hooks/inviteList.ts
new file mode 100644 (file)
index 0000000..f8b7e05
--- /dev/null
@@ -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));
+};
diff --git a/src/app/state/hooks/roomList.ts b/src/app/state/hooks/roomList.ts
new file mode 100644 (file)
index 0000000..5d0890b
--- /dev/null
@@ -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));
+};
diff --git a/src/app/state/hooks/settings.ts b/src/app/state/hooks/settings.ts
new file mode 100644 (file)
index 0000000..3f4dab6
--- /dev/null
@@ -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];
+};
diff --git a/src/app/state/hooks/useBindAtoms.ts b/src/app/state/hooks/useBindAtoms.ts
new file mode 100644 (file)
index 0000000..6dc2a3d
--- /dev/null
@@ -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);
+};
diff --git a/src/app/state/inviteList.ts b/src/app/state/inviteList.ts
new file mode 100644 (file)
index 0000000..463fd35
--- /dev/null
@@ -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], [])
+  );
+};
diff --git a/src/app/state/list.ts b/src/app/state/list.ts
new file mode 100644 (file)
index 0000000..4f5a619
--- /dev/null
@@ -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>>;
diff --git a/src/app/state/mDirectList.ts b/src/app/state/mDirectList.ts
new file mode 100644 (file)
index 0000000..96e2f0d
--- /dev/null
@@ -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]);
+};
diff --git a/src/app/state/mutedRoomList.ts b/src/app/state/mutedRoomList.ts
new file mode 100644 (file)
index 0000000..d456f85
--- /dev/null
@@ -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]);
+};
diff --git a/src/app/state/roomInputDrafts.ts b/src/app/state/roomInputDrafts.ts
new file mode 100644 (file)
index 0000000..2708b8b
--- /dev/null
@@ -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()
+);
diff --git a/src/app/state/roomList.ts b/src/app/state/roomList.ts
new file mode 100644 (file)
index 0000000..7a793d8
--- /dev/null
@@ -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], [])
+  );
+};
diff --git a/src/app/state/roomToParents.ts b/src/app/state/roomToParents.ts
new file mode 100644 (file)
index 0000000..374ddd5
--- /dev/null
@@ -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]);
+};
diff --git a/src/app/state/roomToUnread.ts b/src/app/state/roomToUnread.ts
new file mode 100644 (file)
index 0000000..0c7b6bd
--- /dev/null
@@ -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]);
+};
diff --git a/src/app/state/selectedRoom.ts b/src/app/state/selectedRoom.ts
new file mode 100644 (file)
index 0000000..1ef04de
--- /dev/null
@@ -0,0 +1,3 @@
+import { atom } from 'jotai';
+
+export const selectedRoomAtom = atom<string | undefined>(undefined);
diff --git a/src/app/state/selectedTab.ts b/src/app/state/selectedTab.ts
new file mode 100644 (file)
index 0000000..e680ae6
--- /dev/null
@@ -0,0 +1,8 @@
+import { atom } from 'jotai';
+
+export enum SidebarTab {
+  Home = 'Home',
+  People = 'People',
+}
+
+export const selectedTabAtom = atom<SidebarTab | string>(SidebarTab.Home);
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
new file mode 100644 (file)
index 0000000..7739c58
--- /dev/null
@@ -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);
+  }
+);
diff --git a/src/app/state/tabToRoom.ts b/src/app/state/tabToRoom.ts
new file mode 100644 (file)
index 0000000..2f4ee92
--- /dev/null
@@ -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:
+};
diff --git a/src/app/state/upload.ts b/src/app/state/upload.ts
new file mode 100644 (file)
index 0000000..d92b93d
--- /dev/null
@@ -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>;
diff --git a/src/app/state/utils.ts b/src/app/state/utils.ts
new file mode 100644 (file)
index 0000000..355c941
--- /dev/null
@@ -0,0 +1,64 @@
+import { useSetAtom, WritableAtom } from 'jotai';
+import { ClientEvent, MatrixClient, Room, RoomEvent } from 'matrix-js-sdk';
+import { useEffect } from 'react';
+import { Membership } from '../../types/matrix/room';
+
+export type RoomsAction =
+  | {
+      type: 'INITIALIZE';
+      rooms: string[];
+    }
+  | {
+      type: 'PUT' | 'DELETE';
+      roomId: string;
+    };
+
+export const useBindRoomsWithMembershipsAtom = (
+  mx: MatrixClient,
+  roomsAtom: WritableAtom<string[], RoomsAction>,
+  memberships: Membership[]
+) => {
+  const setRoomsAtom = useSetAtom(roomsAtom);
+
+  useEffect(() => {
+    const satisfyMembership = (room: Room): boolean =>
+      !!memberships.find((membership) => membership === room.getMyMembership());
+    setRoomsAtom({
+      type: 'INITIALIZE',
+      rooms: mx
+        .getRooms()
+        .filter(satisfyMembership)
+        .map((room) => room.roomId),
+    });
+
+    const handleAddRoom = (room: Room) => {
+      if (satisfyMembership(room)) {
+        setRoomsAtom({ type: 'PUT', roomId: room.roomId });
+      }
+    };
+
+    const handleMembershipChange = (room: Room) => {
+      if (!satisfyMembership(room)) {
+        setRoomsAtom({ type: 'DELETE', roomId: room.roomId });
+      }
+    };
+
+    const handleDeleteRoom = (roomId: string) => {
+      setRoomsAtom({ type: 'DELETE', roomId });
+    };
+
+    mx.on(ClientEvent.Room, handleAddRoom);
+    mx.on(RoomEvent.MyMembership, handleMembershipChange);
+    mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
+    return () => {
+      mx.removeListener(ClientEvent.Room, handleAddRoom);
+      mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
+      mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
+    };
+  }, [mx, memberships, setRoomsAtom]);
+};
+
+export const compareRoomsEqual = (a: string[], b: string[]) => {
+  if (a.length !== b.length) return false;
+  return a.every((roomId, roomIdIndex) => roomId === b[roomIdIndex]);
+};
index d83845b8b1e503ae63918108aa8cf5a7328a7407..cc9d88fa043d3edd6c4ef1e6717873f0f07ff10c 100644 (file)
@@ -18,14 +18,13 @@ import EmojiBoardOpener from '../../organisms/emoji-board/EmojiBoardOpener';
 import initMatrix from '../../../client/initMatrix';
 import navigation from '../../../client/state/navigation';
 import cons from '../../../client/state/cons';
-import DragDrop from '../../organisms/drag-drop/DragDrop';
 
 import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
+import { MatrixClientProvider } from '../../hooks/useMatrixClient';
 
 function Client() {
   const [isLoading, changeLoading] = useState(true);
   const [loadingMsg, setLoadingMsg] = useState('Heating up');
-  const [dragCounter, setDragCounter] = useState(0);
   const classNameHidden = 'client__item-hidden';
 
   const navWrapperRef = useRef(null);
@@ -44,19 +43,17 @@ function Client() {
     navigation.on(cons.events.navigation.ROOM_SELECTED, onRoomSelected);
     navigation.on(cons.events.navigation.NAVIGATION_OPENED, onNavigationSelected);
 
-    return (() => {
+    return () => {
       navigation.removeListener(cons.events.navigation.ROOM_SELECTED, onRoomSelected);
       navigation.removeListener(cons.events.navigation.NAVIGATION_OPENED, onNavigationSelected);
-    });
+    };
   }, []);
 
   useEffect(() => {
+    changeLoading(true);
     let counter = 0;
     const iId = setInterval(() => {
-      const msgList = [
-        'Almost there...',
-        'Looks like you have a lot of stuff to heat up!',
-      ];
+      const msgList = ['Almost there...', 'Looks like you have a lot of stuff to heat up!'];
       if (counter === msgList.length - 1) {
         setLoadingMsg(msgList[msgList.length - 1]);
         clearInterval(iId);
@@ -80,103 +77,48 @@ function Client() {
         <div className="loading__menu">
           <ContextMenu
             placement="bottom"
-            content={(
+            content={
               <>
                 <MenuItem onClick={() => initMatrix.clearCacheAndReload()}>
                   Clear cache & reload
                 </MenuItem>
                 <MenuItem onClick={() => initMatrix.logout()}>Logout</MenuItem>
               </>
+            }
+            render={(toggle) => (
+              <IconButton size="extra-small" onClick={toggle} src={VerticalMenuIC} />
             )}
-            render={(toggle) => <IconButton size="extra-small" onClick={toggle} src={VerticalMenuIC} />}
           />
         </div>
         <Spinner />
-        <Text className="loading__message" variant="b2">{loadingMsg}</Text>
+        <Text className="loading__message" variant="b2">
+          {loadingMsg}
+        </Text>
 
         <div className="loading__appname">
-          <Text variant="h2" weight="medium">Cinny</Text>
+          <Text variant="h2" weight="medium">
+            Cinny
+          </Text>
         </div>
       </div>
     );
   }
 
-  function dragContainsFiles(e) {
-    if (!e.dataTransfer.types) return false;
-
-    for (let i = 0; i < e.dataTransfer.types.length; i += 1) {
-      if (e.dataTransfer.types[i] === 'Files') return true;
-    }
-    return false;
-  }
-
-  function modalOpen() {
-    return navigation.isRawModalVisible && dragCounter <= 0;
-  }
-
-  function handleDragOver(e) {
-    if (!dragContainsFiles(e)) return;
-
-    e.preventDefault();
-
-    if (!navigation.selectedRoomId || modalOpen()) {
-      e.dataTransfer.dropEffect = 'none';
-    }
-  }
-
-  function handleDragEnter(e) {
-    e.preventDefault();
-
-    if (navigation.selectedRoomId && !modalOpen() && dragContainsFiles(e)) {
-      setDragCounter(dragCounter + 1);
-    }
-  }
-
-  function handleDragLeave(e) {
-    e.preventDefault();
-
-    if (navigation.selectedRoomId && !modalOpen() && dragContainsFiles(e)) {
-      setDragCounter(dragCounter - 1);
-    }
-  }
-
-  function handleDrop(e) {
-    e.preventDefault();
-
-    setDragCounter(0);
-
-    if (modalOpen()) return;
-
-    const roomId = navigation.selectedRoomId;
-    if (!roomId) return;
-
-    const { files } = e.dataTransfer;
-    if (!files?.length) return;
-    const file = files[0];
-    initMatrix.roomsInput.setAttachment(roomId, file);
-    initMatrix.roomsInput.emit(cons.events.roomsInput.ATTACHMENT_SET, file);
-  }
-
   return (
-    <div
-      className="client-container"
-      onDragOver={handleDragOver}
-      onDragEnter={handleDragEnter}
-      onDragLeave={handleDragLeave}
-      onDrop={handleDrop}
-    >
-      <div className="navigation__wrapper" ref={navWrapperRef}>
-        <Navigation />
-      </div>
-      <div className={`room__wrapper ${classNameHidden}`} ref={roomWrapperRef}>
-        <Room />
+    <MatrixClientProvider value={initMatrix.matrixClient}>
+      <div className="client-container">
+        <div className="navigation__wrapper" ref={navWrapperRef}>
+          <Navigation />
+        </div>
+        <div className={`room__wrapper ${classNameHidden}`} ref={roomWrapperRef}>
+          <Room />
+        </div>
+        <Windows />
+        <Dialogs />
+        <EmojiBoardOpener />
+        <ReusableContextMenu />
       </div>
-      <Windows />
-      <Dialogs />
-      <EmojiBoardOpener />
-      <ReusableContextMenu />
-      <DragDrop isOpen={dragCounter !== 0} />
-    </div>
+    </MatrixClientProvider>
   );
 }
 
diff --git a/src/app/utils/AsyncSearch.ts b/src/app/utils/AsyncSearch.ts
new file mode 100644 (file)
index 0000000..4baacf0
--- /dev/null
@@ -0,0 +1,102 @@
+export type NormalizeOption = {
+  caseSensitive?: boolean;
+  normalizeUnicode?: boolean;
+  ignoreWhitespace?: boolean;
+};
+
+export type MatchQueryOption = {
+  contain?: boolean;
+};
+
+export type AsyncSearchOption = {
+  limit?: number;
+};
+
+export type MatchHandler<TSearchItem extends object | string | number> = (
+  item: TSearchItem,
+  query: string
+) => boolean;
+export type ResultHandler<TSearchItem extends object | string | number> = (
+  results: TSearchItem[],
+  query: string
+) => void;
+
+export type AsyncSearchHandler = (query: string) => void;
+export type TerminateAsyncSearch = () => void;
+
+export const normalize = (str: string, options?: NormalizeOption) => {
+  let nStr = str.normalize(options?.normalizeUnicode ?? true ? 'NFKC' : 'NFC');
+  if (!options?.caseSensitive) nStr = nStr.toLocaleLowerCase();
+  if (options?.ignoreWhitespace ?? true) nStr = nStr.replace(/\s/g, '');
+  return nStr;
+};
+
+export const matchQuery = (item: string, query: string, options?: MatchQueryOption): boolean => {
+  if (options?.contain) return item.indexOf(query) !== -1;
+  return item.startsWith(query);
+};
+
+export const AsyncSearch = <TSearchItem extends object | string | number>(
+  list: TSearchItem[],
+  match: MatchHandler<TSearchItem>,
+  onResult: ResultHandler<TSearchItem>,
+  options?: AsyncSearchOption
+): [AsyncSearchHandler, TerminateAsyncSearch] => {
+  let resultList: TSearchItem[] = [];
+
+  let searchIndex = 0;
+  let sessionStartTimestamp = 0;
+  let sessionScheduleId: number | undefined;
+
+  const terminateSearch: TerminateAsyncSearch = () => {
+    resultList = [];
+    searchIndex = 0;
+    sessionStartTimestamp = 0;
+    if (sessionScheduleId) clearTimeout(sessionScheduleId);
+    sessionScheduleId = undefined;
+  };
+
+  const find = (query: string, sessionTimestamp: number) => {
+    const findingCount = resultList.length;
+    sessionScheduleId = undefined;
+    // return if find session got reset
+    if (sessionTimestamp !== sessionStartTimestamp) return;
+
+    sessionStartTimestamp = window.performance.now();
+    for (; searchIndex < list.length; searchIndex += 1) {
+      if (match(list[searchIndex], query)) {
+        resultList.push(list[searchIndex]);
+        if (typeof options?.limit === 'number' && resultList.length >= options.limit) {
+          break;
+        }
+      }
+
+      const matchFinishTime = window.performance.now();
+      if (matchFinishTime - sessionStartTimestamp > 8) {
+        const currentFindingCount = resultList.length;
+        const thisSessionTimestamp = sessionStartTimestamp;
+        if (findingCount !== currentFindingCount) onResult(resultList, query);
+
+        searchIndex += 1;
+        sessionScheduleId = window.setTimeout(() => find(query, thisSessionTimestamp), 1);
+        return;
+      }
+    }
+
+    if (findingCount !== resultList.length || findingCount === 0) {
+      onResult(resultList, query);
+    }
+    terminateSearch();
+  };
+
+  const search: AsyncSearchHandler = (query: string) => {
+    terminateSearch();
+    if (query === '') {
+      onResult(resultList, query);
+      return;
+    }
+    find(query, sessionStartTimestamp);
+  };
+
+  return [search, terminateSearch];
+};
diff --git a/src/app/utils/blurHash.ts b/src/app/utils/blurHash.ts
new file mode 100644 (file)
index 0000000..0de5a92
--- /dev/null
@@ -0,0 +1,19 @@
+import { encode } from 'blurhash';
+
+export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash';
+
+export const encodeBlurHash = (
+  img: HTMLImageElement | HTMLVideoElement,
+  width?: number,
+  height?: number
+): string | undefined => {
+  const canvas = document.createElement('canvas');
+  canvas.width = width || img.width;
+  canvas.height = height || img.height;
+  const context = canvas.getContext('2d');
+
+  if (!context) return undefined;
+  context.drawImage(img, 0, 0, canvas.width, canvas.height);
+  const data = context.getImageData(0, 0, canvas.width, canvas.height);
+  return encode(data.data, data.width, data.height, 4, 4);
+};
diff --git a/src/app/utils/common.ts b/src/app/utils/common.ts
new file mode 100644 (file)
index 0000000..d3804ae
--- /dev/null
@@ -0,0 +1,32 @@
+import { IconName, IconSrc } from 'folds';
+
+export const bytesToSize = (bytes: number): string => {
+  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+  if (bytes === 0) return '0KB';
+
+  let sizeIndex = Math.floor(Math.log(bytes) / Math.log(1000));
+
+  if (sizeIndex === 0) sizeIndex = 1;
+
+  return `${(bytes / 1000 ** sizeIndex).toFixed(1)} ${sizes[sizeIndex]}`;
+};
+
+export const getFileTypeIcon = (icons: Record<IconName, IconSrc>, fileType: string): IconSrc => {
+  const type = fileType.toLowerCase();
+  if (type.startsWith('audio')) {
+    return icons.Play;
+  }
+  if (type.startsWith('video')) {
+    return icons.Vlc;
+  }
+  if (type.startsWith('image')) {
+    return icons.Photo;
+  }
+  return icons.File;
+};
+
+export const fulfilledPromiseSettledResult = <T>(prs: PromiseSettledResult<T>[]): T[] =>
+  prs.reduce<T[]>((values, pr) => {
+    if (pr.status === 'fulfilled') values.push(pr.value);
+    return values;
+  }, []);
diff --git a/src/app/utils/disposable.ts b/src/app/utils/disposable.ts
new file mode 100644 (file)
index 0000000..7840fe4
--- /dev/null
@@ -0,0 +1,8 @@
+export type DisposeCallback<Q extends unknown[] = [], R = void> = (...args: Q) => R;
+export type DisposableContext<P extends unknown[] = [], Q extends unknown[] = [], R = void> = (
+  ...args: P
+) => DisposeCallback<Q, R>;
+
+export const disposable = <P extends unknown[], Q extends unknown[] = [], R = void>(
+  context: DisposableContext<P, Q, R>
+) => context;
diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts
new file mode 100644 (file)
index 0000000..d717adf
--- /dev/null
@@ -0,0 +1,133 @@
+export const targetFromEvent = (evt: Event, selector: string): Element | undefined => {
+  const targets = evt.composedPath() as Element[];
+  return targets.find((target) => target.matches?.(selector));
+};
+
+export const editableActiveElement = (): boolean =>
+  !!document.activeElement &&
+  /^(input)|(textarea)$/.test(document.activeElement.nodeName.toLowerCase());
+
+export const inVisibleScrollArea = (
+  scrollElement: HTMLElement,
+  childElement: HTMLElement
+): boolean => {
+  const scrollTop = scrollElement.offsetTop + scrollElement.scrollTop;
+  const scrollBottom = scrollTop + scrollElement.offsetHeight;
+
+  const childTop = childElement.offsetTop;
+  const childBottom = childTop + childElement.clientHeight;
+
+  if (childTop >= scrollTop && childTop < scrollBottom) return true;
+  if (childTop < scrollTop && childBottom > scrollTop) return true;
+  return false;
+};
+
+export type FilesOrFile<T extends boolean | undefined = undefined> = T extends true ? File[] : File;
+
+export const selectFile = <M extends boolean | undefined = undefined>(
+  accept: string,
+  multiple?: M
+): Promise<FilesOrFile<M> | undefined> =>
+  new Promise((resolve) => {
+    const input = document.createElement('input');
+    input.type = 'file';
+    if (accept) input.accept = accept;
+    if (multiple) input.multiple = true;
+
+    const changeHandler = () => {
+      const fileList = input.files;
+      if (!fileList) {
+        resolve(undefined);
+      } else {
+        const files: File[] = [...fileList].filter((file) => file);
+        resolve((multiple ? files : files[0]) as FilesOrFile<M>);
+      }
+      input.removeEventListener('change', changeHandler);
+    };
+
+    input.addEventListener('change', changeHandler);
+    input.click();
+  });
+
+export const getDataTransferFiles = (dataTransfer: DataTransfer): File[] | undefined => {
+  const fileList = dataTransfer.files;
+  const files = [...fileList].filter((file) => file);
+  if (files.length === 0) return undefined;
+  return files;
+};
+
+export const getImageUrlBlob = async (url: string) => {
+  const res = await fetch(url);
+  const blob = await res.blob();
+  return blob;
+};
+
+export const getImageFileUrl = (fileOrBlob: File | Blob) => URL.createObjectURL(fileOrBlob);
+
+export const getVideoFileUrl = (fileOrBlob: File | Blob) => URL.createObjectURL(fileOrBlob);
+
+export const loadImageElement = (url: string): Promise<HTMLImageElement> =>
+  new Promise((resolve, reject) => {
+    const img = document.createElement('img');
+    img.onload = () => resolve(img);
+    img.onerror = (err) => reject(err);
+    img.src = url;
+  });
+
+export const loadVideoElement = (url: string): Promise<HTMLVideoElement> =>
+  new Promise((resolve, reject) => {
+    const video = document.createElement('video');
+    video.preload = 'metadata';
+    video.playsInline = true;
+    video.muted = true;
+
+    video.onloadeddata = () => {
+      resolve(video);
+      video.pause();
+    };
+    video.onerror = (e) => {
+      reject(e);
+    };
+
+    video.src = url;
+    video.load();
+    video.play();
+  });
+
+export const getThumbnailDimensions = (width: number, height: number): [number, number] => {
+  const MAX_WIDTH = 400;
+  const MAX_HEIGHT = 300;
+  let targetWidth = width;
+  let targetHeight = height;
+  if (targetHeight > MAX_HEIGHT) {
+    targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
+    targetHeight = MAX_HEIGHT;
+  }
+  if (targetWidth > MAX_WIDTH) {
+    targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
+    targetWidth = MAX_WIDTH;
+  }
+  return [targetWidth, targetHeight];
+};
+
+export const getThumbnail = (
+  img: HTMLImageElement | SVGImageElement | HTMLVideoElement,
+  width: number,
+  height: number,
+  thumbnailMimeType?: string
+): Promise<Blob | undefined> =>
+  new Promise((resolve) => {
+    const canvas = document.createElement('canvas');
+    canvas.width = width;
+    canvas.height = height;
+    const context = canvas.getContext('2d');
+    if (!context) {
+      resolve(undefined);
+      return;
+    }
+    context.drawImage(img, 0, 0, width, height);
+
+    canvas.toBlob((thumbnail) => {
+      resolve(thumbnail ?? undefined);
+    }, thumbnailMimeType ?? 'image/jpeg');
+  });
diff --git a/src/app/utils/key-symbol.ts b/src/app/utils/key-symbol.ts
new file mode 100644 (file)
index 0000000..7e758fd
--- /dev/null
@@ -0,0 +1,6 @@
+export enum KeySymbol {
+  Command = '⌘',
+  Shift = '⇧',
+  Option = '⌥',
+  Control = '⌃',
+}
diff --git a/src/app/utils/keyboard.ts b/src/app/utils/keyboard.ts
new file mode 100644 (file)
index 0000000..56eeb9f
--- /dev/null
@@ -0,0 +1,25 @@
+import isHotkey from 'is-hotkey';
+import { KeyboardEventHandler } from 'react';
+
+export interface KeyboardEventLike {
+  key: string;
+  which: number;
+  altKey: boolean;
+  ctrlKey: boolean;
+  metaKey: boolean;
+  shiftKey: boolean;
+  preventDefault(): void;
+}
+
+export const onTabPress = (evt: KeyboardEventLike, callback: () => void) => {
+  if (isHotkey('tab', evt)) {
+    evt.preventDefault();
+    callback();
+  }
+};
+
+export const preventScrollWithArrowKey: KeyboardEventHandler = (evt) => {
+  if (isHotkey(['arrowup', 'arrowright', 'arrowdown', 'arrowleft'], evt)) {
+    evt.preventDefault();
+  }
+};
diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts
new file mode 100644 (file)
index 0000000..7f2fc0f
--- /dev/null
@@ -0,0 +1,118 @@
+import { EncryptedAttachmentInfo, encryptAttachment } from 'browser-encrypt-attachment';
+import { MatrixClient, MatrixError, UploadProgress, UploadResponse } from 'matrix-js-sdk';
+import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
+
+export const matchMxId = (id: string): RegExpMatchArray | null =>
+  id.match(/^([@!$+#])(\S+):(\S+)$/);
+
+export const validMxId = (id: string): boolean => !!matchMxId(id);
+
+export const getMxIdServer = (userId: string): string | undefined => matchMxId(userId)?.[3];
+
+export const getMxIdLocalPart = (userId: string): string | undefined => matchMxId(userId)?.[2];
+
+export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@');
+
+export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => {
+  const info: IImageInfo = {};
+  info.w = img.width;
+  info.h = img.height;
+  info.mimetype = fileOrBlob.type;
+  info.size = fileOrBlob.size;
+  return info;
+};
+
+export const getVideoInfo = (video: HTMLVideoElement, fileOrBlob: File | Blob): IVideoInfo => {
+  const info: IVideoInfo = {};
+  info.duration = Number.isNaN(video.duration) ? undefined : video.duration;
+  info.w = video.videoWidth;
+  info.h = video.videoHeight;
+  info.mimetype = fileOrBlob.type;
+  info.size = fileOrBlob.size;
+  return info;
+};
+
+export const getThumbnailContent = (thumbnailInfo: {
+  thumbnail: File | Blob;
+  encInfo: EncryptedAttachmentInfo | undefined;
+  mxc: string;
+  width: number;
+  height: number;
+}): IThumbnailContent => {
+  const { thumbnail, encInfo, mxc, width, height } = thumbnailInfo;
+
+  const content: IThumbnailContent = {
+    thumbnail_info: {
+      mimetype: thumbnail.type,
+      size: thumbnail.size,
+      w: width,
+      h: height,
+    },
+  };
+  if (encInfo) {
+    content.thumbnail_file = {
+      ...encInfo,
+      url: mxc,
+    };
+  } else {
+    content.thumbnail_url = mxc;
+  }
+  return content;
+};
+
+export const encryptFile = async (
+  file: File | Blob
+): Promise<{
+  encInfo: EncryptedAttachmentInfo;
+  file: File;
+  originalFile: File | Blob;
+}> => {
+  const dataBuffer = await file.arrayBuffer();
+  const encryptedAttachment = await encryptAttachment(dataBuffer);
+  const encFile = new File([encryptedAttachment.data], file.name, {
+    type: file.type,
+  });
+  return {
+    encInfo: encryptedAttachment.info,
+    file: encFile,
+    originalFile: file,
+  };
+};
+
+export type TUploadContent = File | Blob;
+
+export type ContentUploadOptions = {
+  name?: string;
+  fileType?: string;
+  hideFilename?: boolean;
+  onPromise?: (promise: Promise<UploadResponse>) => void;
+  onProgress?: (progress: UploadProgress) => void;
+  onSuccess: (mxc: string) => void;
+  onError: (error: MatrixError) => void;
+};
+
+export const uploadContent = async (
+  mx: MatrixClient,
+  file: TUploadContent,
+  options: ContentUploadOptions
+) => {
+  const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options;
+
+  const uploadPromise = mx.uploadContent(file, {
+    name,
+    type: fileType,
+    includeFilename: !hideFilename,
+    progressHandler: onProgress,
+  });
+  onPromise?.(uploadPromise);
+  try {
+    const data = await uploadPromise;
+    const mxc = data.content_uri;
+    if (mxc) onSuccess(mxc);
+    else onError(new MatrixError(data));
+  } catch (e: any) {
+    const error = typeof e?.message === 'string' ? e.message : undefined;
+    const errcode = typeof e?.name === 'string' ? e.message : undefined;
+    onError(new MatrixError({ error, errcode }));
+  }
+};
diff --git a/src/app/utils/mimeTypes.ts b/src/app/utils/mimeTypes.ts
new file mode 100644 (file)
index 0000000..c432bdc
--- /dev/null
@@ -0,0 +1,47 @@
+// https://github.com/matrix-org/matrix-react-sdk/blob/cd15e08fc285da42134817cce50de8011809cd53/src/utils/blobs.ts
+export const ALLOWED_BLOB_MIMETYPES = [
+  'image/jpeg',
+  'image/gif',
+  'image/png',
+  'image/apng',
+  'image/webp',
+  'image/avif',
+
+  'video/mp4',
+  'video/webm',
+  'video/ogg',
+  'video/quicktime',
+
+  'audio/mp4',
+  'audio/webm',
+  'audio/aac',
+  'audio/mpeg',
+  'audio/ogg',
+  'audio/wave',
+  'audio/wav',
+  'audio/x-wav',
+  'audio/x-pn-wav',
+  'audio/flac',
+  'audio/x-flac',
+];
+
+export const getBlobSafeMimeType = (mimeType: string) => {
+  if (typeof mimeType !== 'string') return 'application/octet-stream';
+  const [type] = mimeType.split(';');
+  if (!ALLOWED_BLOB_MIMETYPES.includes(type)) {
+    return 'application/octet-stream';
+  }
+  // Required for Chromium browsers
+  if (type === 'video/quicktime') {
+    return 'video/mp4';
+  }
+  return type;
+};
+
+export const safeFile = (f: File) => {
+  const safeType = getBlobSafeMimeType(f.type);
+  if (safeType !== f.type) {
+    return new File([f], f.name, { type: safeType });
+  }
+  return f;
+};
diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts
new file mode 100644 (file)
index 0000000..daf9560
--- /dev/null
@@ -0,0 +1,265 @@
+import { IconName, IconSrc } from 'folds';
+
+import {
+  IPushRule,
+  IPushRules,
+  JoinRule,
+  MatrixClient,
+  MatrixEvent,
+  NotificationCountType,
+  Room,
+} from 'matrix-js-sdk';
+import { AccountDataEvent } from '../../types/matrix/accountData';
+import {
+  NotificationType,
+  RoomToParents,
+  RoomType,
+  StateEvent,
+  UnreadInfo,
+} from '../../types/matrix/room';
+
+export const getStateEvent = (
+  room: Room,
+  eventType: StateEvent,
+  stateKey = ''
+): MatrixEvent | undefined => room.currentState.getStateEvents(eventType, stateKey) ?? undefined;
+
+export const getStateEvents = (room: Room, eventType: StateEvent): MatrixEvent[] =>
+  room.currentState.getStateEvents(eventType);
+
+export const getAccountData = (
+  mx: MatrixClient,
+  eventType: AccountDataEvent
+): MatrixEvent | undefined => mx.getAccountData(eventType);
+
+export const getMDirects = (mDirectEvent: MatrixEvent): Set<string> => {
+  const roomIds = new Set<string>();
+  const userIdToDirects = mDirectEvent?.getContent();
+
+  if (userIdToDirects === undefined) return roomIds;
+
+  Object.keys(userIdToDirects).forEach((userId) => {
+    const directs = userIdToDirects[userId];
+    if (Array.isArray(directs)) {
+      directs.forEach((id) => {
+        if (typeof id === 'string') roomIds.add(id);
+      });
+    }
+  });
+
+  return roomIds;
+};
+
+export const isDirectInvite = (room: Room | null, myUserId: string | null): boolean => {
+  if (!room || !myUserId) return false;
+  const me = room.getMember(myUserId);
+  const memberEvent = me?.events?.member;
+  const content = memberEvent?.getContent();
+  return content?.is_direct === true;
+};
+
+export const isSpace = (room: Room | null): boolean => {
+  if (!room) return false;
+  const event = getStateEvent(room, StateEvent.RoomCreate);
+  if (!event) return false;
+  return event.getContent().type === RoomType.Space;
+};
+
+export const isRoom = (room: Room | null): boolean => {
+  if (!room) return false;
+  const event = getStateEvent(room, StateEvent.RoomCreate);
+  if (!event) return false;
+  return event.getContent().type === undefined;
+};
+
+export const isUnsupportedRoom = (room: Room | null): boolean => {
+  if (!room) return false;
+  const event = getStateEvent(room, StateEvent.RoomCreate);
+  if (!event) return true; // Consider room unsupported if m.room.create event doesn't exist
+  return event.getContent().type !== undefined && event.getContent().type !== RoomType.Space;
+};
+
+export function isValidChild(mEvent: MatrixEvent): boolean {
+  return mEvent.getType() === StateEvent.SpaceChild && Object.keys(mEvent.getContent()).length > 0;
+}
+
+export const getAllParents = (roomToParents: RoomToParents, roomId: string): Set<string> => {
+  const allParents = new Set<string>();
+
+  const addAllParentIds = (rId: string) => {
+    if (allParents.has(rId)) return;
+    allParents.add(rId);
+
+    const parents = roomToParents.get(rId);
+    parents?.forEach((id) => addAllParentIds(id));
+  };
+  addAllParentIds(roomId);
+  allParents.delete(roomId);
+  return allParents;
+};
+
+export const getSpaceChildren = (room: Room) =>
+  getStateEvents(room, StateEvent.SpaceChild).reduce<string[]>((filtered, mEvent) => {
+    const stateKey = mEvent.getStateKey();
+    if (isValidChild(mEvent) && stateKey) {
+      filtered.push(stateKey);
+    }
+    return filtered;
+  }, []);
+
+export const mapParentWithChildren = (
+  roomToParents: RoomToParents,
+  roomId: string,
+  children: string[]
+) => {
+  const allParents = getAllParents(roomToParents, roomId);
+  children.forEach((childId) => {
+    if (allParents.has(childId)) {
+      // Space cycle detected.
+      return;
+    }
+    const parents = roomToParents.get(childId) ?? new Set<string>();
+    parents.add(roomId);
+    roomToParents.set(childId, parents);
+  });
+};
+
+export const getRoomToParents = (mx: MatrixClient): RoomToParents => {
+  const map: RoomToParents = new Map();
+  mx.getRooms()
+    .filter((room) => isSpace(room))
+    .forEach((room) => mapParentWithChildren(map, room.roomId, getSpaceChildren(room)));
+
+  return map;
+};
+
+export const isMutedRule = (rule: IPushRule) =>
+  rule.actions[0] === 'dont_notify' && rule.conditions?.[0]?.kind === 'event_match';
+
+export const findMutedRule = (overrideRules: IPushRule[], roomId: string) =>
+  overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule));
+
+export const getNotificationType = (mx: MatrixClient, roomId: string): NotificationType => {
+  let roomPushRule: IPushRule | undefined;
+  try {
+    roomPushRule = mx.getRoomPushRule('global', roomId);
+  } catch {
+    roomPushRule = undefined;
+  }
+
+  if (!roomPushRule) {
+    const overrideRules = mx.getAccountData('m.push_rules')?.getContent<IPushRules>()
+      ?.global?.override;
+    if (!overrideRules) return NotificationType.Default;
+
+    return findMutedRule(overrideRules, roomId) ? NotificationType.Mute : NotificationType.Default;
+  }
+
+  if (roomPushRule.actions[0] === 'notify') return NotificationType.AllMessages;
+  return NotificationType.MentionsAndKeywords;
+};
+
+export const isNotificationEvent = (mEvent: MatrixEvent) => {
+  const eType = mEvent.getType();
+  if (
+    ['m.room.create', 'm.room.message', 'm.room.encrypted', 'm.room.member', 'm.sticker'].find(
+      (type) => type === eType
+    )
+  )
+    return false;
+  if (eType === 'm.room.member') return false;
+
+  if (mEvent.isRedacted()) return false;
+  if (mEvent.getRelation()?.rel_type === 'm.replace') return false;
+
+  return true;
+};
+
+export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
+  const userId = mx.getUserId();
+  if (!userId) return false;
+  const readUpToId = room.getEventReadUpTo(userId);
+  const liveEvents = room.getLiveTimeline().getEvents();
+
+  if (liveEvents[liveEvents.length - 1]?.getSender() === userId) {
+    return false;
+  }
+
+  for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
+    const event = liveEvents[i];
+    if (!event) return false;
+    if (event.getId() === readUpToId) return false;
+    if (isNotificationEvent(event)) return true;
+  }
+  return true;
+};
+
+export const getUnreadInfo = (room: Room): UnreadInfo => {
+  const total = room.getUnreadNotificationCount(NotificationCountType.Total);
+  const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
+  return {
+    roomId: room.roomId,
+    highlight,
+    total: highlight > total ? highlight : total,
+  };
+};
+
+export const getUnreadInfos = (mx: MatrixClient): UnreadInfo[] => {
+  const unreadInfos = mx.getRooms().reduce<UnreadInfo[]>((unread, room) => {
+    if (room.isSpaceRoom()) return unread;
+    if (room.getMyMembership() !== 'join') return unread;
+    if (getNotificationType(mx, room.roomId) === NotificationType.Mute) return unread;
+
+    if (roomHaveUnread(mx, room)) {
+      unread.push(getUnreadInfo(room));
+    }
+
+    return unread;
+  }, []);
+  return unreadInfos;
+};
+
+export const joinRuleToIconSrc = (
+  icons: Record<IconName, IconSrc>,
+  joinRule: JoinRule,
+  space: boolean
+): IconSrc | undefined => {
+  if (joinRule === JoinRule.Restricted) {
+    return space ? icons.Space : icons.Hash;
+  }
+  if (joinRule === JoinRule.Knock) {
+    return space ? icons.SpaceLock : icons.HashLock;
+  }
+  if (joinRule === JoinRule.Invite) {
+    return space ? icons.SpaceLock : icons.HashLock;
+  }
+  if (joinRule === JoinRule.Public) {
+    return space ? icons.SpaceGlobe : icons.HashGlobe;
+  }
+  return undefined;
+};
+
+export const getRoomAvatarUrl = (mx: MatrixClient, room: Room): string | undefined => {
+  const url =
+    room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 32, 32, 'crop', undefined, false) ??
+    undefined;
+  if (url) return url;
+  return room.getAvatarUrl(mx.baseUrl, 32, 32, 'crop') ?? undefined;
+};
+
+export const parseReplyBody = (userId: string, body: string) =>
+  `> <${userId}> ${body.replace(/\n/g, '\n> ')}\n\n`;
+
+export const parseReplyFormattedBody = (
+  roomId: string,
+  userId: string,
+  eventId: string,
+  formattedBody: string
+): string => {
+  const replyToLink = `<a href="https://matrix.to/#/${encodeURIComponent(
+    roomId
+  )}/${encodeURIComponent(eventId)}">In reply to</a>`;
+  const userLink = `<a href="https://matrix.to/#/${encodeURIComponent(userId)}">${userId}</a>`;
+
+  return `<mx-reply><blockquote>${replyToLink}${userLink}<br />${formattedBody}</blockquote></mx-reply>`;
+};
diff --git a/src/app/utils/sanitize.ts b/src/app/utils/sanitize.ts
new file mode 100644 (file)
index 0000000..555089d
--- /dev/null
@@ -0,0 +1,10 @@
+export const sanitizeText = (body: string) => {
+  const tagsToReplace: Record<string, string> = {
+    '&': '&amp;',
+    '<': '&lt;',
+    '>': '&gt;',
+    '"': '&quot;',
+    "'": '&#39;',
+  };
+  return body.replace(/[&<>'"]/g, (tag) => tagsToReplace[tag] || tag);
+};
diff --git a/src/app/utils/user-agent.ts b/src/app/utils/user-agent.ts
new file mode 100644 (file)
index 0000000..61a903f
--- /dev/null
@@ -0,0 +1,5 @@
+import { UAParser } from 'ua-parser-js';
+
+export const ua = () => UAParser(window.navigator.userAgent);
+
+export const isMacOS = () => ua().os.name === 'Mac OS';
index 420f3154e6d3b2041ca9aeb124fd28ee901db411..9b8d1d82ee0982403ff4e4e69f08be87fb5c6ef7 100644 (file)
@@ -23,6 +23,11 @@ class InitMatrix extends EventEmitter {
   }
 
   async init() {
+    if (this.matrixClient) {
+      console.warn('Client is already initialized!')
+      return;
+    }
+
     await this.startClient();
     this.setupSync();
     this.listenEvents();
diff --git a/src/client/mx.ts b/src/client/mx.ts
new file mode 100644 (file)
index 0000000..3090945
--- /dev/null
@@ -0,0 +1,7 @@
+import { MatrixClient } from 'matrix-js-sdk';
+import initMatrix from './initMatrix';
+
+export const mx = (): MatrixClient => {
+  if (!initMatrix.matrixClient) console.error('Matrix client is used before initialization!');
+  return initMatrix.matrixClient!;
+};
index a1570480a338ea294082757d6fd69527f9bcf695..fc137ae26134e0fd8c3f5251388c7b728b7f85a8 100644 (file)
@@ -220,12 +220,6 @@ class RoomList extends EventEmitter {
     this.inviteRooms.clear();
     this.matrixClient.getRooms().forEach((room) => {
       const { roomId } = room;
-      const tombstone = room.currentState.events.get('m.room.tombstone');
-      if (tombstone?.get('') !== undefined) {
-        const repRoomId = tombstone.get('').getContent().replacement_room;
-        const repRoomMembership = this.matrixClient.getRoom(repRoomId)?.getMyMembership();
-        if (repRoomMembership === 'join') return;
-      }
 
       if (room.getMyMembership() === 'invite') {
         if (this._isDMInvite(room)) this.inviteDirects.add(roomId);
index 32f55fcc611ed9cec8c0c983d650f44b53e327ed..af2e279ad32a92151ad6fb3bc1b21c712b573963 100644 (file)
@@ -1,7 +1,9 @@
+import { lightTheme } from 'folds';
 import EventEmitter from 'events';
 import appDispatcher from '../dispatcher';
 
 import cons from './cons';
+import { darkTheme, butterTheme, silverTheme } from '../../colors.css';
 
 function getSettings() {
   const settings = localStorage.getItem('settings');
@@ -20,6 +22,7 @@ class Settings extends EventEmitter {
   constructor() {
     super();
 
+    this.themeClasses = [lightTheme, silverTheme, darkTheme, butterTheme];
     this.themes = ['', 'silver-theme', 'dark-theme', 'butter-theme'];
     this.themeIndex = this.getThemeIndex();
 
@@ -31,6 +34,10 @@ class Settings extends EventEmitter {
     this._showNotifications = this.getShowNotifications();
     this.isNotificationSounds = this.getIsNotificationSounds();
 
+    this.darkModeQueryList = window.matchMedia('(prefers-color-scheme: dark)');
+
+    this.darkModeQueryList.addEventListener('change', () => this.applyTheme())
+
     this.isTouchScreenDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0);
   }
 
@@ -49,20 +56,19 @@ class Settings extends EventEmitter {
   }
 
   _clearTheme() {
-    document.body.classList.remove('system-theme');
-    this.themes.forEach((themeName) => {
-      if (themeName === '') return;
-      document.body.classList.remove(themeName);
+    this.themes.forEach((themeName, index) => {
+      if (themeName !== '') document.body.classList.remove(themeName);
+      document.body.classList.remove(this.themeClasses[index]);
     });
   }
 
   applyTheme() {
     this._clearTheme();
-    if (this.useSystemTheme) {
-      document.body.classList.add('system-theme');
-    } else if (this.themes[this.themeIndex]) {
-      document.body.classList.add(this.themes[this.themeIndex]);
-    }
+    const autoThemeIndex = this.darkModeQueryList.matches ? 2 : 0;
+    const themeIndex = this.useSystemTheme ? autoThemeIndex : this.themeIndex;
+    if (this.themes[themeIndex] === undefined) return
+    if (this.themes[themeIndex]) document.body.classList.add(this.themes[themeIndex]);
+    document.body.classList.add(this.themeClasses[themeIndex]);
   }
 
   setTheme(themeIndex) {
diff --git a/src/colors.css.ts b/src/colors.css.ts
new file mode 100644 (file)
index 0000000..9b854be
--- /dev/null
@@ -0,0 +1,238 @@
+import { createTheme } from '@vanilla-extract/css';
+import { color } from 'folds';
+
+export const silverTheme = createTheme(color, {
+  Background: {
+    Container: '#E6E6E6',
+    ContainerHover: '#DADADA',
+    ContainerActive: '#CECECE',
+    ContainerLine: '#C2C2C2',
+    OnContainer: '#000000',
+  },
+
+  Surface: {
+    Container: '#F2F2F2',
+    ContainerHover: '#E6E6E6',
+    ContainerActive: '#DADADA',
+    ContainerLine: '#CECECE',
+    OnContainer: '#000000',
+  },
+
+  SurfaceVariant: {
+    Container: '#E6E6E6',
+    ContainerHover: '#DADADA',
+    ContainerActive: '#CECECE',
+    ContainerLine: '#C2C2C2',
+    OnContainer: '#000000',
+  },
+
+  Primary: {
+    Main: '#1858D5',
+    MainHover: '#164FC0',
+    MainActive: '#144BB5',
+    MainLine: '#1346AA',
+    OnMain: '#FFFFFF',
+    Container: '#E8EEFB',
+    ContainerHover: '#DCE6F9',
+    ContainerActive: '#D1DEF7',
+    ContainerLine: '#C5D5F5',
+    OnContainer: '#113E95',
+  },
+
+  Secondary: {
+    Main: '#000000',
+    MainHover: '#0C0C0C',
+    MainActive: '#181818',
+    MainLine: '#303030',
+    OnMain: '#F2F2F2',
+    Container: '#CECECE',
+    ContainerHover: '#C2C2C2',
+    ContainerActive: '#B5B5B5',
+    ContainerLine: '#A9A9A9',
+    OnContainer: '#0C0C0C',
+  },
+
+  Success: {
+    Main: '#00844C',
+    MainHover: '#007744',
+    MainActive: '#007041',
+    MainLine: '#006A3D',
+    OnMain: '#FFFFFF',
+    Container: '#E5F3ED',
+    ContainerHover: '#D9EDE4',
+    ContainerActive: '#CCE6DB',
+    ContainerLine: '#BFE0D2',
+    OnContainer: '#005C35',
+  },
+
+  Warning: {
+    Main: '#A85400',
+    MainHover: '#974C00',
+    MainActive: '#8F4700',
+    MainLine: '#864300',
+    OnMain: '#FFFFFF',
+    Container: '#F6EEE5',
+    ContainerHover: '#F2E5D9',
+    ContainerActive: '#EEDDCC',
+    ContainerLine: '#E9D4BF',
+    OnContainer: '#763B00',
+  },
+
+  Critical: {
+    Main: '#C40E0E',
+    MainHover: '#AC0909',
+    MainActive: '#A60C0C',
+    MainLine: '#9C0B0B',
+    OnMain: '#FFFFFF',
+    Container: '#F9E7E7',
+    ContainerHover: '#F6DBDB',
+    ContainerActive: '#F3CFCF',
+    ContainerLine: '#F0C3C3',
+    OnContainer: '#890A0A',
+  },
+
+  Other: {
+    FocusRing: 'rgba(0 0 0 / 50%)',
+    Shadow: 'rgba(0 0 0 / 20%)',
+    Overlay: 'rgba(0 0 0 / 50%)',
+  },
+});
+
+const darkThemeData = {
+  Background: {
+    Container: '#15171A',
+    ContainerHover: '#1F2326',
+    ContainerActive: '#2A2E33',
+    ContainerLine: '#343A40',
+    OnContainer: '#ffffff',
+  },
+
+  Surface: {
+    Container: '#1F2326',
+    ContainerHover: '#2A2E33',
+    ContainerActive: '#343A40',
+    ContainerLine: '#3F464D',
+    OnContainer: '#ffffff',
+  },
+
+  SurfaceVariant: {
+    Container: '#2A2E33',
+    ContainerHover: '#343A40',
+    ContainerActive: '#3F464D',
+    ContainerLine: '#495159',
+    OnContainer: '#ffffff',
+  },
+
+  Primary: {
+    Main: '#BDB6EC',
+    MainHover: '#B2AAE9',
+    MainActive: '#ADA3E8',
+    MainLine: '#A79DE6',
+    OnMain: '#2C2843',
+    Container: '#413C65',
+    ContainerHover: '#494370',
+    ContainerActive: '#50497B',
+    ContainerLine: '#575086',
+    OnContainer: '#E3E1F7',
+  },
+
+  Secondary: {
+    Main: '#D1E8FF',
+    MainHover: '#BCD1E5',
+    MainActive: '#B2C5D9',
+    MainLine: '#A7BACC',
+    OnMain: '#15171A',
+    Container: '#343A40',
+    ContainerHover: '#3F464D',
+    ContainerActive: '#495159',
+    ContainerLine: '#545D66',
+    OnContainer: '#C7DCF2',
+  },
+
+  Success: {
+    Main: '#85E0BA',
+    MainHover: '#70DBAF',
+    MainActive: '#66D9A9',
+    MainLine: '#5CD6A3',
+    OnMain: '#0F3D2A',
+    Container: '#175C3F',
+    ContainerHover: '#1A6646',
+    ContainerActive: '#1C704D',
+    ContainerLine: '#1F7A54',
+    OnContainer: '#CCF2E2',
+  },
+
+  Warning: {
+    Main: '#E3BA91',
+    MainHover: '#DFAF7E',
+    MainActive: '#DDA975',
+    MainLine: '#DAA36C',
+    OnMain: '#3F2A15',
+    Container: '#5E3F20',
+    ContainerHover: '#694624',
+    ContainerActive: '#734D27',
+    ContainerLine: '#7D542B',
+    OnContainer: '#F3E2D1',
+  },
+
+  Critical: {
+    Main: '#E69D9D',
+    MainHover: '#E28D8D',
+    MainActive: '#E08585',
+    MainLine: '#DE7D7D',
+    OnMain: '#401C1C',
+    Container: '#602929',
+    ContainerHover: '#6B2E2E',
+    ContainerActive: '#763333',
+    ContainerLine: '#803737',
+    OnContainer: '#F5D6D6',
+  },
+
+  Other: {
+    FocusRing: 'rgba(255, 255, 255, 0.5)',
+    Shadow: 'rgba(0, 0, 0, 1)',
+    Overlay: 'rgba(0, 0, 0, 0.6)',
+  },
+};
+
+export const darkTheme = createTheme(color, darkThemeData);
+
+export const butterTheme = createTheme(color, {
+  ...darkThemeData,
+  Background: {
+    Container: '#1A1916',
+    ContainerHover: '#262621',
+    ContainerActive: '#33322C',
+    ContainerLine: '#403F38',
+    OnContainer: '#FFFBDE',
+  },
+
+  Surface: {
+    Container: '#262621',
+    ContainerHover: '#33322C',
+    ContainerActive: '#403F38',
+    ContainerLine: '#4D4B43',
+    OnContainer: '#FFFBDE',
+  },
+
+  SurfaceVariant: {
+    Container: '#33322C',
+    ContainerHover: '#403F38',
+    ContainerActive: '#4D4B43',
+    ContainerLine: '#59584E',
+    OnContainer: '#FFFBDE',
+  },
+
+  Secondary: {
+    Main: '#FFFBDE',
+    MainHover: '#E5E2C8',
+    MainActive: '#D9D5BD',
+    MainLine: '#CCC9B2',
+    OnMain: '#1A1916',
+    Container: '#403F38',
+    ContainerHover: '#4D4B43',
+    ContainerActive: '#59584E',
+    ContainerLine: '#666459',
+    OnContainer: '#F2EED3',
+  },
+});
diff --git a/src/ext.d.ts b/src/ext.d.ts
new file mode 100644 (file)
index 0000000..55f5932
--- /dev/null
@@ -0,0 +1,23 @@
+declare module 'browser-encrypt-attachment' {
+  export interface EncryptedAttachmentInfo {
+    v: string;
+    key: {
+      alg: string;
+      key_ops: string[];
+      kty: string;
+      k: string;
+      ext: boolean;
+    };
+    iv: string;
+    hashes: {
+      [alg: string]: string;
+    };
+  }
+
+  export interface EncryptedAttachment {
+    data: ArrayBuffer;
+    info: EncryptedAttachmentInfo;
+  }
+
+  export function encryptAttachment(dataBuffer: ArrayBuffer): Promise<EncryptedAttachment>;
+}
index a252f6f0521c25211be407196bfe508641217c87..e7256e251e17aae82ffcb27c57edac0668dd6d8d 100644 (file)
@@ -1,5 +1,13 @@
+/* eslint-disable import/first */
 import React from 'react';
 import ReactDom from 'react-dom';
+import { enableMapSet } from 'immer';
+import '@fontsource/inter/variable.css';
+import 'folds/dist/style.css';
+import { configClass, varsClass } from 'folds';
+
+enableMapSet();
+
 import './font';
 import './index.scss';
 
@@ -7,6 +15,8 @@ import settings from './client/state/settings';
 
 import App from './app/pages/App';
 
+document.body.classList.add(configClass, varsClass);
+
 settings.applyTheme();
 
 ReactDom.render(<App />, document.getElementById('root'));
index 39d0612b0d023d5b950eba8eb22aced578bace56..93443fe91179e2ffff2b4f3f07f9b46448eab0a7 100644 (file)
@@ -1,14 +1,20 @@
 @use './app/partials/screen';
 
-:root {
+@font-face {
+  font-family: Twemoji;
+  src: url('../public/font/Twemoji.Mozilla.v.7.0.woff2'),
+    url('../public/font/Twemoji.Mozilla.v0.7.0.ttf');
+  font-display: swap;
+}
 
+:root {
   /* background color | --bg-[background type]: value */
-  --bg-surface: #FFFFFF;
-  --bg-surface-transparent: #FFFFFF00;
-  --bg-surface-low: #F6F6F6;
-  --bg-surface-low-transparent: #F6F6F600;
-  --bg-surface-extra-low: #F6F6F6;
-  --bg-surface-extra-low-transparent: #F6F6F600;
+  --bg-surface: #ffffff;
+  --bg-surface-transparent: #ffffff00;
+  --bg-surface-low: #f6f6f6;
+  --bg-surface-low-transparent: #f6f6f600;
+  --bg-surface-extra-low: #f6f6f6;
+  --bg-surface-extra-low-transparent: #f6f6f600;
   --bg-surface-hover: rgba(0, 0, 0, 3%);
   --bg-surface-active: rgba(0, 0, 0, 5%);
   --bg-surface-border: rgba(0, 0, 0, 6%);
@@ -22,7 +28,7 @@
   --bg-positive-hover: rgba(69, 184, 59, 8%);
   --bg-positive-active: rgba(69, 184, 59, 15%);
   --bg-positive-border: rgba(69, 184, 59, 40%);
-  
+
   --bg-caution: rgb(255, 179, 0);
   --bg-caution-hover: rgba(255, 179, 0, 8%);
   --bg-caution-active: rgba(255, 179, 0, 15%);
   --bg-badge: #989898;
   --bg-ping: hsla(137deg, 100%, 68%, 40%);
   --bg-ping-hover: hsla(137deg, 100%, 68%, 50%);
-  --bg-divider: hsla(0, 0%, 0%, .1);
+  --bg-divider: hsla(0, 0%, 0%, 0.1);
 
   /* text color | --tc-[background type]-[priority]: value */
   --tc-surface-high: #000000;
   --tc-surface-normal: rgba(0, 0, 0, 78%);
   --tc-surface-normal-low: rgba(0, 0, 0, 60%);
   --tc-surface-low: rgba(0, 0, 0, 48%);
-  
+
   --tc-primary-high: #ffffff;
   --tc-primary-normal: rgba(255, 255, 255, 68%);
   --tc-primary-low: rgba(255, 255, 255, 40%);
-  
+
   --tc-positive-high: var(--bg-positive);
   --tc-positive-normal: rgb(69, 184, 59, 80%);
   --tc-positive-low: rgb(69, 184, 59, 60%);
@@ -56,7 +62,7 @@
   --tc-caution-high: var(--bg-caution);
   --tc-caution-normal: rgb(255, 179, 0, 80%);
   --tc-caution-low: rgb(255, 179, 0, 60%);
-  
+
   --tc-danger-high: var(--bg-danger);
   --tc-danger-normal: rgba(240, 71, 71, 88%);
   --tc-danger-low: rgba(240, 71, 71, 60%);
@@ -66,7 +72,6 @@
   --tc-tooltip: white;
   --tc-badge: white;
 
-
   /* system icons | --ic-[background type]-[priority]: value */
   --ic-surface-high: #272727;
   --ic-surface-normal: #626262;
   --av-small: 36px;
   --av-extra-small: 24px;
 
-
   /* shadow and overlay */
   --bg-overlay: rgba(0, 0, 0, 20%);
   --bg-overlay-low: rgba(0, 0, 0, 50%);
   --bs-danger-border: inset 0 0 0 1px var(--bg-danger-border);
   --bs-danger-outline: 0 0 0 2px var(--bg-danger-border);
 
-
   /* border */
   --bo-radius: 8px;
 
-
   /* font styles: font-size, letter-spacing, line-hight */
   --fs-h1: 36px;
   --ls-h1: -1.5px;
   --fw-medium: 500;
   --fw-bold: 700;
 
-
   /* spacing | --sp-[space]: value */
   --sp-none: 0px;
   --sp-ultra-tight: 4px;
   --sp-loose: 20px;
   --sp-extra-loose: 32px;
 
-
   /* other */
   --border-width: 1px;
   --header-height: 54px;
   --people-drawer-width: calc(268px - var(--border-width));
 
   --popup-window-drawer-width: 280px;
-  
+
   @include screen.smallerThan(tabletBreakpoint) {
     --navigation-drawer-width: calc(240px + var(--border-width));
     --people-drawer-width: calc(256px - var(--border-width));
   --fluid-push: cubic-bezier(0, 0.8, 0.67, 0.97);
   --fluid-slide-down: cubic-bezier(0.02, 0.82, 0.4, 0.96);
   --fluid-slide-up: cubic-bezier(0.13, 0.56, 0.25, 0.99);
-  
-  --font-primary: 'Roboto', sans-serif;
-  --font-secondary: 'Roboto', sans-serif;
-}
 
+  --font-emoji: 'Twemoji';
+  --font-primary: 'Roboto', var(--font-emoji), sans-serif;
+  --font-secondary: 'Roboto', var(--font-emoji), sans-serif;
+}
 
 .silver-theme {
   /* background color | --bg-[background type]: value */
   --bg-surface-extra-low-transparent: hsla(0, 0%, 91%, 0);
 }
 
-@mixin dark-mode() {
+.dark-theme,
+.butter-theme {
   /* background color | --bg-[background type]: value */
   --bg-surface: hsl(208, 8%, 20%);
   --bg-surface-transparent: hsla(208, 8%, 20%, 0);
   --bg-badge: hsl(0, 0%, 75%);
   --bg-ping: hsla(137deg, 100%, 38%, 40%);
   --bg-ping-hover: hsla(137deg, 100%, 38%, 50%);
-  --bg-divider: hsla(0, 0%, 100%, .1);
-
+  --bg-divider: hsla(0, 0%, 100%, 0.1);
 
   /* text color | --tc-[background type]-[priority]: value */
   --tc-surface-high: rgba(255, 255, 255, 98%);
   --tc-surface-normal: rgba(255, 255, 255, 94%);
   --tc-surface-normal-low: rgba(255, 255, 255, 60%);
   --tc-surface-low: rgba(255, 255, 255, 58%);
-  
+
   --tc-primary-high: #ffffff;
   --tc-primary-normal: rgba(255, 255, 255, 0.68);
   --tc-primary-low: rgba(255, 255, 255, 0.4);
     --mx-uc-7: hsl(243, 100%, 74%);
     --mx-uc-8: hsl(94, 66%, 50%);
   }
-  
+
   /* shadow and overlay */
   --bg-overlay: rgba(0, 0, 0, 60%);
   --bg-overlay-low: rgba(0, 0, 0, 80%);
 
   --bs-primary-border: inset 0 0 0 1px var(--bg-primary-border);
   --bs-primary-outline: 0 0 0 2px var(--bg-primary-border);
-  
+
   /* font styles: font-size, letter-spacing, line-hight */
   --fs-h1: 35.6px;
 
   /* override normal font weight for dark mode */
   --fw-normal: 350;
 
-  --font-secondary: 'InterVariable', 'Roboto', sans-serif;
-}
-
-.dark-theme,
-.butter-theme {
-  @include dark-mode();
-}
-
-@media (prefers-color-scheme: dark) {
-  .system-theme {
-    @include dark-mode();
-  }
+  --font-secondary: 'InterVariable', 'Roboto', var(--font-emoji), sans-serif;
 }
 
 .butter-theme {
 
   --bg-badge: #c4c1ab;
 
-  
   /* text color | --tc-[background type]-[priority]: value */
   --tc-surface-high: rgb(255, 251, 222, 94%);
   --tc-surface-normal: rgba(255, 251, 222, 94%);
-  --tc-surface-normal-low: rgba(255, 251, 222, 60%); 
+  --tc-surface-normal-low: rgba(255, 251, 222, 60%);
   --tc-surface-low: rgba(255, 251, 222, 58%);
 
-
   /* system icons | --ic-[background type]-[priority]: value */
   --ic-surface-high: rgb(255, 251, 222);
   --ic-surface-normal: rgba(255, 251, 222, 84%);
@@ -387,9 +374,11 @@ body {
   height: 100%;
 }
 
-*, *::before, *::after {
+*,
+*::before,
+*::after {
   box-sizing: border-box;
-  -webkit-tap-highlight-color: rgba(0,0,0,0);
+  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
   -webkit-tap-highlight-color: transparent;
 }
 a {
@@ -428,16 +417,16 @@ button {
 textarea,
 input,
 input[type],
-input[type=text],
-input[type=username],
-input[type=password],
-input[type=email],
-input[type=checkbox] {
+input[type='text'],
+input[type='username'],
+input[type='password'],
+input[type='email'],
+input[type='checkbox'] {
   -webkit-appearance: none;
   -moz-appearance: none;
   appearance: none;
 }
-input[type=checkbox] {
+input[type='checkbox'] {
   margin: 0;
   padding: 0;
   width: 20px;
@@ -451,7 +440,7 @@ input[type=checkbox] {
   &:checked {
     background-color: var(--bg-primary);
     &::before {
-      content: "";
+      content: '';
       display: inline-block;
       width: 12px;
       height: 6px;
@@ -468,11 +457,11 @@ textarea {
 }
 .noselect {
   -webkit-touch-callout: none; /* iOS Safari */
-    -webkit-user-select: none; /* Safari */
-     -khtml-user-select: none; /* Konqueror HTML */
-       -moz-user-select: none; /* Old versions of Firefox */
-        -ms-user-select: none; /* Internet Explorer/Edge */
-            user-select: none; /* Non-prefixed version, currently
+  -webkit-user-select: none; /* Safari */
+  -khtml-user-select: none; /* Konqueror HTML */
+  -moz-user-select: none; /* Old versions of Firefox */
+  -ms-user-select: none; /* Internet Explorer/Edge */
+  user-select: none; /* Non-prefixed version, currently
                                   supported by Chrome, Edge, Opera and Firefox */
 }
 
@@ -484,4 +473,4 @@ audio:not([controls]) {
   display: flex;
   justify-content: center;
   align-items: center;
-}
\ No newline at end of file
+}
diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts
new file mode 100644 (file)
index 0000000..1078cb3
--- /dev/null
@@ -0,0 +1,12 @@
+export enum AccountDataEvent {
+  PushRules = 'm.push_rules',
+  Direct = 'm.direct',
+  IgnoredUserList = 'm.ignored_user_list',
+
+  CinnySpaces = 'in.cinny.spaces',
+
+  ElementRecentEmoji = 'io.element.recent_emoji',
+
+  PoniesUserEmotes = 'im.ponies.user_emotes',
+  PoniesEmoteRooms = 'im.ponies.emote_rooms',
+}
diff --git a/src/types/matrix/common.ts b/src/types/matrix/common.ts
new file mode 100644 (file)
index 0000000..94a46a9
--- /dev/null
@@ -0,0 +1,22 @@
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+
+export type IImageInfo = {
+  w?: number;
+  h?: number;
+  mimetype?: string;
+  size?: number;
+};
+
+export type IVideoInfo = IImageInfo & {
+  duration?: number;
+};
+
+export type IEncryptedFile = EncryptedAttachmentInfo & {
+  url: string;
+};
+
+export type IThumbnailContent = {
+  thumbnail_info?: IImageInfo;
+  thumbnail_file?: IEncryptedFile;
+  thumbnail_url?: string;
+};
diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts
new file mode 100644 (file)
index 0000000..93e8761
--- /dev/null
@@ -0,0 +1,61 @@
+export enum Membership {
+  Invite = 'invite',
+  Knock = 'knock',
+  Join = 'join',
+  Leave = 'leave',
+  Ban = 'ban',
+}
+
+export enum StateEvent {
+  RoomCanonicalAlias = 'm.room.canonical_alias',
+  RoomCreate = 'm.room.create',
+  RoomJoinRules = 'm.room.join_rules',
+  RoomMember = 'm.room.member',
+  RoomThirdPartyInvite = 'm.room.third_party_invite',
+  RoomPowerLevels = 'm.room.power_levels',
+  RoomName = 'm.room.name',
+  RoomTopic = 'm.room.topic',
+  RoomAvatar = 'm.room.avatar',
+  RoomPinnedEvents = 'm.room.pinned_events',
+  RoomEncryption = 'm.room.encryption',
+  RoomHistoryVisibility = 'm.room.history_visibility',
+  RoomGuestAccess = 'm.room.guest_access',
+  RoomServerAcl = 'm.room.server_acl',
+  RoomTombstone = 'm.room.tombstone',
+
+  SpaceChild = 'm.space.child',
+  SpaceParent = 'm.space.parent',
+
+  PoniesRoomEmotes = 'im.ponies.room_emotes',
+}
+
+export enum RoomType {
+  Space = 'm.space',
+}
+
+export enum NotificationType {
+  Default = 'default',
+  AllMessages = 'all_messages',
+  MentionsAndKeywords = 'mentions_and_keywords',
+  Mute = 'mute',
+}
+
+export type RoomToParents = Map<string, Set<string>>;
+export type RoomToUnread = Map<
+  string,
+  {
+    total: number;
+    highlight: number;
+    from: Set<string> | null;
+  }
+>;
+export type UnreadInfo = {
+  roomId: string;
+  total: number;
+  highlight: number;
+};
+
+export type MuteChanges = {
+  added: string[];
+  removed: string[];
+};
index 79cc0418383c7c411d334df263408da47a16323c..3723a11b3c8aa79c56f4993c68cb463f9b303577 100644 (file)
@@ -6,7 +6,7 @@ let mx = null;
 const permittedHtmlTags = [
   'font', 'del', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
   'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub',
-  'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code',
+  'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 's', 'code',
   'hr', 'br', 'div', 'table', 'thead', 'tbody', 'tr', 'th',
   'td', 'caption', 'pre', 'span', 'img', 'details', 'summary',
 ];
index e109a97cc88273dcb505e57f6529b67a49b8bce7..02eb1843bf67dd80252d19e1056afc7e8e668c71 100644 (file)
@@ -2,8 +2,9 @@
   "compilerOptions": {
     "sourceMap": true,
     "jsx": "react",
-    "target": "ES6",
+    "target": "ES2016",
     "allowJs": true,
+    "strict": true,
     "esModuleInterop": true,
     "moduleResolution": "Node",
     "outDir": "dist",
index 979e9aa0b7e78ef5b6289490d64f5d4b516d2d80..6a443166dc020e25390a91a4c142464ea15d22d1 100644 (file)
@@ -2,6 +2,7 @@ import { defineConfig } from 'vite';
 import react from '@vitejs/plugin-react';
 import { wasm } from '@rollup/plugin-wasm';
 import { viteStaticCopy } from 'vite-plugin-static-copy';
+import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
 import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill';
 import inject from '@rollup/plugin-inject';
 import { svgLoader } from './viteSvgLoader';
@@ -37,6 +38,7 @@ export default defineConfig({
   },
   plugins: [
     viteStaticCopy(copyFiles),
+    vanillaExtractPlugin(),
     svgLoader(),
     wasm(),
     react(),