Refactor timeline (#1346)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Fri, 6 Oct 2023 02:44:06 +0000 (13:44 +1100)
committerGitHub <noreply@github.com>
Fri, 6 Oct 2023 02:44:06 +0000 (08:14 +0530)
* fix intersection & resize observer

* add binary search util

* add scroll info util

* add virtual paginator hook - WIP

* render timeline using paginator hook

* add continuous pagination to fill timeline

* add doc comments in virtual paginator hook

* add scroll to element func in virtual paginator

* extract timeline pagination login into hook

* add sliding name for timeline messages - testing

* scroll with live event

* change message rending style

* make message timestamp smaller

* remove unused imports

* add random number between util

* add compact message component

* add sanitize html types

* fix sending alias in room mention

* get room member display name util

* add get room with canonical alias util

* add sanitize html util

* render custom html with new styles

* fix linkifying link text

* add reaction component

* display message reactions in timeline

* Change mention color

* show edited message

* add event sent by function factory

* add functions to get emoji shortcode

* add component for reaction msg

* add tooltip for who has reacted

* add message layouts & placeholder

* fix reaction size

* fix dark theme colors

* add code highlight with prismjs

* add options to configure spacing in msgs

* render message reply

* fix trim reply from body regex

* fix crash when loading reply

* fix reply hover style

* decrypt event on timeline paginate

* update custom html code style

* remove console logs

* fix virtual paginator scroll to func

* fix virtual paginator scroll to types

* add stop scroll for in view item options

* fix virtual paginator out of range scroll to index

* scroll to and highlight reply on click

* fix reply hover style

* make message avatar clickable

* fix scrollTo issue in virtual paginator

* load reply from fetch

* import virtual paginator restore scroll

* load timeline for specific event

* Fix back pagination recalibration

* fix reply min height

* revert code block colors to secondary

* stop sanitizing text in code block

* add decrypt file util

* add image media component

* update folds

* fix code block font style

* add msg event type

* add scale dimension util

* strict msg layout type

* add image renderer component

* add message content fallback components

* add message matrix event renderer components

* render matrix event using hooks

* add attachment component

* add attachment content types

* handle error when rendering image in timeline

* add video component

* render video

* include blurhash in thumbnails

* generate thumbnails for image message

* fix reactToDom spoiler opts

* add hooks for HTMLMediaElement

* render audio file in timeline

* add msg image content component

* fix image content props

* add video content component

* render new image/video component in timeline

* remove console.log

* convert seconds to milliseconds in video info

* add load thumbnail prop to video content component

* add file saver types

* add file header component

* add file content component

* render file in timeline

* add media control component

* render audio message in room timeline

* remove moved components

* safely load message reply

* add media loading hook

* update media control layout

* add loading indication in audio component

* fill audio play icon when playing audio

* fix media expanding

* add image viewer - WIP

* add pan and zoom control to image viewer

* add text based file viewer

* add pdf viewer

* add error handling in pdf viewer

* add download btn to pdf viewer

* fix file button spinner fill

* fix file opens on re-render

* add range slider in audio content player

* render location in timeline

* update folds

* display membership event in timeline

* make reactions toggle

* render sticker messages in timeline

* render room name, topic, avatar change and event

* fix typos

* update render state event type style

* add  room intro in start of timeline

* add power levels context

* fix wrong param passing in RoomView

* fix sending typing notification in wrong room

Slate onChange callback was not updating with react re-renders.

* send typing status on key up

* add typing indicator component

* add typing member atom

* display typing status in member drawer

* add room view typing member component

* display typing members in room view

* remove old roomTimeline uses

* add event readers hook

* add latest event hook

* display following members in room view

* fetch event instead of event context for reply

* fix typo in virtual paginator hook

* add scroll to latest btn in timeline

* change scroll to latest chip variant

* destructure paginator object to improve perf

* restore forward dir scroll in virtual paginator

* run scroll to bottom in layout effect

* display unread message indicator in timeline

* make component for room timeline float

* add timeline divider component

* add day divider and format message time

* apply message spacing to dividers

* format date in room intro

* send read receipt on message arrive

* add event readers component

* add reply, read receipt, source delete opt

* bug fixes

* update timeline on delete & show reason

* fix empty reaction container style

* show msg selection effect on msg option open

* add report message options

* add options to send quick reactions

* add emoji board in message options

* add reaction viewer

* fix styles

* show view reaction in msg options menu

* fix spacing between two msg by same person

* add option menu in other rendered event

* handle m.room.encrypted messages

* fix italic reply text overflow cut

* handle encrypted sticker messages

* remove console log

* prevent message context menu with alt key pressed

* make mentions clickable in messages

* add options to show and hidden events in timeline

* add option to disable media autoload

* remove old emojiboard opener

* add options to use system emoji

* refresh timeline on reset

* fix stuck typing member in member drawer

125 files changed:
.eslintrc.js
package-lock.json
package.json
src/app/components/Pdf-viewer/PdfViewer.css.ts [new file with mode: 0644]
src/app/components/Pdf-viewer/PdfViewer.tsx [new file with mode: 0644]
src/app/components/Pdf-viewer/index.ts [new file with mode: 0644]
src/app/components/editor/Editor.tsx
src/app/components/editor/Elements.css.ts [deleted file]
src/app/components/editor/Elements.tsx
src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx
src/app/components/emoji-board/EmojiBoard.tsx
src/app/components/event-readers/EventReaders.css.ts [new file with mode: 0644]
src/app/components/event-readers/EventReaders.tsx [new file with mode: 0644]
src/app/components/event-readers/index.ts [new file with mode: 0644]
src/app/components/image-viewer/ImageViewer.css.ts [new file with mode: 0644]
src/app/components/image-viewer/ImageViewer.tsx [new file with mode: 0644]
src/app/components/image-viewer/index.ts [new file with mode: 0644]
src/app/components/media/Image.tsx [new file with mode: 0644]
src/app/components/media/MediaControls.tsx [new file with mode: 0644]
src/app/components/media/Video.tsx [new file with mode: 0644]
src/app/components/media/index.ts [new file with mode: 0644]
src/app/components/media/media.css.ts [new file with mode: 0644]
src/app/components/message/MessageContentFallback.tsx [new file with mode: 0644]
src/app/components/message/Reaction.css.ts [new file with mode: 0644]
src/app/components/message/Reaction.tsx [new file with mode: 0644]
src/app/components/message/Reply.css.ts [new file with mode: 0644]
src/app/components/message/Reply.tsx [new file with mode: 0644]
src/app/components/message/Time.tsx [new file with mode: 0644]
src/app/components/message/attachment/Attachment.css.ts [new file with mode: 0644]
src/app/components/message/attachment/Attachment.tsx [new file with mode: 0644]
src/app/components/message/attachment/index.ts [new file with mode: 0644]
src/app/components/message/index.ts [new file with mode: 0644]
src/app/components/message/layout/Base.tsx [new file with mode: 0644]
src/app/components/message/layout/Bubble.tsx [new file with mode: 0644]
src/app/components/message/layout/Compact.tsx [new file with mode: 0644]
src/app/components/message/layout/Modern.tsx [new file with mode: 0644]
src/app/components/message/layout/index.ts [new file with mode: 0644]
src/app/components/message/layout/layout.css.ts [new file with mode: 0644]
src/app/components/message/placeholder/CompactPlaceholder.tsx [new file with mode: 0644]
src/app/components/message/placeholder/DefaultPlaceholder.tsx [new file with mode: 0644]
src/app/components/message/placeholder/LinePlaceholder.css.ts [new file with mode: 0644]
src/app/components/message/placeholder/LinePlaceholder.tsx [new file with mode: 0644]
src/app/components/message/placeholder/index.ts [new file with mode: 0644]
src/app/components/room-intro/RoomIntro.tsx [new file with mode: 0644]
src/app/components/room-intro/index.ts [new file with mode: 0644]
src/app/components/text-viewer/TextViewer.css.ts [new file with mode: 0644]
src/app/components/text-viewer/TextViewer.tsx [new file with mode: 0644]
src/app/components/text-viewer/index.ts [new file with mode: 0644]
src/app/components/typing-indicator/TypingIndicator.css.ts [new file with mode: 0644]
src/app/components/typing-indicator/TypingIndicator.tsx [new file with mode: 0644]
src/app/components/typing-indicator/index.ts [new file with mode: 0644]
src/app/hooks/media/index.ts [new file with mode: 0644]
src/app/hooks/media/useMediaLoading.ts [new file with mode: 0644]
src/app/hooks/media/useMediaPlay.ts [new file with mode: 0644]
src/app/hooks/media/useMediaPlayTimeCallback.ts [new file with mode: 0644]
src/app/hooks/media/useMediaPlaybackRate.ts [new file with mode: 0644]
src/app/hooks/media/useMediaSeek.ts [new file with mode: 0644]
src/app/hooks/media/useMediaVolume.ts [new file with mode: 0644]
src/app/hooks/useIntersectionObserver.ts
src/app/hooks/useMatrixEventRenderer.ts [new file with mode: 0644]
src/app/hooks/useMemberEventParser.tsx [new file with mode: 0644]
src/app/hooks/usePan.ts [new file with mode: 0644]
src/app/hooks/usePowerLevels.ts
src/app/hooks/useRelations.ts [new file with mode: 0644]
src/app/hooks/useResizeObserver.ts
src/app/hooks/useRoomEventReaders.ts [new file with mode: 0644]
src/app/hooks/useRoomLatestEvent.ts [new file with mode: 0644]
src/app/hooks/useRoomMsgContentRenderer.ts [new file with mode: 0644]
src/app/hooks/useVirtualPaginator.ts [new file with mode: 0644]
src/app/hooks/useZoom.ts [new file with mode: 0644]
src/app/organisms/room/MembersDrawer.tsx
src/app/organisms/room/Room.jsx [deleted file]
src/app/organisms/room/Room.tsx [new file with mode: 0644]
src/app/organisms/room/RoomInput.tsx
src/app/organisms/room/RoomTimeline.css.ts [new file with mode: 0644]
src/app/organisms/room/RoomTimeline.tsx [new file with mode: 0644]
src/app/organisms/room/RoomView.jsx
src/app/organisms/room/RoomViewFollowing.css.ts [new file with mode: 0644]
src/app/organisms/room/RoomViewFollowing.tsx [new file with mode: 0644]
src/app/organisms/room/RoomViewTyping.css.ts [new file with mode: 0644]
src/app/organisms/room/RoomViewTyping.tsx [new file with mode: 0644]
src/app/organisms/room/message/AudioContent.tsx [new file with mode: 0644]
src/app/organisms/room/message/EncryptedContent.tsx [new file with mode: 0644]
src/app/organisms/room/message/EventContent.tsx [new file with mode: 0644]
src/app/organisms/room/message/FileContent.tsx [new file with mode: 0644]
src/app/organisms/room/message/FileHeader.tsx [new file with mode: 0644]
src/app/organisms/room/message/ImageContent.tsx [new file with mode: 0644]
src/app/organisms/room/message/Message.tsx [new file with mode: 0644]
src/app/organisms/room/message/Reactions.tsx [new file with mode: 0644]
src/app/organisms/room/message/StickerContent.tsx [new file with mode: 0644]
src/app/organisms/room/message/VideoContent.tsx [new file with mode: 0644]
src/app/organisms/room/message/fileRenderer.tsx [new file with mode: 0644]
src/app/organisms/room/message/index.ts [new file with mode: 0644]
src/app/organisms/room/message/styles.css.ts [new file with mode: 0644]
src/app/organisms/room/message/util.ts [new file with mode: 0644]
src/app/organisms/room/msgContent.ts
src/app/organisms/room/reaction-viewer/ReactionViewer.css.ts [new file with mode: 0644]
src/app/organisms/room/reaction-viewer/ReactionViewer.tsx [new file with mode: 0644]
src/app/organisms/room/reaction-viewer/index.ts [new file with mode: 0644]
src/app/organisms/settings/Settings.jsx
src/app/plugins/emoji.ts
src/app/plugins/pdfjs-dist.ts [new file with mode: 0644]
src/app/plugins/react-custom-html-parser.tsx [new file with mode: 0644]
src/app/plugins/react-prism/ReactPrism.css [new file with mode: 0644]
src/app/plugins/react-prism/ReactPrism.tsx [new file with mode: 0644]
src/app/state/settings.ts
src/app/state/typingMembers.ts [new file with mode: 0644]
src/app/styles/CustomHtml.css.ts [new file with mode: 0644]
src/app/templates/client/Client.jsx
src/app/templates/client/ClientContent.jsx [new file with mode: 0644]
src/app/utils/blurHash.ts
src/app/utils/common.ts
src/app/utils/dom.ts
src/app/utils/matrix.ts
src/app/utils/mimeTypes.ts
src/app/utils/room.ts
src/app/utils/sanitize.ts
src/app/utils/time.ts [new file with mode: 0644]
src/client/state/settings.js
src/ext.d.ts
src/index.scss
src/types/matrix/common.ts
src/types/matrix/room.ts
tsconfig.json
vite.config.js

index 7043741823d097604eeb20450c165e8447e355fc..36101fbe503ac89e8afcba82b5e6c7a0ea11c1bd 100644 (file)
@@ -20,6 +20,9 @@ module.exports = {
     ecmaVersion: 'latest',
     sourceType: 'module',
   },
+  "globals": {
+    JSX: "readonly"
+  },
   plugins: [
     'react',
     '@typescript-eslint'
index d5867ae16d225528267f26ca58b5ae0a4068bd85..6f00efe823539c641d8b076aa16f4ed59973a071 100644 (file)
         "browser-encrypt-attachment": "0.3.0",
         "classnames": "2.3.2",
         "dateformat": "5.0.3",
+        "dayjs": "1.11.10",
         "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.3.0",
+        "folds": "1.5.0",
         "formik": "2.2.9",
-        "html-react-parser": "3.0.4",
+        "html-react-parser": "4.2.0",
         "immer": "9.0.16",
         "is-hotkey": "0.2.0",
         "jotai": "1.12.0",
         "katex": "0.16.4",
         "linkify-html": "4.0.2",
+        "linkify-react": "4.1.1",
         "linkifyjs": "4.0.2",
         "matrix-js-sdk": "24.1.0",
         "millify": "6.1.0",
+        "pdfjs-dist": "3.10.111",
+        "prismjs": "1.29.0",
         "prop-types": "15.8.1",
         "react": "17.0.2",
         "react-autosize-textarea": "7.1.0",
         "react-dnd": "15.1.2",
         "react-dnd-html5-backend": "15.1.3",
         "react-dom": "17.0.2",
+        "react-error-boundary": "4.0.10",
         "react-google-recaptcha": "2.1.0",
         "react-modal": "3.16.1",
+        "react-range": "1.8.14",
         "sanitize-html": "2.8.0",
         "slate": "0.90.0",
         "slate-history": "0.93.0",
         "@esbuild-plugins/node-globals-polyfill": "0.2.3",
         "@rollup/plugin-inject": "5.0.3",
         "@rollup/plugin-wasm": "6.1.1",
+        "@types/file-saver": "2.0.5",
         "@types/node": "18.11.18",
+        "@types/prismjs": "1.26.0",
         "@types/react": "18.0.26",
         "@types/react-dom": "18.0.9",
+        "@types/sanitize-html": "2.9.0",
         "@types/ua-parser-js": "0.7.36",
         "@typescript-eslint/eslint-plugin": "5.46.1",
         "@typescript-eslint/parser": "5.46.1",
         "react-dom": "16.14.0"
       }
     },
+    "node_modules/@mapbox/node-pre-gyp": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
+      "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
+      "optional": true,
+      "dependencies": {
+        "detect-libc": "^2.0.0",
+        "https-proxy-agent": "^5.0.0",
+        "make-dir": "^3.1.0",
+        "node-fetch": "^2.6.7",
+        "nopt": "^5.0.0",
+        "npmlog": "^5.0.1",
+        "rimraf": "^3.0.2",
+        "semver": "^7.3.5",
+        "tar": "^6.1.11"
+      },
+      "bin": {
+        "node-pre-gyp": "bin/node-pre-gyp"
+      }
+    },
+    "node_modules/@mapbox/node-pre-gyp/node_modules/semver": {
+      "version": "7.5.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
+      "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
+      "optional": true,
+      "dependencies": {
+        "lru-cache": "^6.0.0"
+      },
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/@matrix-org/matrix-sdk-crypto-js": {
       "version": "0.1.0-alpha.5",
       "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.5.tgz",
       "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
       "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g=="
     },
+    "node_modules/@types/file-saver": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz",
+      "integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==",
+      "dev": true
+    },
     "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-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==",
       "dev": true
     },
+    "node_modules/@types/prismjs": {
+      "version": "1.26.0",
+      "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.0.tgz",
+      "integrity": "sha512-ZTaqn/qSqUuAq1YwvOFQfVW1AR/oQJlLSZVustdjwI+GZ8kr0MSHBj0tsXPW1EqHubx50gtBEjbPGsdZwQwCjQ==",
+      "dev": true
+    },
     "node_modules/@types/prop-types": {
       "version": "15.7.5",
       "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
       "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
       "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="
     },
+    "node_modules/@types/sanitize-html": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.9.0.tgz",
+      "integrity": "sha512-4fP/kEcKNj2u39IzrxWYuf/FnCCwwQCpif6wwY6ROUS1EPRIfWJjGkY3HIowY1EX/VbX5e86yq8AAE7UPMgATg==",
+      "dev": true,
+      "dependencies": {
+        "htmlparser2": "^8.0.0"
+      }
+    },
     "node_modules/@types/scheduler": {
       "version": "0.16.2",
       "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
         "vite": "^4.0.0"
       }
     },
+    "node_modules/abbrev": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+      "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+      "optional": true
+    },
     "node_modules/acorn": {
       "version": "8.8.1",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz",
         "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
       }
     },
+    "node_modules/agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "optional": true,
+      "dependencies": {
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
     "node_modules/ahocorasick": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/ahocorasick/-/ahocorasick-1.0.2.tgz",
         "node": ">= 8"
       }
     },
+    "node_modules/aproba": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
+      "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==",
+      "optional": true
+    },
+    "node_modules/are-we-there-yet": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
+      "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
+      "optional": true,
+      "dependencies": {
+        "delegates": "^1.0.0",
+        "readable-stream": "^3.6.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/argparse": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
       "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
-      "dev": true
+      "devOptional": true
     },
     "node_modules/base-x": {
       "version": "4.0.0",
       "version": "1.1.11",
       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
       "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
-      "dev": true,
+      "devOptional": true,
       "dependencies": {
         "balanced-match": "^1.0.0",
         "concat-map": "0.0.1"
         }
       ]
     },
+    "node_modules/canvas": {
+      "version": "2.11.2",
+      "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz",
+      "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==",
+      "hasInstallScript": true,
+      "optional": true,
+      "dependencies": {
+        "@mapbox/node-pre-gyp": "^1.0.0",
+        "nan": "^2.17.0",
+        "simple-get": "^3.0.3"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/chalk": {
       "version": "2.4.2",
       "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
         "node": ">= 6"
       }
     },
+    "node_modules/chownr": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+      "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+      "optional": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/classnames": {
       "version": "2.3.2",
       "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
       "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
       "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
     },
+    "node_modules/color-support": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+      "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+      "optional": true,
+      "bin": {
+        "color-support": "bin.js"
+      }
+    },
     "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",
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
       "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
-      "dev": true
+      "devOptional": true
     },
     "node_modules/confusing-browser-globals": {
       "version": "1.0.11",
       "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==",
       "dev": true
     },
+    "node_modules/console-control-strings": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+      "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+      "optional": true
+    },
     "node_modules/content-type": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
         "node": ">=12.20"
       }
     },
+    "node_modules/dayjs": {
+      "version": "1.11.10",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
+      "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
+    },
     "node_modules/debug": {
       "version": "4.3.4",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
         }
       }
     },
+    "node_modules/decompress-response": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
+      "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
+      "optional": true,
+      "dependencies": {
+        "mimic-response": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/deep-is": {
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/delegates": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+      "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+      "optional": true
+    },
+    "node_modules/detect-libc": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz",
+      "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==",
+      "optional": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/dir-glob": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
       }
     },
     "node_modules/domutils": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz",
-      "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==",
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
+      "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
       "dependencies": {
         "dom-serializer": "^2.0.0",
         "domelementtype": "^2.3.0",
-        "domhandler": "^5.0.1"
+        "domhandler": "^5.0.3"
       },
       "funding": {
         "url": "https://github.com/fb55/domutils?sponsor=1"
       }
     },
     "node_modules/entities": {
-      "version": "4.4.0",
-      "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz",
-      "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==",
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
       "engines": {
         "node": ">=0.12"
       },
         "node": "^10.12.0 || >=12.0.0"
       }
     },
-    "node_modules/flat-cache/node_modules/rimraf": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
-      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
-      "dev": true,
-      "dependencies": {
-        "glob": "^7.1.3"
-      },
-      "bin": {
-        "rimraf": "bin.js"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
     "node_modules/flatted": {
       "version": "3.2.7",
       "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
       }
     },
     "node_modules/folds": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/folds/-/folds-1.3.0.tgz",
-      "integrity": "sha512-Jcv6xN9woJWaTaATDGCD9xFqUhjuSw+afvChYoUt4UsAyY351hfpkGNYzglN+gA5fvJw6N9oa6Ogjj2p84kFfA==",
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/folds/-/folds-1.5.0.tgz",
+      "integrity": "sha512-1QNHzD57OxFZT5SOe0nWcrKQvWmfMRv1f5sTF8xhGtwx9rajjv36T9SwCcj9Fh58PbERqOdBiwvpdhu+BQTVjg==",
       "peerDependencies": {
         "@vanilla-extract/css": "^1.9.2",
         "@vanilla-extract/recipes": "^0.3.0",
         "graceful-fs": "^4.1.6"
       }
     },
+    "node_modules/fs-minipass": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+      "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+      "optional": true,
+      "dependencies": {
+        "minipass": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/fs-minipass/node_modules/minipass": {
+      "version": "3.3.6",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+      "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+      "optional": true,
+      "dependencies": {
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/fs.realpath": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
       "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
-      "dev": true
+      "devOptional": true
     },
     "node_modules/fsevents": {
       "version": "2.3.2",
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/gauge": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
+      "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
+      "optional": true,
+      "dependencies": {
+        "aproba": "^1.0.3 || ^2.0.0",
+        "color-support": "^1.1.2",
+        "console-control-strings": "^1.0.0",
+        "has-unicode": "^2.0.1",
+        "object-assign": "^4.1.1",
+        "signal-exit": "^3.0.0",
+        "string-width": "^4.2.3",
+        "strip-ansi": "^6.0.1",
+        "wide-align": "^1.1.2"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/gensync": {
       "version": "1.0.0-beta.2",
       "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
       "version": "7.2.3",
       "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
       "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
-      "dev": true,
+      "devOptional": true,
       "dependencies": {
         "fs.realpath": "^1.0.0",
         "inflight": "^1.0.4",
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/has-unicode": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+      "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+      "optional": true
+    },
     "node_modules/hoist-non-react-statics": {
       "version": "3.3.2",
       "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
       }
     },
     "node_modules/html-dom-parser": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-3.1.2.tgz",
-      "integrity": "sha512-mLTtl3pVn3HnqZSZzW3xVs/mJAKrG1yIw3wlp+9bdoZHHLaBRvELdpfShiPVLyjPypq1Fugv2KMDoGHW4lVXnw==",
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-4.0.0.tgz",
+      "integrity": "sha512-TUa3wIwi80f5NF8CVWzkopBVqVAtlawUzJoLwVLHns0XSJGynss4jiY0mTWpiDOsuyw+afP+ujjMgRh9CoZcXw==",
       "dependencies": {
         "domhandler": "5.0.3",
-        "htmlparser2": "8.0.1"
+        "htmlparser2": "9.0.0"
+      }
+    },
+    "node_modules/html-dom-parser/node_modules/htmlparser2": {
+      "version": "9.0.0",
+      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.0.0.tgz",
+      "integrity": "sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ==",
+      "funding": [
+        "https://github.com/fb55/htmlparser2?sponsor=1",
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.1.0",
+        "entities": "^4.5.0"
       }
     },
     "node_modules/html-react-parser": {
-      "version": "3.0.4",
-      "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-3.0.4.tgz",
-      "integrity": "sha512-va68PSmC7uA6PbOEc9yuw5Mu3OHPXmFKUpkLGvUPdTuNrZ0CJZk1s/8X/FaHjswK/6uZghu2U02tJjussT8+uw==",
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-4.2.0.tgz",
+      "integrity": "sha512-gzU55AS+FI6qD7XaKe5BLuLFM2Xw0/LodfMWZlxV9uOHe7LCD5Lukx/EgYuBI3c0kLu0XlgFXnSzO0qUUn3Vrg==",
       "dependencies": {
         "domhandler": "5.0.3",
-        "html-dom-parser": "3.1.2",
+        "html-dom-parser": "4.0.0",
         "react-property": "2.0.0",
-        "style-to-js": "1.1.1"
+        "style-to-js": "1.1.3"
       },
       "peerDependencies": {
         "react": "0.14 || 15 || 16 || 17 || 18"
         "entities": "^4.3.0"
       }
     },
+    "node_modules/https-proxy-agent": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+      "optional": true,
+      "dependencies": {
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/ieee754": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
       "version": "1.0.6",
       "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
       "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
-      "dev": true,
+      "devOptional": true,
       "dependencies": {
         "once": "^1.3.0",
         "wrappy": "1"
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "dev": true
+      "devOptional": true
     },
     "node_modules/inline-style-parser": {
       "version": "0.1.1",
         "linkifyjs": "^4.0.0"
       }
     },
+    "node_modules/linkify-react": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.1.1.tgz",
+      "integrity": "sha512-2K9Y1cUdvq40dFWqCJ//X+WP19nlzIVITFGI93RjLnA0M7KbnxQ/ffC3AZIZaEIrLangF9Hjt3i0GQ9/anEG5A==",
+      "peerDependencies": {
+        "linkifyjs": "^4.0.0",
+        "react": ">= 15.0.0"
+      }
+    },
     "node_modules/linkifyjs": {
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.0.2.tgz",
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
       "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-      "dev": true,
+      "devOptional": true,
       "dependencies": {
         "yallist": "^4.0.0"
       },
         "node": ">=12"
       }
     },
+    "node_modules/make-dir": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+      "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+      "optional": true,
+      "dependencies": {
+        "semver": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/matrix-events-sdk": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz",
         "millify": "bin/millify"
       }
     },
+    "node_modules/mimic-response": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
+      "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==",
+      "optional": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/mini-svg-data-uri": {
       "version": "1.4.4",
       "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
       "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
-      "dev": true,
+      "devOptional": true,
       "dependencies": {
         "brace-expansion": "^1.1.7"
       },
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/minipass": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
+      "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
+      "optional": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/minizlib": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+      "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+      "optional": true,
+      "dependencies": {
+        "minipass": "^3.0.0",
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/minizlib/node_modules/minipass": {
+      "version": "3.3.6",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+      "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+      "optional": true,
+      "dependencies": {
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/mkdirp": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+      "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+      "optional": true,
+      "bin": {
+        "mkdirp": "bin/cmd.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "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=="
     },
+    "node_modules/nan": {
+      "version": "2.17.0",
+      "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz",
+      "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==",
+      "optional": true
+    },
     "node_modules/nanoid": {
       "version": "3.3.6",
       "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
       "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/nopt": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+      "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+      "optional": true,
+      "dependencies": {
+        "abbrev": "1"
+      },
+      "bin": {
+        "nopt": "bin/nopt.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/normalize-path": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
         "node": ">=0.10.0"
       }
     },
+    "node_modules/npmlog": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
+      "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
+      "optional": true,
+      "dependencies": {
+        "are-we-there-yet": "^2.0.0",
+        "console-control-strings": "^1.1.0",
+        "gauge": "^3.0.0",
+        "set-blocking": "^2.0.0"
+      }
+    },
     "node_modules/object-assign": {
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
       "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
-      "dev": true,
+      "devOptional": true,
       "dependencies": {
         "wrappy": "1"
       }
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
       "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
-      "dev": true,
+      "devOptional": true,
       "engines": {
         "node": ">=0.10.0"
       }
         "node": ">=8"
       }
     },
+    "node_modules/path2d-polyfill": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz",
+      "integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==",
+      "optional": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/pdfjs-dist": {
+      "version": "3.10.111",
+      "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.10.111.tgz",
+      "integrity": "sha512-+SXXGN/3YTNQSK5Ae7EyqQuR+4IAsNunJq/Us5ByOkRJ45qBXXOwkiWi3RIDU+CyF+ak5eSWXl2FQW2PKBrsRA==",
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "canvas": "^2.11.2",
+        "path2d-polyfill": "^2.0.1"
+      }
+    },
     "node_modules/picocolors": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
         "url": "https://github.com/prettier/prettier?sponsor=1"
       }
     },
+    "node_modules/prismjs": {
+      "version": "1.29.0",
+      "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
+      "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/promise": {
       "version": "7.3.1",
       "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
         "react": "17.0.2"
       }
     },
+    "node_modules/react-error-boundary": {
+      "version": "4.0.10",
+      "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.0.10.tgz",
+      "integrity": "sha512-pvVKdi77j2OoPHo+p3rorgE43OjDWiqFkaqkJz8sJKK6uf/u8xtzuaVfj5qJ2JnDLIgF1De3zY5AJDijp+LVPA==",
+      "dependencies": {
+        "@babel/runtime": "^7.12.5"
+      },
+      "peerDependencies": {
+        "react": ">=16.13.1"
+      }
+    },
     "node_modules/react-fast-compare": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
       "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.0.tgz",
       "integrity": "sha512-kzmNjIgU32mO4mmH5+iUyrqlpFQhF8K2k7eZ4fdLSOPFrD1XgEuSBv9LDEgxRXTMBqMd8ppT0x6TIzqE5pdGdw=="
     },
+    "node_modules/react-range": {
+      "version": "1.8.14",
+      "resolved": "https://registry.npmjs.org/react-range/-/react-range-1.8.14.tgz",
+      "integrity": "sha512-v2nyD5106rHf9dwHzq+WRlhCes83h1wJRHIMFjbZsYYsO6LF4mG/mR3cH7Cf+dkeHq65DItuqIbLn/3jjYjsHg==",
+      "peerDependencies": {
+        "react": "^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0",
+        "react-dom": "^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0"
+      }
+    },
     "node_modules/react-refresh": {
       "version": "0.14.0",
       "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz",
         "node": ">=0.10.0"
       }
     },
+    "node_modules/readable-stream": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "optional": true,
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/readdirp": {
       "version": "3.6.0",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
         "node": ">=0.10.0"
       }
     },
+    "node_modules/rimraf": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+      "devOptional": true,
+      "dependencies": {
+        "glob": "^7.1.3"
+      },
+      "bin": {
+        "rimraf": "bin.js"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
     "node_modules/rollup": {
       "version": "3.25.1",
       "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.25.1.tgz",
         "queue-microtask": "^1.2.2"
       }
     },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "optional": true
+    },
     "node_modules/safe-regex-test": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz",
         "semver": "bin/semver.js"
       }
     },
+    "node_modules/set-blocking": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+      "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+      "optional": true
+    },
     "node_modules/setimmediate": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/signal-exit": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+      "optional": true
+    },
+    "node_modules/simple-concat": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+      "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "optional": true
+    },
+    "node_modules/simple-get": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz",
+      "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==",
+      "optional": true,
+      "dependencies": {
+        "decompress-response": "^4.2.0",
+        "once": "^1.3.1",
+        "simple-concat": "^1.0.0"
+      }
+    },
     "node_modules/slash": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
         "node": ">=0.10.0"
       }
     },
+    "node_modules/string_decoder": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+      "optional": true,
+      "dependencies": {
+        "safe-buffer": "~5.2.0"
+      }
+    },
     "node_modules/string-width": {
       "version": "4.2.3",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
       }
     },
     "node_modules/style-to-js": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.1.tgz",
-      "integrity": "sha512-RJ18Z9t2B02sYhZtfWKQq5uplVctgvjTfLWT7+Eb1zjUjIrWzX5SdlkwLGQozrqarTmEzJJ/YmdNJCUNI47elg==",
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.3.tgz",
+      "integrity": "sha512-zKI5gN/zb7LS/Vm0eUwjmjrXWw8IMtyA8aPBJZdYiQTXj4+wQ3IucOLIOnF7zCHxvW8UhIGh/uZh/t9zEHXNTQ==",
       "dependencies": {
-        "style-to-object": "0.3.0"
+        "style-to-object": "0.4.1"
       }
     },
     "node_modules/style-to-object": {
-      "version": "0.3.0",
-      "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz",
-      "integrity": "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==",
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.1.tgz",
+      "integrity": "sha512-HFpbb5gr2ypci7Qw+IOhnP2zOU7e77b+rzM+wTzXzfi1PrtBCX0E7Pk4wL4iTLnhzZ+JgEGAhX81ebTg/aYjQw==",
       "dependencies": {
         "inline-style-parser": "0.1.1"
       }
       "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.0.1.tgz",
       "integrity": "sha512-SYJSIgeyXW7EuX1ytdneO5e8jip42oHWg9xl/o3oTYhmXusZVgiA+VlPvjIN+kHii9v90AmzTZEBcsEvuAY+TA=="
     },
+    "node_modules/tar": {
+      "version": "6.1.15",
+      "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz",
+      "integrity": "sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==",
+      "optional": true,
+      "dependencies": {
+        "chownr": "^2.0.0",
+        "fs-minipass": "^2.0.0",
+        "minipass": "^5.0.0",
+        "minizlib": "^2.1.1",
+        "mkdirp": "^1.0.3",
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/text-table": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
         "punycode": "^2.1.0"
       }
     },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "optional": true
+    },
     "node_modules/uuid": {
       "version": "9.0.0",
       "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/wide-align": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+      "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+      "optional": true,
+      "dependencies": {
+        "string-width": "^1.0.2 || 2 || 3 || 4"
+      }
+    },
     "node_modules/word-wrap": {
       "version": "1.2.3",
       "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
       "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
-      "dev": true
+      "devOptional": true
     },
     "node_modules/y18n": {
       "version": "5.0.8",
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
       "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-      "dev": true
+      "devOptional": true
     },
     "node_modules/yaml": {
       "version": "1.10.2",
index 5eb3fa9885e55dcfe057f0b3aa6d408b2e894bae..83850a80ee04a138d6f5bfc5243eed50d72f1dc7 100644 (file)
     "browser-encrypt-attachment": "0.3.0",
     "classnames": "2.3.2",
     "dateformat": "5.0.3",
+    "dayjs": "1.11.10",
     "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.3.0",
+    "folds": "1.5.0",
     "formik": "2.2.9",
-    "html-react-parser": "3.0.4",
+    "html-react-parser": "4.2.0",
     "immer": "9.0.16",
     "is-hotkey": "0.2.0",
     "jotai": "1.12.0",
     "katex": "0.16.4",
     "linkify-html": "4.0.2",
+    "linkify-react": "4.1.1",
     "linkifyjs": "4.0.2",
     "matrix-js-sdk": "24.1.0",
     "millify": "6.1.0",
+    "pdfjs-dist": "3.10.111",
+    "prismjs": "1.29.0",
     "prop-types": "15.8.1",
     "react": "17.0.2",
     "react-autosize-textarea": "7.1.0",
     "react-dnd": "15.1.2",
     "react-dnd-html5-backend": "15.1.3",
     "react-dom": "17.0.2",
+    "react-error-boundary": "4.0.10",
     "react-google-recaptcha": "2.1.0",
     "react-modal": "3.16.1",
+    "react-range": "1.8.14",
     "sanitize-html": "2.8.0",
     "slate": "0.90.0",
     "slate-history": "0.93.0",
     "@esbuild-plugins/node-globals-polyfill": "0.2.3",
     "@rollup/plugin-inject": "5.0.3",
     "@rollup/plugin-wasm": "6.1.1",
+    "@types/file-saver": "2.0.5",
     "@types/node": "18.11.18",
+    "@types/prismjs": "1.26.0",
     "@types/react": "18.0.26",
     "@types/react-dom": "18.0.9",
+    "@types/sanitize-html": "2.9.0",
     "@types/ua-parser-js": "0.7.36",
     "@typescript-eslint/eslint-plugin": "5.46.1",
     "@typescript-eslint/parser": "5.46.1",
diff --git a/src/app/components/Pdf-viewer/PdfViewer.css.ts b/src/app/components/Pdf-viewer/PdfViewer.css.ts
new file mode 100644 (file)
index 0000000..4655109
--- /dev/null
@@ -0,0 +1,37 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
+
+export const PdfViewer = style([
+  DefaultReset,
+  {
+    height: '100%',
+  },
+]);
+
+export const PdfViewerHeader = style([
+  DefaultReset,
+  {
+    paddingLeft: config.space.S200,
+    paddingRight: config.space.S200,
+    borderBottomWidth: config.borderWidth.B300,
+    flexShrink: 0,
+    gap: config.space.S200,
+  },
+]);
+export const PdfViewerFooter = style([
+  PdfViewerHeader,
+  {
+    borderTopWidth: config.borderWidth.B300,
+    borderBottomWidth: 0,
+  },
+]);
+
+export const PdfViewerContent = style([
+  DefaultReset,
+  {
+    margin: 'auto',
+    display: 'inline-block',
+    backgroundColor: color.Surface.Container,
+    color: color.Surface.OnContainer,
+  },
+]);
diff --git a/src/app/components/Pdf-viewer/PdfViewer.tsx b/src/app/components/Pdf-viewer/PdfViewer.tsx
new file mode 100644 (file)
index 0000000..c440cce
--- /dev/null
@@ -0,0 +1,257 @@
+/* eslint-disable no-param-reassign */
+/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
+import React, { FormEventHandler, useEffect, useRef, useState } from 'react';
+import classNames from 'classnames';
+import {
+  Box,
+  Button,
+  Chip,
+  Header,
+  Icon,
+  IconButton,
+  Icons,
+  Input,
+  Menu,
+  PopOut,
+  Scroll,
+  Spinner,
+  Text,
+  as,
+  config,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import FileSaver from 'file-saver';
+import * as css from './PdfViewer.css';
+import { AsyncStatus } from '../../hooks/useAsyncCallback';
+import { useZoom } from '../../hooks/useZoom';
+import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
+
+export type PdfViewerProps = {
+  name: string;
+  src: string;
+  requestClose: () => void;
+};
+
+export const PdfViewer = as<'div', PdfViewerProps>(
+  ({ className, name, src, requestClose, ...props }, ref) => {
+    const containerRef = useRef<HTMLDivElement>(null);
+    const scrollRef = useRef<HTMLDivElement>(null);
+    const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
+
+    const [pdfJSState, loadPdfJS] = usePdfJSLoader();
+    const [docState, loadPdfDocument] = usePdfDocumentLoader(
+      pdfJSState.status === AsyncStatus.Success ? pdfJSState.data : undefined,
+      src
+    );
+    const isLoading =
+      pdfJSState.status === AsyncStatus.Loading || docState.status === AsyncStatus.Loading;
+    const isError =
+      pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
+    const [pageNo, setPageNo] = useState(1);
+    const [openJump, setOpenJump] = useState(false);
+
+    useEffect(() => {
+      loadPdfJS();
+    }, [loadPdfJS]);
+    useEffect(() => {
+      if (pdfJSState.status === AsyncStatus.Success) {
+        loadPdfDocument();
+      }
+    }, [pdfJSState, loadPdfDocument]);
+
+    useEffect(() => {
+      if (docState.status === AsyncStatus.Success) {
+        const doc = docState.data;
+        if (pageNo < 0 || pageNo > doc.numPages) return;
+        createPage(doc, pageNo, { scale: zoom }).then((canvas) => {
+          const container = containerRef.current;
+          if (!container) return;
+          container.textContent = '';
+          container.append(canvas);
+          scrollRef.current?.scrollTo({
+            top: 0,
+          });
+        });
+      }
+    }, [docState, pageNo, zoom]);
+
+    const handleDownload = () => {
+      FileSaver.saveAs(src, name);
+    };
+
+    const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+      evt.preventDefault();
+      if (docState.status !== AsyncStatus.Success) return;
+      const jumpInput = evt.currentTarget.jumpInput as HTMLInputElement;
+      if (!jumpInput) return;
+      const jumpTo = parseInt(jumpInput.value, 10);
+      setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
+      setOpenJump(false);
+    };
+
+    const handlePrevPage = () => {
+      setPageNo((n) => Math.max(n - 1, 1));
+    };
+
+    const handleNextPage = () => {
+      if (docState.status !== AsyncStatus.Success) return;
+      setPageNo((n) => Math.min(n + 1, docState.data.numPages));
+    };
+
+    return (
+      <Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
+        <Header className={css.PdfViewerHeader} size="400">
+          <Box grow="Yes" alignItems="Center" gap="200">
+            <IconButton size="300" radii="300" onClick={requestClose}>
+              <Icon size="50" src={Icons.ArrowLeft} />
+            </IconButton>
+            <Text size="T300" truncate>
+              {name}
+            </Text>
+          </Box>
+          <Box shrink="No" alignItems="Center" gap="200">
+            <IconButton
+              variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
+              outlined={zoom < 1}
+              size="300"
+              radii="Pill"
+              onClick={zoomOut}
+              aria-label="Zoom Out"
+            >
+              <Icon size="50" src={Icons.Minus} />
+            </IconButton>
+            <Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
+              <Text size="B300">{Math.round(zoom * 100)}%</Text>
+            </Chip>
+            <IconButton
+              variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
+              outlined={zoom > 1}
+              size="300"
+              radii="Pill"
+              onClick={zoomIn}
+              aria-label="Zoom In"
+            >
+              <Icon size="50" src={Icons.Plus} />
+            </IconButton>
+            <Chip
+              variant="Primary"
+              onClick={handleDownload}
+              radii="300"
+              before={<Icon size="50" src={Icons.Download} />}
+            >
+              <Text size="B300">Download</Text>
+            </Chip>
+          </Box>
+        </Header>
+        <Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
+          {isLoading && <Spinner variant="Secondary" size="600" />}
+          {isError && (
+            <>
+              <Text>Failed to load PDF</Text>
+              <Button
+                variant="Critical"
+                fill="Soft"
+                size="300"
+                radii="300"
+                before={<Icon src={Icons.Warning} size="50" />}
+                onClick={loadPdfJS}
+              >
+                <Text size="B300">Retry</Text>
+              </Button>
+            </>
+          )}
+          {docState.status === AsyncStatus.Success && (
+            <Scroll
+              ref={scrollRef}
+              size="300"
+              direction="Both"
+              variant="Surface"
+              visibility="Hover"
+            >
+              <Box>
+                <div className={css.PdfViewerContent} ref={containerRef} />
+              </Box>
+            </Scroll>
+          )}
+        </Box>
+        {docState.status === AsyncStatus.Success && (
+          <Header as="footer" className={css.PdfViewerFooter} size="400">
+            <Chip
+              variant="Secondary"
+              radii="300"
+              before={<Icon size="50" src={Icons.ChevronLeft} />}
+              onClick={handlePrevPage}
+              aria-disabled={pageNo <= 1}
+            >
+              <Text size="B300">Previous</Text>
+            </Chip>
+            <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
+              <PopOut
+                open={openJump}
+                align="Center"
+                position="Top"
+                content={
+                  <FocusTrap
+                    focusTrapOptions={{
+                      initialFocus: false,
+                      onDeactivate: () => setOpenJump(false),
+                      clickOutsideDeactivates: true,
+                    }}
+                  >
+                    <Menu variant="Surface">
+                      <Box
+                        as="form"
+                        onSubmit={handleJumpSubmit}
+                        style={{ padding: config.space.S200 }}
+                        direction="Column"
+                        gap="200"
+                      >
+                        <Input
+                          name="jumpInput"
+                          size="300"
+                          variant="Background"
+                          defaultValue={pageNo}
+                          min={1}
+                          max={docState.data.numPages}
+                          step={1}
+                          outlined
+                          type="number"
+                          radii="300"
+                          aria-label="Page Number"
+                        />
+                        <Button type="submit" size="300" variant="Primary" radii="300">
+                          <Text size="B300">Jump To Page</Text>
+                        </Button>
+                      </Box>
+                    </Menu>
+                  </FocusTrap>
+                }
+              >
+                {(anchorRef) => (
+                  <Chip
+                    onClick={() => setOpenJump(!openJump)}
+                    ref={anchorRef}
+                    variant="SurfaceVariant"
+                    radii="300"
+                    aria-pressed={openJump}
+                  >
+                    <Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
+                  </Chip>
+                )}
+              </PopOut>
+            </Box>
+            <Chip
+              variant="Primary"
+              radii="300"
+              after={<Icon size="50" src={Icons.ChevronRight} />}
+              onClick={handleNextPage}
+              aria-disabled={pageNo >= docState.data.numPages}
+            >
+              <Text size="B300">Next</Text>
+            </Chip>
+          </Header>
+        )}
+      </Box>
+    );
+  }
+);
diff --git a/src/app/components/Pdf-viewer/index.ts b/src/app/components/Pdf-viewer/index.ts
new file mode 100644 (file)
index 0000000..5fc0566
--- /dev/null
@@ -0,0 +1 @@
+export * from './PdfViewer';
index f4241e0e3f3353539b9aa2981c4239d919e86112..e5377f2fc5ba72824388c06936228d89e4ee0962 100644 (file)
@@ -54,7 +54,7 @@ export const useEditor = (): Editor => {
   return editor;
 };
 
-export type EditorChangeHandler = ((value: Descendant[]) => void) | undefined;
+export type EditorChangeHandler = (value: Descendant[]) => void;
 type CustomEditorProps = {
   top?: ReactNode;
   bottom?: ReactNode;
@@ -64,6 +64,7 @@ type CustomEditorProps = {
   editor: Editor;
   placeholder?: string;
   onKeyDown?: KeyboardEventHandler;
+  onKeyUp?: KeyboardEventHandler;
   onChange?: EditorChangeHandler;
   onPaste?: ClipboardEventHandler;
 };
@@ -78,6 +79,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
       editor,
       placeholder,
       onKeyDown,
+      onKeyUp,
       onChange,
       onPaste,
     },
@@ -141,6 +143,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
                 renderElement={renderElement}
                 renderLeaf={renderLeaf}
                 onKeyDown={handleKeydown}
+                onKeyUp={onKeyUp}
                 onPaste={onPaste}
               />
             </Scroll>
diff --git a/src/app/components/editor/Elements.css.ts b/src/app/components/editor/Elements.css.ts
deleted file mode 100644 (file)
index 99d037d..0000000
+++ /dev/null
@@ -1,142 +0,0 @@
-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',
-  },
-]);
index 59893e539e4819cad18f7f81df4a9775f0ea0e7a..2df8099368c20f852cd6b5849db4f668cb47f0e2 100644 (file)
@@ -2,7 +2,7 @@ import { Scroll, Text } from 'folds';
 import React from 'react';
 import { RenderElementProps, RenderLeafProps, useFocused, useSelected } from 'slate-react';
 
-import * as css from './Elements.css';
+import * as css from '../../styles/CustomHtml.css';
 import { EmoticonElement, LinkElement, MentionElement } from './slate';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 
@@ -145,7 +145,13 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
     case BlockType.CodeBlock:
       return (
         <Text as="pre" className={css.CodeBlock} {...attributes}>
-          <Scroll direction="Horizontal" variant="Warning" size="300" visibility="Hover" hideTrack>
+          <Scroll
+            direction="Horizontal"
+            variant="Secondary"
+            size="300"
+            visibility="Hover"
+            hideTrack
+          >
             <div className={css.CodeBlockInternal}>{children}</div>
           </Scroll>
         </Text>
@@ -242,7 +248,7 @@ export function RenderLeaf({ attributes, leaf, children }: RenderLeafProps) {
     );
   if (leaf.spoiler)
     child = (
-      <span className={css.Spoiler} {...attributes}>
+      <span className={css.Spoiler()} {...attributes}>
         <InlineChromiumBugfix />
         {child}
       </span>
index 6bea1952bbe06c829e97ee5268917a8a52620bba..baa217ca9a862ba8182c52fd04d9b5362893706b 100644 (file)
@@ -122,8 +122,9 @@ export function RoomMentionAutocomplete({
         return;
       }
       const rId = autoCompleteRoomIds[0];
-      const name = mx.getRoom(rId)?.name ?? rId;
-      handleAutocomplete(rId, name);
+      const r = mx.getRoom(rId);
+      const name = r?.name ?? rId;
+      handleAutocomplete(r?.getCanonicalAlias() ?? rId, name);
     });
   });
 
@@ -147,7 +148,7 @@ export function RoomMentionAutocomplete({
               onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
                 onTabPress(evt, () => handleAutocomplete(rId, room.name))
               }
-              onClick={() => handleAutocomplete(rId, room.name)}
+              onClick={() => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name)}
               after={
                 <Text size="T200" priority="300" truncate>
                   {room.getCanonicalAlias() ?? ''}
index 76c6f05dda4d59716e929cf0ca2ddc8de4aecb94..a7309834cfe250f902c23d44466f45565e45d351 100644 (file)
@@ -42,7 +42,7 @@ 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 { editableActiveElement, isIntersectingScrollView, targetFromEvent } from '../../utils/dom';
 import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
 import { useDebounce } from '../../hooks/useDebounce';
 import { useThrottle } from '../../hooks/useThrottle';
@@ -675,7 +675,7 @@ export function EmojiBoard({
     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 groupEl = groupEls.find((el) => isIntersectingScrollView(targetEl, el));
     const groupId = groupEl?.getAttribute('data-group-id') ?? undefined;
     setActiveGroupId(groupId);
   }, [setActiveGroupId]);
diff --git a/src/app/components/event-readers/EventReaders.css.ts b/src/app/components/event-readers/EventReaders.css.ts
new file mode 100644 (file)
index 0000000..36f47b5
--- /dev/null
@@ -0,0 +1,21 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, config } from 'folds';
+
+export const EventReaders = style([
+  DefaultReset,
+  {
+    height: '100%',
+  },
+]);
+
+export const Header = style({
+  paddingLeft: config.space.S400,
+  paddingRight: config.space.S300,
+
+  flexShrink: 0,
+});
+
+export const Content = style({
+  paddingLeft: config.space.S200,
+  paddingBottom: config.space.S400,
+});
diff --git a/src/app/components/event-readers/EventReaders.tsx b/src/app/components/event-readers/EventReaders.tsx
new file mode 100644 (file)
index 0000000..c05efc5
--- /dev/null
@@ -0,0 +1,110 @@
+import React from 'react';
+import classNames from 'classnames';
+import {
+  Avatar,
+  AvatarFallback,
+  AvatarImage,
+  Box,
+  Header,
+  Icon,
+  IconButton,
+  Icons,
+  MenuItem,
+  Scroll,
+  Text,
+  as,
+  config,
+} from 'folds';
+import { Room, RoomMember } from 'matrix-js-sdk';
+import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import * as css from './EventReaders.css';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import colorMXID from '../../../util/colorMXID';
+import { openProfileViewer } from '../../../client/action/navigation';
+
+export type EventReadersProps = {
+  room: Room;
+  eventId: string;
+  requestClose: () => void;
+};
+export const EventReaders = as<'div', EventReadersProps>(
+  ({ className, room, eventId, requestClose, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const latestEventReaders = useRoomEventReaders(room, eventId);
+    const followingMembers = latestEventReaders
+      .map((readerId) => room.getMember(readerId))
+      .filter((member) => member) as RoomMember[];
+
+    const getName = (member: RoomMember) =>
+      getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
+
+    return (
+      <Box
+        className={classNames(css.EventReaders, className)}
+        direction="Column"
+        {...props}
+        ref={ref}
+      >
+        <Header className={css.Header} variant="Surface" size="600">
+          <Box grow="Yes">
+            <Text size="H3">Seen by</Text>
+          </Box>
+          <IconButton size="300" onClick={requestClose}>
+            <Icon src={Icons.Cross} />
+          </IconButton>
+        </Header>
+        <Box grow="Yes">
+          <Scroll visibility="Hover" hideTrack size="300">
+            <Box className={css.Content} direction="Column">
+              {followingMembers.map((member) => {
+                const name = getName(member);
+                const avatarUrl = member.getAvatarUrl(
+                  mx.baseUrl,
+                  100,
+                  100,
+                  'crop',
+                  undefined,
+                  false
+                );
+
+                return (
+                  <MenuItem
+                    key={member.userId}
+                    style={{ padding: `0 ${config.space.S200}` }}
+                    radii="400"
+                    onClick={() => {
+                      requestClose();
+                      openProfileViewer(member.userId, room.roomId);
+                    }}
+                    before={
+                      <Avatar size="200">
+                        {avatarUrl ? (
+                          <AvatarImage src={avatarUrl} />
+                        ) : (
+                          <AvatarFallback
+                            style={{
+                              background: colorMXID(member.userId),
+                              color: 'white',
+                            }}
+                          >
+                            <Text size="H6">{name[0]}</Text>
+                          </AvatarFallback>
+                        )}
+                      </Avatar>
+                    }
+                  >
+                    <Text size="T400" truncate>
+                      {name}
+                    </Text>
+                  </MenuItem>
+                );
+              })}
+            </Box>
+          </Scroll>
+        </Box>
+      </Box>
+    );
+  }
+);
diff --git a/src/app/components/event-readers/index.ts b/src/app/components/event-readers/index.ts
new file mode 100644 (file)
index 0000000..8a37548
--- /dev/null
@@ -0,0 +1 @@
+export * from './EventReaders';
diff --git a/src/app/components/image-viewer/ImageViewer.css.ts b/src/app/components/image-viewer/ImageViewer.css.ts
new file mode 100644 (file)
index 0000000..fc2f508
--- /dev/null
@@ -0,0 +1,40 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
+
+export const ImageViewer = style([
+  DefaultReset,
+  {
+    height: '100%',
+  },
+]);
+
+export const ImageViewerHeader = style([
+  DefaultReset,
+  {
+    paddingLeft: config.space.S200,
+    paddingRight: config.space.S200,
+    borderBottomWidth: config.borderWidth.B300,
+    flexShrink: 0,
+    gap: config.space.S200,
+  },
+]);
+
+export const ImageViewerContent = style([
+  DefaultReset,
+  {
+    backgroundColor: color.Background.Container,
+    color: color.Background.OnContainer,
+    overflow: 'hidden',
+  },
+]);
+
+export const ImageViewerImg = style([
+  DefaultReset,
+  {
+    objectFit: 'contain',
+    width: '100%',
+    height: '100%',
+    backgroundColor: color.Surface.Container,
+    transition: 'transform 100ms linear',
+  },
+]);
diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx
new file mode 100644 (file)
index 0000000..4fd06b7
--- /dev/null
@@ -0,0 +1,95 @@
+/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
+import React from 'react';
+import FileSaver from 'file-saver';
+import classNames from 'classnames';
+import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
+import * as css from './ImageViewer.css';
+import { useZoom } from '../../hooks/useZoom';
+import { usePan } from '../../hooks/usePan';
+
+export type ImageViewerProps = {
+  alt: string;
+  src: string;
+  requestClose: () => void;
+};
+
+export const ImageViewer = as<'div', ImageViewerProps>(
+  ({ className, alt, src, requestClose, ...props }, ref) => {
+    const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
+    const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
+
+    const handleDownload = () => {
+      FileSaver.saveAs(src, alt);
+    };
+
+    return (
+      <Box
+        className={classNames(css.ImageViewer, className)}
+        direction="Column"
+        {...props}
+        ref={ref}
+      >
+        <Header className={css.ImageViewerHeader} size="400">
+          <Box grow="Yes" alignItems="Center" gap="200">
+            <IconButton size="300" radii="300" onClick={requestClose}>
+              <Icon size="50" src={Icons.ArrowLeft} />
+            </IconButton>
+            <Text size="T300" truncate>
+              {alt}
+            </Text>
+          </Box>
+          <Box shrink="No" alignItems="Center" gap="200">
+            <IconButton
+              variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
+              outlined={zoom < 1}
+              size="300"
+              radii="Pill"
+              onClick={zoomOut}
+              aria-label="Zoom Out"
+            >
+              <Icon size="50" src={Icons.Minus} />
+            </IconButton>
+            <Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
+              <Text size="B300">{Math.round(zoom * 100)}%</Text>
+            </Chip>
+            <IconButton
+              variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
+              outlined={zoom > 1}
+              size="300"
+              radii="Pill"
+              onClick={zoomIn}
+              aria-label="Zoom In"
+            >
+              <Icon size="50" src={Icons.Plus} />
+            </IconButton>
+            <Chip
+              variant="Primary"
+              onClick={handleDownload}
+              radii="300"
+              before={<Icon size="50" src={Icons.Download} />}
+            >
+              <Text size="B300">Download</Text>
+            </Chip>
+          </Box>
+        </Header>
+        <Box
+          grow="Yes"
+          className={css.ImageViewerContent}
+          justifyContent="Center"
+          alignItems="Center"
+        >
+          <img
+            className={css.ImageViewerImg}
+            style={{
+              cursor,
+              transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
+            }}
+            src={src}
+            alt={alt}
+            onMouseDown={onMouseDown}
+          />
+        </Box>
+      </Box>
+    );
+  }
+);
diff --git a/src/app/components/image-viewer/index.ts b/src/app/components/image-viewer/index.ts
new file mode 100644 (file)
index 0000000..69943f2
--- /dev/null
@@ -0,0 +1 @@
+export * from './ImageViewer';
diff --git a/src/app/components/media/Image.tsx b/src/app/components/media/Image.tsx
new file mode 100644 (file)
index 0000000..dda21a5
--- /dev/null
@@ -0,0 +1,9 @@
+import React, { ImgHTMLAttributes, forwardRef } from 'react';
+import classNames from 'classnames';
+import * as css from './media.css';
+
+export const Image = forwardRef<HTMLImageElement, ImgHTMLAttributes<HTMLImageElement>>(
+  ({ className, alt, ...props }, ref) => (
+    <img className={classNames(css.Image, className)} alt={alt} {...props} ref={ref} />
+  )
+);
diff --git a/src/app/components/media/MediaControls.tsx b/src/app/components/media/MediaControls.tsx
new file mode 100644 (file)
index 0000000..95a344a
--- /dev/null
@@ -0,0 +1,27 @@
+import React, { ReactNode } from 'react';
+import { Box, as } from 'folds';
+
+export type MediaControlProps = {
+  before?: ReactNode;
+  after?: ReactNode;
+  leftControl?: ReactNode;
+  rightControl?: ReactNode;
+};
+export const MediaControl = as<'div', MediaControlProps>(
+  ({ before, after, leftControl, rightControl, children, ...props }, ref) => (
+    <Box grow="Yes" direction="Column" gap="300" {...props} ref={ref}>
+      {before && <Box direction="Column">{before}</Box>}
+      <Box alignItems="Center" gap="200">
+        <Box alignItems="Center" grow="Yes" gap="Inherit">
+          {leftControl}
+        </Box>
+
+        <Box justifyItems="End" alignItems="Center" gap="Inherit">
+          {rightControl}
+        </Box>
+      </Box>
+      {after && <Box direction="Column">{after}</Box>}
+      {children}
+    </Box>
+  )
+);
diff --git a/src/app/components/media/Video.tsx b/src/app/components/media/Video.tsx
new file mode 100644 (file)
index 0000000..ab13c5b
--- /dev/null
@@ -0,0 +1,10 @@
+import React, { VideoHTMLAttributes, forwardRef } from 'react';
+import classNames from 'classnames';
+import * as css from './media.css';
+
+export const Video = forwardRef<HTMLVideoElement, VideoHTMLAttributes<HTMLVideoElement>>(
+  ({ className, ...props }, ref) => (
+    // eslint-disable-next-line jsx-a11y/media-has-caption
+    <video className={classNames(css.Image, className)} {...props} ref={ref} />
+  )
+);
diff --git a/src/app/components/media/index.ts b/src/app/components/media/index.ts
new file mode 100644 (file)
index 0000000..92a19d1
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './Image';
+export * from './Video';
+export * from './MediaControls';
diff --git a/src/app/components/media/media.css.ts b/src/app/components/media/media.css.ts
new file mode 100644 (file)
index 0000000..46d17ca
--- /dev/null
@@ -0,0 +1,20 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset } from 'folds';
+
+export const Image = style([
+  DefaultReset,
+  {
+    objectFit: 'cover',
+    width: '100%',
+    height: '100%',
+  },
+]);
+
+export const Video = style([
+  DefaultReset,
+  {
+    objectFit: 'cover',
+    width: '100%',
+    height: '100%',
+  },
+]);
diff --git a/src/app/components/message/MessageContentFallback.tsx b/src/app/components/message/MessageContentFallback.tsx
new file mode 100644 (file)
index 0000000..9edb967
--- /dev/null
@@ -0,0 +1,66 @@
+import { Box, Icon, Icons, Text, as, color, config } from 'folds';
+import React from 'react';
+
+const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 };
+const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 };
+
+export const MessageDeletedContent = as<'div', { children?: never; reason?: string }>(
+  ({ reason, ...props }, ref) => (
+    <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
+      <Icon size="50" src={Icons.Delete} />
+      {reason ? (
+        <i>This message has been deleted. {reason}</i>
+      ) : (
+        <i>This message has been deleted</i>
+      )}
+    </Box>
+  )
+);
+
+export const MessageUnsupportedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+  <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
+    <Icon size="50" src={Icons.Warning} />
+    <i>Unsupported message</i>
+  </Box>
+));
+
+export const MessageFailedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+  <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
+    <Icon size="50" src={Icons.Warning} />
+    <i>Failed to load message</i>
+  </Box>
+));
+
+export const MessageBadEncryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+  <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
+    <Icon size="50" src={Icons.Lock} />
+    <i>Unable to decrypt message</i>
+  </Box>
+));
+
+export const MessageNotDecryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+  <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
+    <Icon size="50" src={Icons.Lock} />
+    <i>This message is not decrypted yet</i>
+  </Box>
+));
+
+export const MessageBrokenContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+  <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
+    <Icon size="50" src={Icons.Warning} />
+    <i>Broken message</i>
+  </Box>
+));
+
+export const MessageEmptyContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+  <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
+    <Icon size="50" src={Icons.Warning} />
+    <i>Empty message</i>
+  </Box>
+));
+
+export const MessageEditedContent = as<'span', { children?: never }>(({ ...props }, ref) => (
+  <Text as="span" size="T200" priority="300" {...props} ref={ref}>
+    {' (edited)'}
+  </Text>
+));
diff --git a/src/app/components/message/Reaction.css.ts b/src/app/components/message/Reaction.css.ts
new file mode 100644 (file)
index 0000000..f020080
--- /dev/null
@@ -0,0 +1,75 @@
+import { createVar, style } from '@vanilla-extract/css';
+import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
+
+const Container = createVar();
+const ContainerHover = createVar();
+const ContainerActive = createVar();
+const ContainerLine = createVar();
+const OnContainer = createVar();
+
+export const Reaction = style([
+  FocusOutline,
+  {
+    vars: {
+      [Container]: color.SurfaceVariant.Container,
+      [ContainerHover]: color.SurfaceVariant.ContainerHover,
+      [ContainerActive]: color.SurfaceVariant.ContainerActive,
+      [ContainerLine]: color.SurfaceVariant.ContainerLine,
+      [OnContainer]: color.SurfaceVariant.OnContainer,
+    },
+    padding: `${toRem(2)} ${config.space.S200} ${toRem(2)} ${config.space.S100}`,
+    backgroundColor: Container,
+    border: `${config.borderWidth.B300} solid ${ContainerLine}`,
+    borderRadius: config.radii.R300,
+
+    selectors: {
+      'button&': {
+        cursor: 'pointer',
+      },
+      '&[aria-pressed=true]': {
+        vars: {
+          [Container]: color.Primary.Container,
+          [ContainerHover]: color.Primary.ContainerHover,
+          [ContainerActive]: color.Primary.ContainerActive,
+          [ContainerLine]: color.Primary.ContainerLine,
+          [OnContainer]: color.Primary.OnContainer,
+        },
+        backgroundColor: Container,
+      },
+      '&[aria-selected=true]': {
+        borderColor: color.Secondary.Main,
+        borderWidth: config.borderWidth.B400,
+      },
+      '&:hover, &:focus-visible': {
+        backgroundColor: ContainerHover,
+      },
+      '&:active': {
+        backgroundColor: ContainerActive,
+      },
+      '&[aria-disabled=true], &:disabled': {
+        cursor: 'not-allowed',
+      },
+    },
+  },
+]);
+
+export const ReactionText = style([
+  DefaultReset,
+  {
+    minWidth: 0,
+    maxWidth: toRem(150),
+    display: 'inline-flex',
+    alignItems: 'center',
+    lineHeight: toRem(20),
+  },
+]);
+
+export const ReactionImg = style([
+  DefaultReset,
+  {
+    height: '1em',
+    minWidth: 0,
+    maxWidth: toRem(150),
+    objectFit: 'contain',
+  },
+]);
diff --git a/src/app/components/message/Reaction.tsx b/src/app/components/message/Reaction.tsx
new file mode 100644 (file)
index 0000000..ce2c2bf
--- /dev/null
@@ -0,0 +1,113 @@
+import React from 'react';
+import { Box, Text, as } from 'folds';
+import classNames from 'classnames';
+import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
+import * as css from './Reaction.css';
+import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
+import { getMemberDisplayName } from '../../utils/room';
+import { eventWithShortcode, getMxIdLocalPart } from '../../utils/matrix';
+
+export const Reaction = as<
+  'button',
+  {
+    mx: MatrixClient;
+    count: number;
+    reaction: string;
+  }
+>(({ className, mx, count, reaction, ...props }, ref) => (
+  <Box
+    as="button"
+    className={classNames(css.Reaction, className)}
+    alignItems="Center"
+    shrink="No"
+    gap="200"
+    {...props}
+    ref={ref}
+  >
+    <Text className={css.ReactionText} as="span" size="T400">
+      {reaction.startsWith('mxc://') ? (
+        <img
+          className={css.ReactionImg}
+          src={mx.mxcUrlToHttp(reaction) ?? reaction}
+          alt={reaction}
+        />
+      ) : (
+        <Text as="span" size="Inherit" truncate>
+          {reaction}
+        </Text>
+      )}
+    </Text>
+    <Text as="span" size="T300">
+      {count}
+    </Text>
+  </Box>
+));
+
+type ReactionTooltipMsgProps = {
+  room: Room;
+  reaction: string;
+  events: MatrixEvent[];
+};
+
+export function ReactionTooltipMsg({ room, reaction, events }: ReactionTooltipMsgProps) {
+  const shortCodeEvt = events.find(eventWithShortcode);
+  const shortcode =
+    shortCodeEvt?.getContent().shortcode ??
+    getShortcodeFor(getHexcodeForEmoji(reaction)) ??
+    reaction;
+  const names = events.map(
+    (ev: MatrixEvent) =>
+      getMemberDisplayName(room, ev.getSender() ?? 'Unknown') ??
+      getMxIdLocalPart(ev.getSender() ?? 'Unknown') ??
+      'Unknown'
+  );
+
+  return (
+    <>
+      {names.length === 1 && <b>{names[0]}</b>}
+      {names.length === 2 && (
+        <>
+          <b>{names[0]}</b>
+          <Text as="span" size="Inherit" priority="300">
+            {' and '}
+          </Text>
+          <b>{names[1]}</b>
+        </>
+      )}
+      {names.length === 3 && (
+        <>
+          <b>{names[0]}</b>
+          <Text as="span" size="Inherit" priority="300">
+            {', '}
+          </Text>
+          <b>{names[1]}</b>
+          <Text as="span" size="Inherit" priority="300">
+            {' and '}
+          </Text>
+          <b>{names[2]}</b>
+        </>
+      )}
+      {names.length > 3 && (
+        <>
+          <b>{names[0]}</b>
+          <Text as="span" size="Inherit" priority="300">
+            {', '}
+          </Text>
+          <b>{names[1]}</b>
+          <Text as="span" size="Inherit" priority="300">
+            {', '}
+          </Text>
+          <b>{names[2]}</b>
+          <Text as="span" size="Inherit" priority="300">
+            {' and '}
+          </Text>
+          <b>{names.length - 3} others</b>
+        </>
+      )}
+      <Text as="span" size="Inherit" priority="300">
+        {' reacted with '}
+      </Text>
+      :<b>{shortcode}</b>:
+    </>
+  );
+}
diff --git a/src/app/components/message/Reply.css.ts b/src/app/components/message/Reply.css.ts
new file mode 100644 (file)
index 0000000..c47aac5
--- /dev/null
@@ -0,0 +1,25 @@
+import { style } from '@vanilla-extract/css';
+import { config, toRem } from 'folds';
+
+export const Reply = style({
+  padding: `0 ${config.space.S100}`,
+  marginBottom: toRem(1),
+  cursor: 'pointer',
+  minWidth: 0,
+  maxWidth: '100%',
+  minHeight: config.lineHeight.T300,
+});
+
+export const ReplyContent = style({
+  opacity: config.opacity.P300,
+
+  selectors: {
+    [`${Reply}:hover &`]: {
+      opacity: config.opacity.P500,
+    },
+  },
+});
+
+export const ReplyContentText = style({
+  paddingRight: config.space.S100,
+});
diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx
new file mode 100644 (file)
index 0000000..67f4df4
--- /dev/null
@@ -0,0 +1,100 @@
+import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
+import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
+import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
+import React, { useEffect, useState } from 'react';
+import to from 'await-to-js';
+import classNames from 'classnames';
+import colorMXID from '../../../util/colorMXID';
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart, trimReplyFromBody } from '../../utils/matrix';
+import { LinePlaceholder } from './placeholder';
+import { randomNumberBetween } from '../../utils/common';
+import * as css from './Reply.css';
+import {
+  MessageBadEncryptedContent,
+  MessageDeletedContent,
+  MessageFailedContent,
+} from './MessageContentFallback';
+
+type ReplyProps = {
+  mx: MatrixClient;
+  room: Room;
+  timelineSet: EventTimelineSet;
+  eventId: string;
+};
+
+export const Reply = as<'div', ReplyProps>(
+  ({ className, mx, room, timelineSet, eventId, ...props }, ref) => {
+    const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
+      timelineSet.findEventById(eventId)
+    );
+
+    const { body } = replyEvent?.getContent() ?? {};
+    const sender = replyEvent?.getSender();
+
+    const fallbackBody = replyEvent?.isRedacted() ? (
+      <MessageDeletedContent />
+    ) : (
+      <MessageFailedContent />
+    );
+
+    useEffect(() => {
+      let disposed = false;
+      const loadEvent = async () => {
+        const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, eventId));
+        const mEvent = new MatrixEvent(evt);
+        if (disposed) return;
+        if (err) {
+          setReplyEvent(null);
+          return;
+        }
+        if (mEvent.isEncrypted() && mx.getCrypto()) {
+          await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
+        }
+        setReplyEvent(mEvent);
+      };
+      if (replyEvent === undefined) loadEvent();
+      return () => {
+        disposed = true;
+      };
+    }, [replyEvent, mx, room, eventId]);
+
+    return (
+      <Box
+        className={classNames(css.Reply, className)}
+        alignItems="Center"
+        gap="100"
+        {...props}
+        ref={ref}
+      >
+        <Box style={{ color: colorMXID(sender ?? eventId) }} alignItems="Center" shrink="No">
+          <Icon src={Icons.ReplyArrow} size="50" />
+          {sender && (
+            <Text size="T300" truncate>
+              {getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}
+            </Text>
+          )}
+        </Box>
+        <Box grow="Yes" className={css.ReplyContent}>
+          {replyEvent !== undefined ? (
+            <Text className={css.ReplyContentText} size="T300" truncate>
+              {replyEvent?.getContent().msgtype === 'm.bad.encrypted' ? (
+                <MessageBadEncryptedContent />
+              ) : (
+                (body && trimReplyFromBody(body)) ?? fallbackBody
+              )}
+            </Text>
+          ) : (
+            <LinePlaceholder
+              style={{
+                backgroundColor: color.SurfaceVariant.ContainerActive,
+                maxWidth: toRem(randomNumberBetween(40, 400)),
+                width: '100%',
+              }}
+            />
+          )}
+        </Box>
+      </Box>
+    );
+  }
+);
diff --git a/src/app/components/message/Time.tsx b/src/app/components/message/Time.tsx
new file mode 100644 (file)
index 0000000..de11cf8
--- /dev/null
@@ -0,0 +1,27 @@
+import React from 'react';
+import { Text, as } from 'folds';
+import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
+
+export type TimeProps = {
+  compact?: boolean;
+  ts: number;
+};
+
+export const Time = as<'span', TimeProps>(({ compact, ts, ...props }, ref) => {
+  let time = '';
+  if (compact) {
+    time = timeHourMinute(ts);
+  } else if (today(ts)) {
+    time = timeHourMinute(ts);
+  } else if (yesterday(ts)) {
+    time = `Yesterday ${timeHourMinute(ts)}`;
+  } else {
+    time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
+  }
+
+  return (
+    <Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
+      {time}
+    </Text>
+  );
+});
diff --git a/src/app/components/message/attachment/Attachment.css.ts b/src/app/components/message/attachment/Attachment.css.ts
new file mode 100644 (file)
index 0000000..4c41761
--- /dev/null
@@ -0,0 +1,42 @@
+import { style } from '@vanilla-extract/css';
+import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
+import { DefaultReset, color, config, toRem } from 'folds';
+
+export const Attachment = recipe({
+  base: {
+    backgroundColor: color.SurfaceVariant.Container,
+    color: color.SurfaceVariant.OnContainer,
+    borderRadius: config.radii.R400,
+    overflow: 'hidden',
+    maxWidth: '100%',
+    width: toRem(400),
+  },
+  variants: {
+    outlined: {
+      true: {
+        boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
+      },
+    },
+  },
+});
+
+export type AttachmentVariants = RecipeVariants<typeof Attachment>;
+
+export const AttachmentHeader = style({
+  padding: config.space.S300,
+});
+
+export const AttachmentBox = style([
+  DefaultReset,
+  {
+    maxWidth: '100%',
+    maxHeight: toRem(600),
+    width: toRem(400),
+    overflow: 'hidden',
+  },
+]);
+
+export const AttachmentContent = style({
+  padding: config.space.S300,
+  paddingTop: 0,
+});
diff --git a/src/app/components/message/attachment/Attachment.tsx b/src/app/components/message/attachment/Attachment.tsx
new file mode 100644 (file)
index 0000000..4b0b71f
--- /dev/null
@@ -0,0 +1,44 @@
+import React from 'react';
+import { Box, as } from 'folds';
+import classNames from 'classnames';
+import * as css from './Attachment.css';
+
+export const Attachment = as<'div', css.AttachmentVariants>(
+  ({ className, outlined, ...props }, ref) => (
+    <Box
+      display="InlineFlex"
+      direction="Column"
+      className={classNames(css.Attachment({ outlined }), className)}
+      {...props}
+      ref={ref}
+    />
+  )
+);
+
+export const AttachmentHeader = as<'div'>(({ className, ...props }, ref) => (
+  <Box
+    shrink="No"
+    gap="200"
+    className={classNames(css.AttachmentHeader, className)}
+    {...props}
+    ref={ref}
+  />
+));
+
+export const AttachmentBox = as<'div'>(({ className, ...props }, ref) => (
+  <Box
+    direction="Column"
+    className={classNames(css.AttachmentBox, className)}
+    {...props}
+    ref={ref}
+  />
+));
+
+export const AttachmentContent = as<'div'>(({ className, ...props }, ref) => (
+  <Box
+    direction="Column"
+    className={classNames(css.AttachmentContent, className)}
+    {...props}
+    ref={ref}
+  />
+));
diff --git a/src/app/components/message/attachment/index.ts b/src/app/components/message/attachment/index.ts
new file mode 100644 (file)
index 0000000..cf17ddc
--- /dev/null
@@ -0,0 +1 @@
+export * from './Attachment';
diff --git a/src/app/components/message/index.ts b/src/app/components/message/index.ts
new file mode 100644 (file)
index 0000000..58f8fe5
--- /dev/null
@@ -0,0 +1,7 @@
+export * from './layout';
+export * from './placeholder';
+export * from './Reaction';
+export * from './attachment';
+export * from './Reply';
+export * from './MessageContentFallback';
+export * from './Time';
diff --git a/src/app/components/message/layout/Base.tsx b/src/app/components/message/layout/Base.tsx
new file mode 100644 (file)
index 0000000..9439ec5
--- /dev/null
@@ -0,0 +1,25 @@
+import React from 'react';
+import { as } from 'folds';
+import classNames from 'classnames';
+import * as css from './layout.css';
+
+export const MessageBase = as<'div', css.MessageBaseVariants>(
+  ({ className, highlight, selected, collapse, autoCollapse, space, ...props }, ref) => (
+    <div
+      className={classNames(
+        css.MessageBase({ highlight, selected, collapse, autoCollapse, space }),
+        className
+      )}
+      {...props}
+      ref={ref}
+    />
+  )
+);
+
+export const AvatarBase = as<'span'>(({ className, ...props }, ref) => (
+  <span className={classNames(css.AvatarBase, className)} {...props} ref={ref} />
+));
+
+export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...props }, ref) => (
+  <AsUsername className={classNames(css.Username, className)} {...props} ref={ref} />
+));
diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx
new file mode 100644 (file)
index 0000000..6f8e70d
--- /dev/null
@@ -0,0 +1,18 @@
+import React, { ReactNode } from 'react';
+import { Box, as } from 'folds';
+import * as css from './layout.css';
+
+type BubbleLayoutProps = {
+  before?: ReactNode;
+};
+
+export const BubbleLayout = as<'div', BubbleLayoutProps>(({ before, children, ...props }, ref) => (
+  <Box gap="300" {...props} ref={ref}>
+    <Box className={css.BubbleBefore} shrink="No">
+      {before}
+    </Box>
+    <Box className={css.BubbleContent} direction="Column">
+      {children}
+    </Box>
+  </Box>
+));
diff --git a/src/app/components/message/layout/Compact.tsx b/src/app/components/message/layout/Compact.tsx
new file mode 100644 (file)
index 0000000..a033919
--- /dev/null
@@ -0,0 +1,18 @@
+import React, { ReactNode } from 'react';
+import { Box, as } from 'folds';
+import * as css from './layout.css';
+
+type CompactLayoutProps = {
+  before?: ReactNode;
+};
+
+export const CompactLayout = as<'div', CompactLayoutProps>(
+  ({ before, children, ...props }, ref) => (
+    <Box gap="200" {...props} ref={ref}>
+      <Box className={css.CompactHeader} gap="200" shrink="No">
+        {before}
+      </Box>
+      {children}
+    </Box>
+  )
+);
diff --git a/src/app/components/message/layout/Modern.tsx b/src/app/components/message/layout/Modern.tsx
new file mode 100644 (file)
index 0000000..70b25c5
--- /dev/null
@@ -0,0 +1,18 @@
+import React, { ReactNode } from 'react';
+import { Box, as } from 'folds';
+import * as css from './layout.css';
+
+type ModernLayoutProps = {
+  before?: ReactNode;
+};
+
+export const ModernLayout = as<'div', ModernLayoutProps>(({ before, children, ...props }, ref) => (
+  <Box gap="300" {...props} ref={ref}>
+    <Box className={css.ModernBefore} shrink="No">
+      {before}
+    </Box>
+    <Box grow="Yes" direction="Column">
+      {children}
+    </Box>
+  </Box>
+));
diff --git a/src/app/components/message/layout/index.ts b/src/app/components/message/layout/index.ts
new file mode 100644 (file)
index 0000000..979f580
--- /dev/null
@@ -0,0 +1,4 @@
+export * from './Modern';
+export * from './Compact';
+export * from './Bubble';
+export * from './Base';
diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts
new file mode 100644 (file)
index 0000000..ff31baa
--- /dev/null
@@ -0,0 +1,155 @@
+import { createVar, keyframes, style, styleVariants } from '@vanilla-extract/css';
+import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
+import { DefaultReset, color, config, toRem } from 'folds';
+
+export const StickySection = style({
+  position: 'sticky',
+  top: config.space.S100,
+});
+
+const SpacingVar = createVar();
+const SpacingVariant = styleVariants({
+  '0': {
+    vars: {
+      [SpacingVar]: config.space.S0,
+    },
+  },
+  '100': {
+    vars: {
+      [SpacingVar]: config.space.S100,
+    },
+  },
+  '200': {
+    vars: {
+      [SpacingVar]: config.space.S200,
+    },
+  },
+  '300': {
+    vars: {
+      [SpacingVar]: config.space.S300,
+    },
+  },
+  '400': {
+    vars: {
+      [SpacingVar]: config.space.S400,
+    },
+  },
+  '500': {
+    vars: {
+      [SpacingVar]: config.space.S500,
+    },
+  },
+});
+
+const highlightAnime = keyframes({
+  '0%': {
+    backgroundColor: color.Primary.Container,
+  },
+  '25%': {
+    backgroundColor: color.Primary.ContainerActive,
+  },
+  '50%': {
+    backgroundColor: color.Primary.Container,
+  },
+  '75%': {
+    backgroundColor: color.Primary.ContainerActive,
+  },
+  '100%': {
+    backgroundColor: color.Primary.Container,
+  },
+});
+const HighlightVariant = styleVariants({
+  true: {
+    animation: `${highlightAnime} 2000ms ease-in-out`,
+  },
+});
+
+const SelectedVariant = styleVariants({
+  true: {
+    backgroundColor: color.Surface.ContainerActive,
+  },
+});
+
+const AutoCollapse = style({
+  selectors: {
+    [`&+&`]: {
+      marginTop: 0,
+    },
+  },
+});
+
+export const MessageBase = recipe({
+  base: [
+    DefaultReset,
+    {
+      marginTop: SpacingVar,
+      padding: `${config.space.S100} ${config.space.S200} ${config.space.S100} ${config.space.S400}`,
+      borderRadius: `0 ${config.radii.R400} ${config.radii.R400} 0`,
+    },
+  ],
+  variants: {
+    space: SpacingVariant,
+    collapse: {
+      true: {
+        marginTop: 0,
+      },
+    },
+    autoCollapse: {
+      true: AutoCollapse,
+    },
+    highlight: HighlightVariant,
+    selected: SelectedVariant,
+  },
+  defaultVariants: {
+    space: '400',
+  },
+});
+
+export type MessageBaseVariants = RecipeVariants<typeof MessageBase>;
+
+export const CompactHeader = style([
+  DefaultReset,
+  StickySection,
+  {
+    maxWidth: toRem(170),
+    width: '100%',
+  },
+]);
+
+export const AvatarBase = style({
+  paddingTop: toRem(4),
+  cursor: 'pointer',
+  transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
+
+  selectors: {
+    '&:hover': {
+      transform: `translateY(${toRem(-4)})`,
+    },
+  },
+});
+
+export const ModernBefore = style({
+  minWidth: toRem(36),
+});
+
+export const BubbleBefore = style([ModernBefore]);
+
+export const BubbleContent = style({
+  maxWidth: toRem(800),
+  padding: config.space.S200,
+  backgroundColor: color.SurfaceVariant.Container,
+  color: color.SurfaceVariant.OnContainer,
+  borderRadius: config.radii.R400,
+});
+
+export const Username = style({
+  cursor: 'pointer',
+  overflow: 'hidden',
+  whiteSpace: 'nowrap',
+  textOverflow: 'ellipsis',
+  selectors: {
+    '&:hover, &:focus-visible': {
+      textDecoration: 'underline',
+    },
+  },
+});
diff --git a/src/app/components/message/placeholder/CompactPlaceholder.tsx b/src/app/components/message/placeholder/CompactPlaceholder.tsx
new file mode 100644 (file)
index 0000000..a6be083
--- /dev/null
@@ -0,0 +1,22 @@
+import React from 'react';
+import { as, toRem } from 'folds';
+import { randomNumberBetween } from '../../../utils/common';
+import { LinePlaceholder } from './LinePlaceholder';
+import { CompactLayout, MessageBase } from '../layout';
+
+export const CompactPlaceholder = as<'div'>(({ ...props }, ref) => (
+  <MessageBase>
+    <CompactLayout
+      {...props}
+      ref={ref}
+      before={
+        <>
+          <LinePlaceholder style={{ maxWidth: toRem(50) }} />
+          <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(40, 100)) }} />
+        </>
+      }
+    >
+      <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(120, 500)) }} />
+    </CompactLayout>
+  </MessageBase>
+));
diff --git a/src/app/components/message/placeholder/DefaultPlaceholder.tsx b/src/app/components/message/placeholder/DefaultPlaceholder.tsx
new file mode 100644 (file)
index 0000000..5f0b57f
--- /dev/null
@@ -0,0 +1,25 @@
+import React, { CSSProperties } from 'react';
+import { Avatar, Box, as, color, toRem } from 'folds';
+import { randomNumberBetween } from '../../../utils/common';
+import { LinePlaceholder } from './LinePlaceholder';
+import { MessageBase, ModernLayout } from '../layout';
+
+const contentMargin: CSSProperties = { marginTop: toRem(3) };
+const avatarBg: CSSProperties = { backgroundColor: color.SurfaceVariant.Container };
+
+export const DefaultPlaceholder = as<'div'>(({ ...props }, ref) => (
+  <MessageBase>
+    <ModernLayout {...props} ref={ref} before={<Avatar style={avatarBg} size="300" />}>
+      <Box style={contentMargin} grow="Yes" direction="Column" gap="200">
+        <Box grow="Yes" gap="200" alignItems="Center" justifyContent="SpaceBetween">
+          <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(40, 100)) }} />
+          <LinePlaceholder style={{ maxWidth: toRem(50) }} />
+        </Box>
+        <Box grow="Yes" gap="200" wrap="Wrap">
+          <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(80, 200)) }} />
+          <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(80, 200)) }} />
+        </Box>
+      </Box>
+    </ModernLayout>
+  </MessageBase>
+));
diff --git a/src/app/components/message/placeholder/LinePlaceholder.css.ts b/src/app/components/message/placeholder/LinePlaceholder.css.ts
new file mode 100644 (file)
index 0000000..0baedf6
--- /dev/null
@@ -0,0 +1,12 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config, toRem } from 'folds';
+
+export const LinePlaceholder = style([
+  DefaultReset,
+  {
+    width: '100%',
+    height: toRem(16),
+    borderRadius: config.radii.R300,
+    backgroundColor: color.SurfaceVariant.Container,
+  },
+]);
diff --git a/src/app/components/message/placeholder/LinePlaceholder.tsx b/src/app/components/message/placeholder/LinePlaceholder.tsx
new file mode 100644 (file)
index 0000000..a5e7bd7
--- /dev/null
@@ -0,0 +1,8 @@
+import React from 'react';
+import { Box, as } from 'folds';
+import classNames from 'classnames';
+import * as css from './LinePlaceholder.css';
+
+export const LinePlaceholder = as<'div'>(({ className, ...props }, ref) => (
+  <Box className={classNames(css.LinePlaceholder, className)} shrink="No" {...props} ref={ref} />
+));
diff --git a/src/app/components/message/placeholder/index.ts b/src/app/components/message/placeholder/index.ts
new file mode 100644 (file)
index 0000000..8a3a222
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './LinePlaceholder';
+export * from './CompactPlaceholder';
+export * from './DefaultPlaceholder';
diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx
new file mode 100644 (file)
index 0000000..863c7cf
--- /dev/null
@@ -0,0 +1,114 @@
+import React, { useCallback } from 'react';
+import { Avatar, AvatarFallback, AvatarImage, Box, Button, Spinner, Text, as, color } from 'folds';
+import { Room } from 'matrix-js-sdk';
+import { openInviteUser, selectRoom } from '../../../client/action/navigation';
+import { useStateEvent } from '../../hooks/useStateEvent';
+import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
+import { getMemberDisplayName, getStateEvent } from '../../utils/room';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
+
+export type RoomIntroProps = {
+  room: Room;
+};
+
+export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
+  const mx = useMatrixClient();
+  const createEvent = getStateEvent(room, StateEvent.RoomCreate);
+  const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
+  const nameEvent = useStateEvent(room, StateEvent.RoomName);
+  const topicEvent = useStateEvent(room, StateEvent.RoomTopic);
+  const createContent = createEvent?.getContent<IRoomCreateContent>();
+
+  const ts = createEvent?.getTs();
+  const creatorId = createEvent?.getSender();
+  const creatorName =
+    creatorId && (getMemberDisplayName(room, creatorId) ?? getMxIdLocalPart(creatorId));
+  const prevRoomId = createContent?.predecessor?.room_id;
+  const avatarMxc = (avatarEvent?.getContent().url as string) || undefined;
+  const avatarHttpUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc) : undefined;
+  const name = (nameEvent?.getContent().name || room.name) as string;
+  const topic = (topicEvent?.getContent().topic as string) || undefined;
+
+  const [prevRoomState, joinPrevRoom] = useAsyncCallback(
+    useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
+  );
+
+  return (
+    <Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
+      <Box>
+        <Avatar size="500">
+          {avatarHttpUrl ? (
+            <AvatarImage src={avatarHttpUrl} alt={name} />
+          ) : (
+            <AvatarFallback
+              style={{
+                backgroundColor: color.SurfaceVariant.Container,
+                color: color.SurfaceVariant.OnContainer,
+              }}
+            >
+              <Text size="H2">{name[0]}</Text>
+            </AvatarFallback>
+          )}
+        </Avatar>
+      </Box>
+      <Box direction="Column" gap="300">
+        <Box direction="Column" gap="100">
+          <Text size="H3" priority="500">
+            {name}
+          </Text>
+          <Text size="T400" priority="400">
+            {typeof topic === 'string' ? topic : 'This is the beginning of conversation.'}
+          </Text>
+          {creatorName && ts && (
+            <Text size="T200" priority="300">
+              {'Created by '}
+              <b>@{creatorName}</b>
+              {` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts)}`}
+            </Text>
+          )}
+        </Box>
+        <Box gap="200" wrap="Wrap">
+          <Button
+            onClick={() => openInviteUser(room.roomId)}
+            variant="Secondary"
+            size="300"
+            radii="300"
+          >
+            <Text size="B300">Invite Member</Text>
+          </Button>
+          {typeof prevRoomId === 'string' &&
+            (mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
+              <Button
+                onClick={() => selectRoom(prevRoomId)}
+                variant="Success"
+                size="300"
+                fill="Soft"
+                radii="300"
+              >
+                <Text size="B300">Open Old Room</Text>
+              </Button>
+            ) : (
+              <Button
+                onClick={() => joinPrevRoom(prevRoomId)}
+                variant="Secondary"
+                size="300"
+                fill="Soft"
+                radii="300"
+                disabled={prevRoomState.status === AsyncStatus.Loading}
+                after={
+                  prevRoomState.status === AsyncStatus.Loading ? (
+                    <Spinner size="50" variant="Secondary" fill="Soft" />
+                  ) : undefined
+                }
+              >
+                <Text size="B300">Join Old Room</Text>
+              </Button>
+            ))}
+        </Box>
+      </Box>
+    </Box>
+  );
+});
diff --git a/src/app/components/room-intro/index.ts b/src/app/components/room-intro/index.ts
new file mode 100644 (file)
index 0000000..7250c78
--- /dev/null
@@ -0,0 +1 @@
+export * from './RoomIntro';
diff --git a/src/app/components/text-viewer/TextViewer.css.ts b/src/app/components/text-viewer/TextViewer.css.ts
new file mode 100644 (file)
index 0000000..1ec5647
--- /dev/null
@@ -0,0 +1,37 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
+
+export const TextViewer = style([
+  DefaultReset,
+  {
+    height: '100%',
+  },
+]);
+
+export const TextViewerHeader = style([
+  DefaultReset,
+  {
+    paddingLeft: config.space.S200,
+    paddingRight: config.space.S200,
+    borderBottomWidth: config.borderWidth.B300,
+    flexShrink: 0,
+    gap: config.space.S200,
+  },
+]);
+
+export const TextViewerContent = style([
+  DefaultReset,
+  {
+    backgroundColor: color.Background.Container,
+    color: color.Background.OnContainer,
+    overflow: 'hidden',
+  },
+]);
+
+export const TextViewerPre = style([
+  DefaultReset,
+  {
+    padding: config.space.S600,
+    whiteSpace: 'pre-wrap',
+  },
+]);
diff --git a/src/app/components/text-viewer/TextViewer.tsx b/src/app/components/text-viewer/TextViewer.tsx
new file mode 100644 (file)
index 0000000..37642db
--- /dev/null
@@ -0,0 +1,69 @@
+/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
+import React, { Suspense, lazy } from 'react';
+import classNames from 'classnames';
+import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'folds';
+import { ErrorBoundary } from 'react-error-boundary';
+import * as css from './TextViewer.css';
+import { mimeTypeToExt } from '../../utils/mimeTypes';
+import { copyToClipboard } from '../../utils/dom';
+
+const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
+
+export type TextViewerProps = {
+  name: string;
+  text: string;
+  mimeType: string;
+  requestClose: () => void;
+};
+
+export const TextViewer = as<'div', TextViewerProps>(
+  ({ className, name, text, mimeType, requestClose, ...props }, ref) => {
+    const handleCopy = () => {
+      copyToClipboard(text);
+    };
+
+    return (
+      <Box
+        className={classNames(css.TextViewer, className)}
+        direction="Column"
+        {...props}
+        ref={ref}
+      >
+        <Header className={css.TextViewerHeader} size="400">
+          <Box grow="Yes" alignItems="Center" gap="200">
+            <IconButton size="300" radii="300" onClick={requestClose}>
+              <Icon size="50" src={Icons.ArrowLeft} />
+            </IconButton>
+            <Text size="T300" truncate>
+              {name}
+            </Text>
+          </Box>
+          <Box shrink="No" alignItems="Center" gap="200">
+            <Chip variant="Primary" radii="300" onClick={handleCopy}>
+              <Text size="B300">Copy All</Text>
+            </Chip>
+          </Box>
+        </Header>
+        <Box
+          grow="Yes"
+          className={css.TextViewerContent}
+          justifyContent="Center"
+          alignItems="Center"
+        >
+          <Scroll hideTrack variant="Background" visibility="Hover">
+            <Text
+              as="pre"
+              className={classNames(css.TextViewerPre, `language-${mimeTypeToExt(mimeType)}`)}
+            >
+              <ErrorBoundary fallback={<code>{text}</code>}>
+                <Suspense fallback={<code>{text}</code>}>
+                  <ReactPrism>{(codeRef) => <code ref={codeRef}>{text}</code>}</ReactPrism>
+                </Suspense>
+              </ErrorBoundary>
+            </Text>
+          </Scroll>
+        </Box>
+      </Box>
+    );
+  }
+);
diff --git a/src/app/components/text-viewer/index.ts b/src/app/components/text-viewer/index.ts
new file mode 100644 (file)
index 0000000..4cf8e28
--- /dev/null
@@ -0,0 +1 @@
+export * from './TextViewer';
diff --git a/src/app/components/typing-indicator/TypingIndicator.css.ts b/src/app/components/typing-indicator/TypingIndicator.css.ts
new file mode 100644 (file)
index 0000000..63a8556
--- /dev/null
@@ -0,0 +1,49 @@
+import { keyframes } from '@vanilla-extract/css';
+import { recipe } from '@vanilla-extract/recipes';
+import { DefaultReset, toRem } from 'folds';
+
+const TypingDotAnime = keyframes({
+  to: {
+    opacity: '0.4',
+    transform: 'translateY(-15%)',
+  },
+});
+
+export const TypingDot = recipe({
+  base: [
+    DefaultReset,
+    {
+      display: 'inline-block',
+      backgroundColor: 'currentColor',
+      borderRadius: '50%',
+      transform: 'translateY(15%)',
+      animation: `${TypingDotAnime} 0.6s infinite alternate`,
+    },
+  ],
+  variants: {
+    size: {
+      '300': {
+        width: toRem(4),
+        height: toRem(4),
+      },
+      '400': {
+        width: toRem(8),
+        height: toRem(8),
+      },
+    },
+    index: {
+      '0': {
+        animationDelay: '0s',
+      },
+      '1': {
+        animationDelay: '0.2s',
+      },
+      '2': {
+        animationDelay: '0.4s',
+      },
+    },
+  },
+  defaultVariants: {
+    size: '400',
+  },
+});
diff --git a/src/app/components/typing-indicator/TypingIndicator.tsx b/src/app/components/typing-indicator/TypingIndicator.tsx
new file mode 100644 (file)
index 0000000..3369035
--- /dev/null
@@ -0,0 +1,21 @@
+import React from 'react';
+import { Box, as, toRem } from 'folds';
+import * as css from './TypingIndicator.css';
+
+export type TypingIndicatorProps = {
+  size?: '300' | '400';
+};
+
+export const TypingIndicator = as<'div', TypingIndicatorProps>(({ size, style, ...props }, ref) => (
+  <Box
+    as="span"
+    alignItems="Center"
+    style={{ gap: toRem(size === '300' ? 1 : 2), ...style }}
+    {...props}
+    ref={ref}
+  >
+    <span className={css.TypingDot({ size, index: '0' })} />
+    <span className={css.TypingDot({ size, index: '1' })} />
+    <span className={css.TypingDot({ size, index: '2' })} />
+  </Box>
+));
diff --git a/src/app/components/typing-indicator/index.ts b/src/app/components/typing-indicator/index.ts
new file mode 100644 (file)
index 0000000..07efe0c
--- /dev/null
@@ -0,0 +1 @@
+export * from './TypingIndicator';
diff --git a/src/app/hooks/media/index.ts b/src/app/hooks/media/index.ts
new file mode 100644 (file)
index 0000000..7c202f9
--- /dev/null
@@ -0,0 +1,6 @@
+export * from './useMediaPlay';
+export * from './useMediaPlayTimeCallback';
+export * from './useMediaPlaybackRate';
+export * from './useMediaSeek';
+export * from './useMediaVolume';
+export * from './useMediaLoading';
diff --git a/src/app/hooks/media/useMediaLoading.ts b/src/app/hooks/media/useMediaLoading.ts
new file mode 100644 (file)
index 0000000..9d73201
--- /dev/null
@@ -0,0 +1,51 @@
+import { useEffect, useState } from 'react';
+
+export type MediaLoadingData = {
+  loading: boolean;
+  error: boolean;
+};
+
+export const useMediaLoading = (
+  getTargetElement: () => HTMLMediaElement | null
+): MediaLoadingData => {
+  const [loadingData, setLoadingData] = useState<MediaLoadingData>({
+    loading: false,
+    error: false,
+  });
+
+  useEffect(() => {
+    const targetEl = getTargetElement();
+    const handleStart = () => {
+      setLoadingData({
+        loading: true,
+        error: false,
+      });
+    };
+    const handleStop = () => {
+      setLoadingData({
+        loading: false,
+        error: false,
+      });
+    };
+    const handleError = () => {
+      setLoadingData({
+        loading: false,
+        error: true,
+      });
+    };
+    targetEl?.addEventListener('loadstart', handleStart);
+    targetEl?.addEventListener('loadeddata', handleStop);
+    targetEl?.addEventListener('stalled', handleStop);
+    targetEl?.addEventListener('suspend', handleStop);
+    targetEl?.addEventListener('error', handleError);
+    return () => {
+      targetEl?.removeEventListener('loadstart', handleStart);
+      targetEl?.removeEventListener('loadeddata', handleStop);
+      targetEl?.removeEventListener('stalled', handleStop);
+      targetEl?.removeEventListener('suspend', handleStop);
+      targetEl?.removeEventListener('error', handleError);
+    };
+  }, [getTargetElement]);
+
+  return loadingData;
+};
diff --git a/src/app/hooks/media/useMediaPlay.ts b/src/app/hooks/media/useMediaPlay.ts
new file mode 100644 (file)
index 0000000..e175eee
--- /dev/null
@@ -0,0 +1,46 @@
+import { useCallback, useEffect, useState } from 'react';
+
+export type MediaPlayData = {
+  playing: boolean;
+};
+
+export type MediaPlayControl = {
+  setPlaying: (play: boolean) => void;
+};
+
+export const useMediaPlay = (
+  getTargetElement: () => HTMLMediaElement | null
+): MediaPlayData & MediaPlayControl => {
+  const [playing, setPlay] = useState(false);
+
+  const setPlaying = useCallback(
+    (play: boolean) => {
+      const targetEl = getTargetElement();
+      if (!targetEl) return;
+      if (play) targetEl.play();
+      else targetEl.pause();
+    },
+    [getTargetElement]
+  );
+
+  useEffect(() => {
+    const targetEl = getTargetElement();
+    const handleChange = () => {
+      if (!targetEl) return;
+      setPlay(targetEl.paused === false);
+    };
+    targetEl?.addEventListener('playing', handleChange);
+    targetEl?.addEventListener('play', handleChange);
+    targetEl?.addEventListener('pause', handleChange);
+    return () => {
+      targetEl?.removeEventListener('playing', handleChange);
+      targetEl?.removeEventListener('play', handleChange);
+      targetEl?.removeEventListener('pause', handleChange);
+    };
+  }, [getTargetElement]);
+
+  return {
+    playing,
+    setPlaying,
+  };
+};
diff --git a/src/app/hooks/media/useMediaPlayTimeCallback.ts b/src/app/hooks/media/useMediaPlayTimeCallback.ts
new file mode 100644 (file)
index 0000000..c70ddc6
--- /dev/null
@@ -0,0 +1,24 @@
+import { useEffect } from 'react';
+
+export type PlayTimeCallback = (duration: number, currentTime: number) => void;
+
+export const useMediaPlayTimeCallback = (
+  getTargetElement: () => HTMLMediaElement | null,
+  onPlayTimeCallback: PlayTimeCallback
+): void => {
+  useEffect(() => {
+    const targetEl = getTargetElement();
+    const handleChange = () => {
+      if (!targetEl) return;
+      onPlayTimeCallback(targetEl.duration, targetEl.currentTime);
+    };
+    targetEl?.addEventListener('timeupdate', handleChange);
+    targetEl?.addEventListener('loadedmetadata', handleChange);
+    targetEl?.addEventListener('ended', handleChange);
+    return () => {
+      targetEl?.removeEventListener('timeupdate', handleChange);
+      targetEl?.removeEventListener('loadedmetadata', handleChange);
+      targetEl?.removeEventListener('ended', handleChange);
+    };
+  }, [getTargetElement, onPlayTimeCallback]);
+};
diff --git a/src/app/hooks/media/useMediaPlaybackRate.ts b/src/app/hooks/media/useMediaPlaybackRate.ts
new file mode 100644 (file)
index 0000000..1736884
--- /dev/null
@@ -0,0 +1,40 @@
+import { useCallback, useEffect, useState } from 'react';
+
+export type MediaPlaybackRateData = {
+  playbackRate: number;
+};
+export type MediaPlaybackRateControl = {
+  setPlaybackRate: (rate: number) => void;
+};
+
+export const useMediaPlaybackRate = (
+  getTargetElement: () => HTMLMediaElement | null
+): MediaPlaybackRateData & MediaPlaybackRateControl => {
+  const [rate, setRate] = useState(1.0);
+
+  const setPlaybackRate = useCallback(
+    (playbackRate: number) => {
+      const targetEl = getTargetElement();
+      if (!targetEl) return;
+      targetEl.playbackRate = playbackRate;
+    },
+    [getTargetElement]
+  );
+
+  useEffect(() => {
+    const targetEl = getTargetElement();
+    const handleChange = () => {
+      if (!targetEl) return;
+      setRate(targetEl.playbackRate);
+    };
+    targetEl?.addEventListener('ratechange', handleChange);
+    return () => {
+      targetEl?.removeEventListener('ratechange', handleChange);
+    };
+  }, [getTargetElement]);
+
+  return {
+    playbackRate: rate,
+    setPlaybackRate,
+  };
+};
diff --git a/src/app/hooks/media/useMediaSeek.ts b/src/app/hooks/media/useMediaSeek.ts
new file mode 100644 (file)
index 0000000..89c8d07
--- /dev/null
@@ -0,0 +1,51 @@
+import { useCallback, useEffect, useState } from 'react';
+
+export type MediaSeekData = {
+  seeking: boolean;
+  seekable?: TimeRanges;
+};
+export type MediaSeekControl = {
+  seek: (time: number) => void;
+};
+
+export const useMediaSeek = (
+  getTargetElement: () => HTMLMediaElement | null
+): MediaSeekData & MediaSeekControl => {
+  const [seekData, setSeekData] = useState<MediaSeekData>({
+    seeking: false,
+    seekable: undefined,
+  });
+
+  const seek = useCallback(
+    (time: number) => {
+      const targetEl = getTargetElement();
+      if (!targetEl) return;
+      targetEl.currentTime = time;
+    },
+    [getTargetElement]
+  );
+
+  useEffect(() => {
+    const targetEl = getTargetElement();
+    const handleChange = () => {
+      if (!targetEl) return;
+      setSeekData({
+        seeking: targetEl.seeking,
+        seekable: targetEl.seekable,
+      });
+    };
+    targetEl?.addEventListener('loadedmetadata', handleChange);
+    targetEl?.addEventListener('seeked', handleChange);
+    targetEl?.addEventListener('seeking', handleChange);
+    return () => {
+      targetEl?.removeEventListener('loadedmetadata', handleChange);
+      targetEl?.removeEventListener('seeked', handleChange);
+      targetEl?.removeEventListener('seeking', handleChange);
+    };
+  }, [getTargetElement]);
+
+  return {
+    ...seekData,
+    seek,
+  };
+};
diff --git a/src/app/hooks/media/useMediaVolume.ts b/src/app/hooks/media/useMediaVolume.ts
new file mode 100644 (file)
index 0000000..605cee4
--- /dev/null
@@ -0,0 +1,60 @@
+import { useCallback, useEffect, useState } from 'react';
+
+export type MediaVolumeData = {
+  volume: number;
+  mute: boolean;
+};
+
+export type MediaVolumeControl = {
+  setMute: (mute: boolean) => void;
+  setVolume: (volume: number) => void;
+};
+
+export const useMediaVolume = (
+  getTargetElement: () => HTMLMediaElement | null
+): MediaVolumeData & MediaVolumeControl => {
+  const [volumeData, setVolumeData] = useState<MediaVolumeData>({
+    volume: 1,
+    mute: false,
+  });
+
+  const setMute = useCallback(
+    (mute: boolean) => {
+      const targetEl = getTargetElement();
+      if (!targetEl) return;
+      targetEl.muted = mute;
+    },
+    [getTargetElement]
+  );
+
+  const setVolume = useCallback(
+    (volume: number) => {
+      const targetEl = getTargetElement();
+      if (!targetEl) return;
+      targetEl.volume = volume;
+    },
+    [getTargetElement]
+  );
+
+  useEffect(() => {
+    const targetEl = getTargetElement();
+    const handleChange = () => {
+      if (!targetEl) return;
+
+      setVolumeData({
+        mute: targetEl.muted,
+        volume: Math.max(0, Math.min(targetEl.volume, 1)),
+      });
+    };
+    targetEl?.addEventListener('volumechange', handleChange);
+    return () => {
+      targetEl?.removeEventListener('volumechange', handleChange);
+    };
+  }, [getTargetElement]);
+
+  return {
+    ...volumeData,
+    setMute,
+    setVolume,
+  };
+};
index 754007aed2180d1471ef4af6661db1a3535b6b2a..9b0b50e843c4329450f9d6803a034161704cbe05 100644 (file)
@@ -25,6 +25,8 @@ export const useIntersectionObserver = (
     setIntersectionObserver(new IntersectionObserver(onIntersectionCallback, initOpts));
   }, [onIntersectionCallback, opts]);
 
+  useEffect(() => () => intersectionObserver?.disconnect(), [intersectionObserver]);
+
   useEffect(() => {
     const element = typeof observeElement === 'function' ? observeElement() : observeElement;
     if (element) intersectionObserver?.observe(element);
diff --git a/src/app/hooks/useMatrixEventRenderer.ts b/src/app/hooks/useMatrixEventRenderer.ts
new file mode 100644 (file)
index 0000000..9ace6b6
--- /dev/null
@@ -0,0 +1,80 @@
+import { ReactNode } from 'react';
+import { MatrixEvent } from 'matrix-js-sdk';
+import { MessageEvent, StateEvent } from '../../types/matrix/room';
+
+export type EventRenderer<T extends unknown[]> = (
+  eventId: string,
+  mEvent: MatrixEvent,
+  ...args: T
+) => ReactNode;
+
+export type EventRendererOpts<T extends unknown[]> = {
+  renderRoomMessage?: EventRenderer<T>;
+  renderRoomEncrypted?: EventRenderer<T>;
+  renderSticker?: EventRenderer<T>;
+  renderRoomMember?: EventRenderer<T>;
+  renderRoomName?: EventRenderer<T>;
+  renderRoomTopic?: EventRenderer<T>;
+  renderRoomAvatar?: EventRenderer<T>;
+  renderStateEvent?: EventRenderer<T>;
+  renderEvent?: EventRenderer<T>;
+};
+
+export type RenderMatrixEvent<T extends unknown[]> = (
+  eventId: string,
+  mEvent: MatrixEvent,
+  ...args: T
+) => ReactNode;
+
+export const useMatrixEventRenderer =
+  <T extends unknown[]>({
+    renderRoomMessage,
+    renderRoomEncrypted,
+    renderSticker,
+    renderRoomMember,
+    renderRoomName,
+    renderRoomTopic,
+    renderRoomAvatar,
+    renderStateEvent,
+    renderEvent,
+  }: EventRendererOpts<T>): RenderMatrixEvent<T> =>
+  (eventId, mEvent, ...args) => {
+    const eventType = mEvent.getWireType();
+
+    if (eventType === MessageEvent.RoomMessage && renderRoomMessage) {
+      return renderRoomMessage(eventId, mEvent, ...args);
+    }
+
+    if (eventType === MessageEvent.RoomMessageEncrypted && renderRoomEncrypted) {
+      return renderRoomEncrypted(eventId, mEvent, ...args);
+    }
+
+    if (eventType === MessageEvent.Sticker && renderSticker) {
+      return renderSticker(eventId, mEvent, ...args);
+    }
+
+    if (eventType === StateEvent.RoomMember && renderRoomMember) {
+      return renderRoomMember(eventId, mEvent, ...args);
+    }
+
+    if (eventType === StateEvent.RoomName && renderRoomName) {
+      return renderRoomName(eventId, mEvent, ...args);
+    }
+
+    if (eventType === StateEvent.RoomTopic && renderRoomTopic) {
+      return renderRoomTopic(eventId, mEvent, ...args);
+    }
+
+    if (eventType === StateEvent.RoomAvatar && renderRoomAvatar) {
+      return renderRoomAvatar(eventId, mEvent, ...args);
+    }
+
+    if (typeof mEvent.getStateKey() === 'string' && renderStateEvent) {
+      return renderStateEvent(eventId, mEvent, ...args);
+    }
+
+    if (typeof mEvent.getStateKey() !== 'string' && renderEvent) {
+      return renderEvent(eventId, mEvent, ...args);
+    }
+    return null;
+  };
diff --git a/src/app/hooks/useMemberEventParser.tsx b/src/app/hooks/useMemberEventParser.tsx
new file mode 100644 (file)
index 0000000..ecb6f66
--- /dev/null
@@ -0,0 +1,218 @@
+import React, { ReactNode } from 'react';
+import { IconSrc, Icons } from 'folds';
+import { MatrixEvent } from 'matrix-js-sdk';
+import { IMemberContent, Membership } from '../../types/matrix/room';
+import { getMxIdLocalPart } from '../utils/matrix';
+
+export type ParsedResult = {
+  icon: IconSrc;
+  body: ReactNode;
+};
+
+export type MemberEventParser = (mEvent: MatrixEvent) => ParsedResult;
+
+export const useMemberEventParser = (): MemberEventParser => {
+  const parseMemberEvent: MemberEventParser = (mEvent) => {
+    const content = mEvent.getContent<IMemberContent>();
+    const prevContent = mEvent.getPrevContent() as IMemberContent;
+    const senderId = mEvent.getSender();
+    const userId = mEvent.getStateKey();
+
+    if (!senderId || !userId)
+      return {
+        icon: Icons.User,
+        body: 'Broken membership event',
+      };
+
+    const senderName = getMxIdLocalPart(senderId);
+    const userName = content.displayname || getMxIdLocalPart(userId);
+
+    if (content.membership !== prevContent.membership) {
+      if (content.membership === Membership.Invite) {
+        if (prevContent.membership === Membership.Knock) {
+          return {
+            icon: Icons.ArrowGoRightPlus,
+            body: (
+              <>
+                <b>{senderName}</b>
+                {' accepted '}
+                <b>{userName}</b>
+                {`'s join request `}
+                {content.reason}
+              </>
+            ),
+          };
+        }
+
+        return {
+          icon: Icons.ArrowGoRightPlus,
+          body: (
+            <>
+              <b>{senderName}</b>
+              {' invited '}
+              <b>{userName}</b> {content.reason}
+            </>
+          ),
+        };
+      }
+
+      if (content.membership === Membership.Knock) {
+        return {
+          icon: Icons.ArrowGoRightPlus,
+          body: (
+            <>
+              <b>{userName}</b>
+              {' request to join room '}
+              {content.reason}
+            </>
+          ),
+        };
+      }
+
+      if (content.membership === Membership.Join) {
+        return {
+          icon: Icons.ArrowGoRight,
+          body: (
+            <>
+              <b>{userName}</b>
+              {' joined the room'}
+            </>
+          ),
+        };
+      }
+
+      if (content.membership === Membership.Leave) {
+        if (prevContent.membership === Membership.Invite) {
+          return {
+            icon: Icons.ArrowGoRightCross,
+            body:
+              senderId === userId ? (
+                <>
+                  <b>{userName}</b>
+                  {' reject the invitation '}
+                  {content.reason}
+                </>
+              ) : (
+                <>
+                  <b>{senderName}</b>
+                  {' reject '}
+                  <b>{userName}</b>
+                  {`'s join request `}
+                  {content.reason}
+                </>
+              ),
+          };
+        }
+
+        if (prevContent.membership === Membership.Knock) {
+          return {
+            icon: Icons.ArrowGoRightCross,
+            body:
+              senderId === userId ? (
+                <>
+                  <b>{userName}</b>
+                  {' revoked joined request '}
+                  {content.reason}
+                </>
+              ) : (
+                <>
+                  <b>{senderName}</b>
+                  {' revoked '}
+                  <b>{userName}</b>
+                  {`'s invite `}
+                  {content.reason}
+                </>
+              ),
+          };
+        }
+
+        if (prevContent.membership === Membership.Ban) {
+          return {
+            icon: Icons.ArrowGoLeft,
+            body: (
+              <>
+                <b>{senderName}</b>
+                {' unbanned '}
+                <b>{userName}</b> {content.reason}
+              </>
+            ),
+          };
+        }
+
+        return {
+          icon: Icons.ArrowGoLeft,
+          body:
+            senderId === userId ? (
+              <>
+                <b>{userName}</b>
+                {' left the room '}
+                {content.reason}
+              </>
+            ) : (
+              <>
+                <b>{senderName}</b>
+                {' kicked '}
+                <b>{userName}</b> {content.reason}
+              </>
+            ),
+        };
+      }
+
+      if (content.membership === Membership.Ban) {
+        return {
+          icon: Icons.ArrowGoLeft,
+          body: (
+            <>
+              <b>{senderName}</b>
+              {' banned '}
+              <b>{userName}</b> {content.reason}
+            </>
+          ),
+        };
+      }
+    }
+
+    if (content.displayname !== prevContent.displayname) {
+      const prevUserName = prevContent.displayname || userId;
+
+      return {
+        icon: Icons.Mention,
+        body: content.displayname ? (
+          <>
+            <b>{prevUserName}</b>
+            {' changed display name to '}
+            <b>{userName}</b>
+          </>
+        ) : (
+          <>
+            <b>{prevUserName}</b>
+            {' removed their display name '}
+          </>
+        ),
+      };
+    }
+    if (content.avatar_url !== prevContent.avatar_url) {
+      return {
+        icon: Icons.User,
+        body: content.displayname ? (
+          <>
+            <b>{userName}</b>
+            {' changed their avatar'}
+          </>
+        ) : (
+          <>
+            <b>{userName}</b>
+            {' removed their avatar '}
+          </>
+        ),
+      };
+    }
+
+    return {
+      icon: Icons.User,
+      body: 'Broken membership event',
+    };
+  };
+
+  return parseMemberEvent;
+};
diff --git a/src/app/hooks/usePan.ts b/src/app/hooks/usePan.ts
new file mode 100644 (file)
index 0000000..60d7954
--- /dev/null
@@ -0,0 +1,62 @@
+import { MouseEventHandler, useEffect, useState } from 'react';
+
+export type Pan = {
+  translateX: number;
+  translateY: number;
+};
+
+const INITIAL_PAN = {
+  translateX: 0,
+  translateY: 0,
+};
+
+export const usePan = (active: boolean) => {
+  const [pan, setPan] = useState<Pan>(INITIAL_PAN);
+  const [cursor, setCursor] = useState<'grab' | 'grabbing' | 'initial'>(
+    active ? 'grab' : 'initial'
+  );
+
+  useEffect(() => {
+    setCursor(active ? 'grab' : 'initial');
+  }, [active]);
+
+  const handleMouseMove = (evt: MouseEvent) => {
+    evt.preventDefault();
+    evt.stopPropagation();
+
+    setPan((p) => {
+      const { translateX, translateY } = p;
+      const mX = translateX + evt.movementX;
+      const mY = translateY + evt.movementY;
+
+      return { translateX: mX, translateY: mY };
+    });
+  };
+
+  const handleMouseUp = (evt: MouseEvent) => {
+    evt.preventDefault();
+    setCursor('grab');
+
+    document.removeEventListener('mousemove', handleMouseMove);
+    document.removeEventListener('mouseup', handleMouseUp);
+  };
+
+  const handleMouseDown: MouseEventHandler<HTMLElement> = (evt) => {
+    if (!active) return;
+    evt.preventDefault();
+    setCursor('grabbing');
+
+    document.addEventListener('mousemove', handleMouseMove);
+    document.addEventListener('mouseup', handleMouseUp);
+  };
+
+  useEffect(() => {
+    if (!active) setPan(INITIAL_PAN);
+  }, [active]);
+
+  return {
+    pan,
+    cursor,
+    onMouseDown: handleMouseDown,
+  };
+};
index 3ce75a2f238333e7f3422090ac509d679d146f8a..98e3701edef386e6a86cf677b9ba3a5c208d3060 100644 (file)
@@ -1,8 +1,10 @@
 import { Room } from 'matrix-js-sdk';
-import { useCallback } from 'react';
+import { createContext, useCallback, useContext } from 'react';
 import { useStateEvent } from './useStateEvent';
 import { StateEvent } from '../../types/matrix/room';
 
+export type PowerLevelActions = 'invite' | 'redact' | 'kick' | 'ban' | 'historical';
+
 enum DefaultPowerLevels {
   usersDefault = 0,
   stateDefault = 50,
@@ -29,12 +31,23 @@ interface IPowerLevels {
   notifications?: Record<string, number>;
 }
 
-export function usePowerLevels(room: Room) {
+export type GetPowerLevel = (userId: string) => number;
+export type CanSend = (eventType: string | undefined, powerLevel: number) => boolean;
+export type CanDoAction = (action: PowerLevelActions, powerLevel: number) => boolean;
+
+export type PowerLevelsAPI = {
+  getPowerLevel: GetPowerLevel;
+  canSendEvent: CanSend;
+  canSendStateEvent: CanSend;
+  canDoAction: CanDoAction;
+};
+
+export function usePowerLevels(room: Room): PowerLevelsAPI {
   const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels);
   const powerLevels: IPowerLevels = powerLevelsEvent?.getContent() ?? DefaultPowerLevels;
 
-  const getPowerLevel = useCallback(
-    (userId: string) => {
+  const getPowerLevel: GetPowerLevel = useCallback(
+    (userId) => {
       const { users_default: usersDefault, users } = powerLevels;
       if (users && typeof users[userId] === 'number') {
         return users[userId];
@@ -44,8 +57,8 @@ export function usePowerLevels(room: Room) {
     [powerLevels]
   );
 
-  const canSendEvent = useCallback(
-    (eventType: string | undefined, powerLevel: number) => {
+  const canSendEvent: CanSend = useCallback(
+    (eventType, powerLevel) => {
       const { events, events_default: eventsDefault } = powerLevels;
       if (events && eventType && typeof events[eventType] === 'number') {
         return powerLevel >= events[eventType];
@@ -55,8 +68,8 @@ export function usePowerLevels(room: Room) {
     [powerLevels]
   );
 
-  const canSendStateEvent = useCallback(
-    (eventType: string | undefined, powerLevel: number) => {
+  const canSendStateEvent: CanSend = useCallback(
+    (eventType, powerLevel) => {
       const { events, state_default: stateDefault } = powerLevels;
       if (events && eventType && typeof events[eventType] === 'number') {
         return powerLevel >= events[eventType];
@@ -66,8 +79,8 @@ export function usePowerLevels(room: Room) {
     [powerLevels]
   );
 
-  const canDoAction = useCallback(
-    (action: 'invite' | 'redact' | 'kick' | 'ban' | 'historical', powerLevel: number) => {
+  const canDoAction: CanDoAction = useCallback(
+    (action, powerLevel) => {
       const requiredPL = powerLevels[action];
       if (typeof requiredPL === 'number') {
         return powerLevel >= requiredPL;
@@ -84,3 +97,13 @@ export function usePowerLevels(room: Room) {
     canDoAction,
   };
 }
+
+export const PowerLevelsContext = createContext<PowerLevelsAPI | null>(null);
+
+export const PowerLevelsContextProvider = PowerLevelsContext.Provider;
+
+export const usePowerLevelsAPI = (): PowerLevelsAPI => {
+  const api = useContext(PowerLevelsContext);
+  if (!api) throw new Error('PowerLevelContext is not initialized!');
+  return api;
+};
diff --git a/src/app/hooks/useRelations.ts b/src/app/hooks/useRelations.ts
new file mode 100644 (file)
index 0000000..51978de
--- /dev/null
@@ -0,0 +1,25 @@
+import { useEffect, useState } from 'react';
+import { RelationsEvent, type Relations } from 'matrix-js-sdk/lib/models/relations';
+
+export const useRelations = <T>(
+  relations: Relations,
+  getRelations: (relations: Relations) => T
+) => {
+  const [data, setData] = useState(() => getRelations(relations));
+
+  useEffect(() => {
+    const handleUpdate = () => {
+      setData(getRelations(relations));
+    };
+    relations.on(RelationsEvent.Add, handleUpdate);
+    relations.on(RelationsEvent.Redaction, handleUpdate);
+    relations.on(RelationsEvent.Remove, handleUpdate);
+    return () => {
+      relations.removeListener(RelationsEvent.Add, handleUpdate);
+      relations.removeListener(RelationsEvent.Redaction, handleUpdate);
+      relations.removeListener(RelationsEvent.Remove, handleUpdate);
+    };
+  }, [relations, getRelations]);
+
+  return data;
+};
index 1e0fc7263eeecd4c89cb9cd5ca444d4acffddeb7..707d3487157eb17794e5fae8ba6d615bcd31f615 100644 (file)
@@ -13,6 +13,8 @@ export const useResizeObserver = (
 ): ResizeObserver => {
   const resizeObserver = useMemo(() => new ResizeObserver(onResizeCallback), [onResizeCallback]);
 
+  useEffect(() => () => resizeObserver?.disconnect(), [resizeObserver]);
+
   useEffect(() => {
     const element = typeof observeElement === 'function' ? observeElement() : observeElement;
     if (element) resizeObserver.observe(element);
diff --git a/src/app/hooks/useRoomEventReaders.ts b/src/app/hooks/useRoomEventReaders.ts
new file mode 100644 (file)
index 0000000..f6bac27
--- /dev/null
@@ -0,0 +1,35 @@
+import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
+import { useEffect, useState } from 'react';
+
+const getEventReaders = (room: Room, evtId?: string) => {
+  if (!evtId) return [];
+  const liveEvents = room.getLiveTimeline().getEvents();
+  const userIds: string[] = [];
+
+  for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
+    userIds.splice(userIds.length, 0, ...room.getUsersReadUpTo(liveEvents[i]));
+    if (liveEvents[i].getId() === evtId) break;
+  }
+
+  return [...new Set(userIds)];
+};
+
+export const useRoomEventReaders = (room: Room, eventId?: string): string[] => {
+  const [readers, setReaders] = useState<string[]>(() => getEventReaders(room, eventId));
+
+  useEffect(() => {
+    setReaders(getEventReaders(room, eventId));
+
+    const handleReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event, r) => {
+      if (r.roomId !== room.roomId) return;
+      setReaders(getEventReaders(room, eventId));
+    };
+
+    room.on(RoomEvent.Receipt, handleReceipt);
+    return () => {
+      room.removeListener(RoomEvent.Receipt, handleReceipt);
+    };
+  }, [room, eventId]);
+
+  return readers;
+};
diff --git a/src/app/hooks/useRoomLatestEvent.ts b/src/app/hooks/useRoomLatestEvent.ts
new file mode 100644 (file)
index 0000000..337438c
--- /dev/null
@@ -0,0 +1,29 @@
+import { MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
+import { useEffect, useState } from 'react';
+
+export const useRoomLatestEvent = (room: Room) => {
+  const [latestEvent, setLatestEvent] = useState<MatrixEvent>();
+
+  useEffect(() => {
+    const getLatestEvent = (): MatrixEvent | undefined => {
+      const liveEvents = room.getLiveTimeline().getEvents();
+      for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
+        const evt = liveEvents[i];
+        if (evt) return evt;
+      }
+      return undefined;
+    };
+
+    const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = () => {
+      setLatestEvent(getLatestEvent());
+    };
+    setLatestEvent(getLatestEvent());
+
+    room.on(RoomEvent.Timeline, handleTimelineEvent);
+    return () => {
+      room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
+    };
+  }, [room]);
+
+  return latestEvent;
+};
diff --git a/src/app/hooks/useRoomMsgContentRenderer.ts b/src/app/hooks/useRoomMsgContentRenderer.ts
new file mode 100644 (file)
index 0000000..b014249
--- /dev/null
@@ -0,0 +1,68 @@
+import { ReactNode } from 'react';
+import { MatrixEvent, MsgType } from 'matrix-js-sdk';
+
+export type MsgContentRenderer<T extends unknown[]> = (
+  eventId: string,
+  mEvent: MatrixEvent,
+  ...args: T
+) => ReactNode;
+
+export type RoomMsgContentRendererOpts<T extends unknown[]> = {
+  renderText?: MsgContentRenderer<T>;
+  renderEmote?: MsgContentRenderer<T>;
+  renderNotice?: MsgContentRenderer<T>;
+  renderImage?: MsgContentRenderer<T>;
+  renderVideo?: MsgContentRenderer<T>;
+  renderAudio?: MsgContentRenderer<T>;
+  renderFile?: MsgContentRenderer<T>;
+  renderLocation?: MsgContentRenderer<T>;
+  renderBadEncrypted?: MsgContentRenderer<T>;
+  renderUnsupported?: MsgContentRenderer<T>;
+  renderBrokenFallback?: MsgContentRenderer<T>;
+};
+
+export type RenderRoomMsgContent<T extends unknown[]> = (
+  eventId: string,
+  mEvent: MatrixEvent,
+  ...args: T
+) => ReactNode;
+
+export const useRoomMsgContentRenderer =
+  <T extends unknown[]>({
+    renderText,
+    renderEmote,
+    renderNotice,
+    renderImage,
+    renderVideo,
+    renderAudio,
+    renderFile,
+    renderLocation,
+    renderBadEncrypted,
+    renderUnsupported,
+    renderBrokenFallback,
+  }: RoomMsgContentRendererOpts<T>): RenderRoomMsgContent<T> =>
+  (eventId, mEvent, ...args) => {
+    const msgType = mEvent.getContent().msgtype;
+
+    let node: ReactNode = null;
+
+    if (msgType === MsgType.Text && renderText) node = renderText(eventId, mEvent, ...args);
+    else if (msgType === MsgType.Emote && renderEmote) node = renderEmote(eventId, mEvent, ...args);
+    else if (msgType === MsgType.Notice && renderNotice)
+      node = renderNotice(eventId, mEvent, ...args);
+    else if (msgType === MsgType.Image && renderImage) node = renderImage(eventId, mEvent, ...args);
+    else if (msgType === MsgType.Video && renderVideo) node = renderVideo(eventId, mEvent, ...args);
+    else if (msgType === MsgType.Audio && renderAudio) node = renderAudio(eventId, mEvent, ...args);
+    else if (msgType === MsgType.File && renderFile) node = renderFile(eventId, mEvent, ...args);
+    else if (msgType === MsgType.Location && renderLocation)
+      node = renderLocation(eventId, mEvent, ...args);
+    else if (msgType === 'm.bad.encrypted' && renderBadEncrypted)
+      node = renderBadEncrypted(eventId, mEvent, ...args);
+    else if (renderUnsupported) {
+      node = renderUnsupported(eventId, mEvent, ...args);
+    }
+
+    if (!node && renderBrokenFallback) node = renderBrokenFallback(eventId, mEvent, ...args);
+
+    return node;
+  };
diff --git a/src/app/hooks/useVirtualPaginator.ts b/src/app/hooks/useVirtualPaginator.ts
new file mode 100644 (file)
index 0000000..550e11c
--- /dev/null
@@ -0,0 +1,405 @@
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
+import { OnIntersectionCallback, useIntersectionObserver } from './useIntersectionObserver';
+import {
+  canFitInScrollView,
+  getScrollInfo,
+  isInScrollView,
+  isIntersectingScrollView,
+} from '../utils/dom';
+
+const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor';
+
+export enum Direction {
+  Backward = 'B',
+  Forward = 'F',
+}
+
+export type ItemRange = {
+  start: number;
+  end: number;
+};
+
+export type ScrollToOptions = {
+  offset?: number;
+  align?: 'start' | 'center' | 'end';
+  behavior?: 'auto' | 'instant' | 'smooth';
+  stopInView?: boolean;
+};
+
+export type ScrollToElement = (element: HTMLElement, opts?: ScrollToOptions) => void;
+export type ScrollToItem = (index: number, opts?: ScrollToOptions) => void;
+
+type HandleObserveAnchor = (element: HTMLElement | null) => void;
+
+type VirtualPaginatorOptions<TScrollElement extends HTMLElement> = {
+  count: number;
+  limit: number;
+  range: ItemRange;
+  onRangeChange: (range: ItemRange) => void;
+  getScrollElement: () => TScrollElement | null;
+  getItemElement: (index: number) => HTMLElement | undefined;
+  onEnd?: (back: boolean) => void;
+};
+
+type VirtualPaginator = {
+  getItems: () => number[];
+  scrollToElement: ScrollToElement;
+  scrollToItem: ScrollToItem;
+  observeBackAnchor: HandleObserveAnchor;
+  observeFrontAnchor: HandleObserveAnchor;
+};
+
+const generateItems = (range: ItemRange) => {
+  const items: number[] = [];
+  for (let i = range.start; i < range.end; i += 1) {
+    items.push(i);
+  }
+
+  return items;
+};
+
+const getDropIndex = (
+  scrollEl: HTMLElement,
+  range: ItemRange,
+  dropDirection: Direction,
+  getItemElement: (index: number) => HTMLElement | undefined,
+  pageThreshold = 1
+): number | undefined => {
+  const fromBackward = dropDirection === Direction.Backward;
+  const items = fromBackward ? generateItems(range) : generateItems(range).reverse();
+
+  const { viewHeight, top, height } = getScrollInfo(scrollEl);
+  const { offsetTop: sOffsetTop } = scrollEl;
+  const bottom = top + viewHeight;
+  const dropEdgePx = fromBackward
+    ? Math.max(top - viewHeight * pageThreshold, 0)
+    : Math.min(bottom + viewHeight * pageThreshold, height);
+  if (dropEdgePx === 0 || dropEdgePx === height) return undefined;
+
+  let dropIndex: number | undefined;
+
+  items.find((item) => {
+    const el = getItemElement(item);
+    if (!el) {
+      dropIndex = item;
+      return false;
+    }
+    const { clientHeight } = el;
+    const offsetTop = el.offsetTop - sOffsetTop;
+    const offsetBottom = offsetTop + clientHeight;
+    const isInView = fromBackward ? offsetBottom > dropEdgePx : offsetTop < dropEdgePx;
+    if (isInView) return true;
+    dropIndex = item;
+    return false;
+  });
+
+  return dropIndex;
+};
+
+type RestoreAnchorData = [number | undefined, HTMLElement | undefined];
+const getRestoreAnchor = (
+  range: ItemRange,
+  getItemElement: (index: number) => HTMLElement | undefined,
+  direction: Direction
+): RestoreAnchorData => {
+  let scrollAnchorEl: HTMLElement | undefined;
+  const scrollAnchorItem = (
+    direction === Direction.Backward ? generateItems(range) : generateItems(range).reverse()
+  ).find((i) => {
+    const el = getItemElement(i);
+    if (el) {
+      scrollAnchorEl = el;
+      return true;
+    }
+    return false;
+  });
+  return [scrollAnchorItem, scrollAnchorEl];
+};
+
+const getRestoreScrollData = (scrollTop: number, restoreAnchorData: RestoreAnchorData) => {
+  const [anchorItem, anchorElement] = restoreAnchorData;
+  if (!anchorItem || !anchorElement) {
+    return undefined;
+  }
+  return {
+    scrollTop,
+    anchorItem,
+    anchorOffsetTop: anchorElement.offsetTop,
+  };
+};
+
+const useObserveAnchorHandle = (
+  intersectionObserver: ReturnType<typeof useIntersectionObserver>,
+  anchorType: Direction
+): HandleObserveAnchor =>
+  useMemo<HandleObserveAnchor>(() => {
+    let anchor: HTMLElement | null = null;
+    return (element) => {
+      if (element === anchor) return;
+      if (anchor) intersectionObserver?.unobserve(anchor);
+      if (!element) return;
+      anchor = element;
+      element.setAttribute(PAGINATOR_ANCHOR_ATTR, anchorType);
+      intersectionObserver?.observe(element);
+    };
+  }, [intersectionObserver, anchorType]);
+
+export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
+  options: VirtualPaginatorOptions<TScrollElement>
+): VirtualPaginator => {
+  const { count, limit, range, onRangeChange, getScrollElement, getItemElement, onEnd } = options;
+
+  const initialRenderRef = useRef(true);
+
+  const restoreScrollRef = useRef<{
+    scrollTop: number;
+    anchorOffsetTop: number;
+    anchorItem: number;
+  }>();
+
+  const scrollToItemRef = useRef<{
+    index: number;
+    opts?: ScrollToOptions;
+  }>();
+
+  const propRef = useRef({
+    range,
+    limit,
+    count,
+  });
+  if (propRef.current.count !== count) {
+    // Clear restoreScrollRef on count change
+    // As restoreScrollRef.current.anchorItem might changes
+    restoreScrollRef.current = undefined;
+  }
+  propRef.current = {
+    range,
+    count,
+    limit,
+  };
+
+  const getItems = useMemo(() => {
+    const items = generateItems(range);
+    return () => items;
+  }, [range]);
+
+  const scrollToElement = useCallback<ScrollToElement>(
+    (element, opts) => {
+      const scrollElement = getScrollElement();
+      if (!scrollElement) return;
+
+      if (opts?.stopInView && isInScrollView(scrollElement, element)) {
+        return;
+      }
+      let scrollTo = element.offsetTop;
+      if (opts?.align === 'center' && canFitInScrollView(scrollElement, element)) {
+        const scrollInfo = getScrollInfo(scrollElement);
+        scrollTo =
+          element.offsetTop -
+          Math.round(scrollInfo.viewHeight / 2) +
+          Math.round(element.clientHeight / 2);
+      } else if (opts?.align === 'end' && canFitInScrollView(scrollElement, element)) {
+        const scrollInfo = getScrollInfo(scrollElement);
+        scrollTo = element.offsetTop - Math.round(scrollInfo.viewHeight) + element.clientHeight;
+      }
+
+      scrollElement.scrollTo({
+        top: scrollTo - (opts?.offset ?? 0),
+        behavior: opts?.behavior,
+      });
+    },
+    [getScrollElement]
+  );
+
+  const scrollToItem = useCallback<ScrollToItem>(
+    (index, opts) => {
+      const { range: currentRange, limit: currentLimit, count: currentCount } = propRef.current;
+
+      if (index < 0 || index >= currentCount) return;
+      // index is not in range change range
+      // and trigger scrollToItem in layoutEffect hook
+      if (index < currentRange.start || index >= currentRange.end) {
+        onRangeChange({
+          start: Math.max(index - currentLimit, 0),
+          end: Math.min(index + currentLimit, currentCount),
+        });
+        scrollToItemRef.current = {
+          index,
+          opts,
+        };
+        return;
+      }
+
+      // find target or it's previous rendered element to scroll to
+      const targetItems = generateItems({ start: currentRange.start, end: index + 1 });
+      const targetItem = targetItems.reverse().find((i) => getItemElement(i) !== undefined);
+      const itemElement = targetItem && getItemElement(targetItem);
+
+      if (!itemElement) {
+        const scrollElement = getScrollElement();
+        scrollElement?.scrollTo({
+          top: opts?.offset ?? 0,
+          behavior: opts?.behavior,
+        });
+        return;
+      }
+      scrollToElement(itemElement, opts);
+    },
+    [getScrollElement, scrollToElement, getItemElement, onRangeChange]
+  );
+
+  const paginate = useCallback(
+    (direction: Direction) => {
+      const scrollEl = getScrollElement();
+      const { range: currentRange, limit: currentLimit, count: currentCount } = propRef.current;
+      let { start, end } = currentRange;
+
+      if (direction === Direction.Backward) {
+        restoreScrollRef.current = undefined;
+        if (start === 0) {
+          onEnd?.(true);
+          return;
+        }
+        if (scrollEl) {
+          restoreScrollRef.current = getRestoreScrollData(
+            scrollEl.scrollTop,
+            getRestoreAnchor({ start, end }, getItemElement, Direction.Backward)
+          );
+        }
+        if (scrollEl) {
+          end = getDropIndex(scrollEl, currentRange, Direction.Forward, getItemElement, 2) ?? end;
+        }
+        start = Math.max(start - currentLimit, 0);
+      }
+
+      if (direction === Direction.Forward) {
+        restoreScrollRef.current = undefined;
+        if (end === currentCount) {
+          onEnd?.(false);
+          return;
+        }
+        if (scrollEl) {
+          restoreScrollRef.current = getRestoreScrollData(
+            scrollEl.scrollTop,
+            getRestoreAnchor({ start, end }, getItemElement, Direction.Forward)
+          );
+        }
+        end = Math.min(end + currentLimit, currentCount);
+        if (scrollEl) {
+          start =
+            getDropIndex(scrollEl, currentRange, Direction.Backward, getItemElement, 2) ?? start;
+        }
+      }
+
+      onRangeChange({
+        start,
+        end,
+      });
+    },
+    [getScrollElement, getItemElement, onEnd, onRangeChange]
+  );
+
+  const handlePaginatorElIntersection: OnIntersectionCallback = useCallback(
+    (entries) => {
+      const anchorB = entries.find(
+        (entry) => entry.target.getAttribute(PAGINATOR_ANCHOR_ATTR) === Direction.Backward
+      );
+      if (anchorB?.isIntersecting) {
+        paginate(Direction.Backward);
+      }
+      const anchorF = entries.find(
+        (entry) => entry.target.getAttribute(PAGINATOR_ANCHOR_ATTR) === Direction.Forward
+      );
+      if (anchorF?.isIntersecting) {
+        paginate(Direction.Forward);
+      }
+    },
+    [paginate]
+  );
+
+  const intersectionObserver = useIntersectionObserver(
+    handlePaginatorElIntersection,
+    useMemo(
+      () => ({
+        root: getScrollElement(),
+      }),
+      [getScrollElement]
+    )
+  );
+
+  const observeBackAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Backward);
+  const observeFrontAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Forward);
+
+  // Restore scroll when local pagination.
+  // restoreScrollRef.current only gets set
+  // when paginate() changes range itself
+  useLayoutEffect(() => {
+    const scrollEl = getScrollElement();
+    if (!restoreScrollRef.current || !scrollEl) return;
+    const {
+      anchorOffsetTop: oldOffsetTop,
+      anchorItem,
+      scrollTop: oldScrollTop,
+    } = restoreScrollRef.current;
+    const anchorEl = getItemElement(anchorItem);
+
+    if (!anchorEl) return;
+    const { offsetTop } = anchorEl;
+    const offsetAddition = offsetTop - oldOffsetTop;
+    const restoreTop = oldScrollTop + offsetAddition;
+
+    scrollEl.scrollTo({
+      top: restoreTop,
+      behavior: 'instant',
+    });
+    restoreScrollRef.current = undefined;
+  }, [range, getScrollElement, getItemElement]);
+
+  // When scrollToItem index was not in range.
+  // Scroll to item after range changes.
+  useLayoutEffect(() => {
+    if (scrollToItemRef.current === undefined) return;
+    const { index, opts } = scrollToItemRef.current;
+    scrollToItem(index, {
+      ...opts,
+      behavior: 'instant',
+    });
+    scrollToItemRef.current = undefined;
+  }, [range, scrollToItem]);
+
+  // Continue pagination to fill view height with scroll items
+  // check if pagination anchor are in visible view height
+  // and trigger pagination
+  useEffect(() => {
+    if (initialRenderRef.current) {
+      // Do not trigger pagination on initial render
+      // anchor intersection observable will trigger pagination on mount
+      initialRenderRef.current = false;
+      return;
+    }
+    const scrollElement = getScrollElement();
+    if (!scrollElement) return;
+    const backAnchor = scrollElement.querySelector(
+      `[${PAGINATOR_ANCHOR_ATTR}="${Direction.Backward}"]`
+    ) as HTMLElement | null;
+    const frontAnchor = scrollElement.querySelector(
+      `[${PAGINATOR_ANCHOR_ATTR}="${Direction.Forward}"]`
+    ) as HTMLElement | null;
+
+    if (backAnchor && isIntersectingScrollView(scrollElement, backAnchor)) {
+      paginate(Direction.Backward);
+      return;
+    }
+    if (frontAnchor && isIntersectingScrollView(scrollElement, frontAnchor)) {
+      paginate(Direction.Forward);
+    }
+  }, [range, getScrollElement, paginate]);
+
+  return {
+    getItems,
+    scrollToItem,
+    scrollToElement,
+    observeBackAnchor,
+    observeFrontAnchor,
+  };
+};
diff --git a/src/app/hooks/useZoom.ts b/src/app/hooks/useZoom.ts
new file mode 100644 (file)
index 0000000..0181063
--- /dev/null
@@ -0,0 +1,26 @@
+import { useState } from 'react';
+
+export const useZoom = (step: number, min = 0.1, max = 5) => {
+  const [zoom, setZoom] = useState<number>(1);
+
+  const zoomIn = () => {
+    setZoom((z) => {
+      const newZ = z + step;
+      return newZ > max ? z : newZ;
+    });
+  };
+
+  const zoomOut = () => {
+    setZoom((z) => {
+      const newZ = z - step;
+      return newZ < min ? z : newZ;
+    });
+  };
+
+  return {
+    zoom,
+    setZoom,
+    zoomIn,
+    zoomOut,
+  };
+};
index 5712c66f6bae2546ddd579f9c5c9803c891bda57..d0bb55d5ddf9c741736f307689a617ef7bedb037 100644 (file)
@@ -10,6 +10,7 @@ import {
   Avatar,
   AvatarFallback,
   AvatarImage,
+  Badge,
   Box,
   Chip,
   ContainerColor,
@@ -33,6 +34,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
 import FocusTrap from 'focus-trap-react';
 import millify from 'millify';
 import classNames from 'classnames';
+import { useAtomValue } from 'jotai';
 
 import { openInviteUser, openProfileViewer } from '../../../client/action/navigation';
 import * as css from './MembersDrawer.css';
@@ -48,6 +50,10 @@ import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearc
 import { useDebounce } from '../../hooks/useDebounce';
 import colorMXID from '../../../util/colorMXID';
 import { usePowerLevelTags, PowerLevelTag } from '../../hooks/usePowerLevelTags';
+import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../../state/typingMembers';
+import { TypingIndicator } from '../../components/typing-indicator';
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
 
 export const MembershipFilters = {
   filterJoined: (m: RoomMember) => m.membership === Membership.Join,
@@ -175,6 +181,10 @@ export function MembersDrawer({ room }: MembersDrawerProps) {
   });
   const [onTop, setOnTop] = useState(true);
 
+  const typingMembers = useAtomValue(
+    useMemo(() => selectRoomTypingMembersAtom(room.roomId, roomIdToTypingMembersAtom), [room])
+  );
+
   const filteredMembers = useMemo(
     () =>
       members
@@ -235,6 +245,9 @@ export function MembersDrawer({ room }: MembersDrawerProps) {
     { wait: 200 }
   );
 
+  const getName = (member: RoomMember) =>
+    getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
+
   const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
     const btn = evt.currentTarget as HTMLButtonElement;
     const userId = btn.getAttribute('data-user-id');
@@ -470,6 +483,7 @@ export function MembersDrawer({ room }: MembersDrawerProps) {
                   }
 
                   const member = tagOrMember;
+                  const name = getName(member);
                   const avatarUrl = member.getAvatarUrl(
                     mx.baseUrl,
                     100,
@@ -482,7 +496,7 @@ export function MembersDrawer({ room }: MembersDrawerProps) {
                   return (
                     <MenuItem
                       style={{
-                        padding: config.space.S200,
+                        padding: `0 ${config.space.S200}`,
                         transform: `translateY(${vItem.start}px)`,
                       }}
                       data-index={vItem.index}
@@ -504,15 +518,24 @@ export function MembersDrawer({ room }: MembersDrawerProps) {
                                 color: 'white',
                               }}
                             >
-                              <Text size="T200">{member.name[0]}</Text>
+                              <Text size="H6">{name[0]}</Text>
                             </AvatarFallback>
                           )}
                         </Avatar>
                       }
+                      after={
+                        typingMembers.find((tm) => tm.userId === member.userId) && (
+                          <Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
+                            <TypingIndicator size="300" />
+                          </Badge>
+                        )
+                      }
                     >
-                      <Text size="T400" truncate>
-                        {member.name}
-                      </Text>
+                      <Box grow="Yes">
+                        <Text size="T400" truncate>
+                          {name}
+                        </Text>
+                      </Box>
                     </MenuItem>
                   );
                 })}
diff --git a/src/app/organisms/room/Room.jsx b/src/app/organisms/room/Room.jsx
deleted file mode 100644 (file)
index 0603b98..0000000
+++ /dev/null
@@ -1,79 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import './Room.scss';
-import { Line } from 'folds';
-
-import initMatrix from '../../../client/initMatrix';
-import cons from '../../../client/state/cons';
-import RoomTimeline from '../../../client/state/RoomTimeline';
-import navigation from '../../../client/state/navigation';
-import { openNavigation } from '../../../client/action/navigation';
-
-import Welcome from '../welcome/Welcome';
-import RoomView from './RoomView';
-import RoomSettings from './RoomSettings';
-import { MembersDrawer } from './MembersDrawer';
-import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
-import { useSetting } from '../../state/hooks/settings';
-import { settingsAtom } from '../../state/settings';
-
-function Room() {
-  const [roomInfo, setRoomInfo] = useState({
-    room: null,
-    roomTimeline: null,
-    eventId: null,
-  });
-  const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
-  const [screenSize] = useScreenSize();
-
-  const mx = initMatrix.matrixClient;
-
-  useEffect(() => {
-    const handleRoomSelected = (rId, pRoomId, eId) => {
-      roomInfo.roomTimeline?.removeInternalListeners();
-      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,
-        });
-      }
-    };
-
-    navigation.on(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
-    return () => {
-      navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
-    };
-  }, [roomInfo, mx]);
-
-  const { room, roomTimeline, eventId } = roomInfo;
-  if (roomTimeline === null) {
-    setTimeout(() => openNavigation());
-    return <Welcome />;
-  }
-
-  return (
-    <div className="room">
-      <div className="room__content">
-        <RoomSettings roomId={roomTimeline.roomId} />
-        <RoomView room={room} roomTimeline={roomTimeline} eventId={eventId} />
-      </div>
-
-      {screenSize === ScreenSize.Desktop && isDrawer && (
-        <>
-          <Line variant="Background" direction="Vertical" size="300" />
-          <MembersDrawer room={room} />
-        </>
-      )}
-    </div>
-  );
-}
-
-export default Room;
diff --git a/src/app/organisms/room/Room.tsx b/src/app/organisms/room/Room.tsx
new file mode 100644 (file)
index 0000000..094daad
--- /dev/null
@@ -0,0 +1,46 @@
+import React from 'react';
+import './Room.scss';
+import { Room } from 'matrix-js-sdk';
+import { Line } from 'folds';
+
+import RoomView from './RoomView';
+import RoomSettings from './RoomSettings';
+import { MembersDrawer } from './MembersDrawer';
+import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
+import {
+  roomIdToTypingMembersAtom,
+  useBindRoomIdToTypingMembersAtom,
+} from '../../state/typingMembers';
+
+export type RoomBaseViewProps = {
+  room: Room;
+  eventId?: string;
+};
+export function RoomBaseView({ room, eventId }: RoomBaseViewProps) {
+  useBindRoomIdToTypingMembersAtom(room.client, roomIdToTypingMembersAtom);
+
+  const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
+  const [screenSize] = useScreenSize();
+  const powerLevelAPI = usePowerLevels(room);
+
+  return (
+    <PowerLevelsContextProvider value={powerLevelAPI}>
+      <div className="room">
+        <div className="room__content">
+          <RoomSettings roomId={room.roomId} />
+          <RoomView room={room} eventId={eventId} />
+        </div>
+
+        {screenSize === ScreenSize.Desktop && isDrawer && (
+          <>
+            <Line variant="Background" direction="Vertical" size="300" />
+            <MembersDrawer room={room} />
+          </>
+        )}
+      </div>
+    </PowerLevelsContextProvider>
+  );
+}
index a50c80047f0441964ee2b117059a058130fd8e41..efef03a27057ae595a82479765dea32bbfe58bcb 100644 (file)
@@ -34,8 +34,6 @@ import to from 'await-to-js';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import {
   CustomEditor,
-  EditorChangeHandler,
-  useEditor,
   Toolbar,
   toMatrixCustomHTML,
   toPlainText,
@@ -102,13 +100,13 @@ import { sanitizeText } from '../../utils/sanitize';
 import { useScreenSize } from '../../hooks/useScreenSize';
 
 interface RoomInputProps {
+  editor: Editor;
   roomViewRef: RefObject<HTMLElement>;
   roomId: string;
 }
 export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
-  ({ roomViewRef, roomId }, ref) => {
+  ({ editor, roomViewRef, roomId }, ref) => {
     const mx = useMatrixClient();
-    const editor = useEditor();
     const room = mx.getRoom(roomId);
 
     const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
@@ -226,7 +224,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
       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));
+          const [imgError, imgContent] = await to(getImageMsgContent(mx, fileItem, upload.mxc));
           if (imgError) console.warn(imgError);
           if (imgContent) mx.sendMessage(roomId, imgContent);
           return;
@@ -294,7 +292,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
 
     const handleKeyDown: KeyboardEventHandler = useCallback(
       (evt) => {
-        const { selection } = editor;
         if (isHotkey('enter', evt)) {
           evt.preventDefault();
           submit();
@@ -303,7 +300,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
           evt.preventDefault();
           setReplyDraft();
         }
-        if (selection && Range.isCollapsed(selection)) {
+
+        if (editor.selection && Range.isCollapsed(editor.selection)) {
           if (isHotkey('arrowleft', evt)) {
             evt.preventDefault();
             Transforms.move(editor, { unit: 'offset', reverse: true });
@@ -317,20 +315,19 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
       [submit, editor, setReplyDraft]
     );
 
-    const handleChange: EditorChangeHandler = (value) => {
+    const handleKeyUp: KeyboardEventHandler = useCallback(() => {
+      const firstChildren = editor.children[0];
+      if (firstChildren && Element.isElement(firstChildren)) {
+        const isEmpty = editor.children.length === 1 && Editor.isEmpty(editor, firstChildren);
+        sendTypingStatus(!isEmpty);
+      }
+
       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);
-      }
-    };
+    }, [editor, sendTypingStatus]);
 
     const handleEmoticonSelect = (key: string, shortcode: string) => {
       editor.insertNode(createEmoticonElement(key, shortcode));
@@ -439,7 +436,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
           editor={editor}
           placeholder="Send a message..."
           onKeyDown={handleKeyDown}
-          onChange={handleChange}
+          onKeyUp={handleKeyUp}
           onPaste={handlePaste}
           top={
             replyDraft && (
diff --git a/src/app/organisms/room/RoomTimeline.css.ts b/src/app/organisms/room/RoomTimeline.css.ts
new file mode 100644 (file)
index 0000000..9cd428e
--- /dev/null
@@ -0,0 +1,30 @@
+import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
+import { DefaultReset, config } from 'folds';
+
+export const TimelineFloat = recipe({
+  base: [
+    DefaultReset,
+    {
+      position: 'absolute',
+      left: '50%',
+      transform: 'translateX(-50%)',
+      zIndex: 1,
+      minWidth: 'max-content',
+    },
+  ],
+  variants: {
+    position: {
+      Top: {
+        top: config.space.S400,
+      },
+      Bottom: {
+        bottom: config.space.S400,
+      },
+    },
+  },
+  defaultVariants: {
+    position: 'Top',
+  },
+});
+
+export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
diff --git a/src/app/organisms/room/RoomTimeline.tsx b/src/app/organisms/room/RoomTimeline.tsx
new file mode 100644 (file)
index 0000000..03f72a3
--- /dev/null
@@ -0,0 +1,1689 @@
+import React, {
+  Dispatch,
+  MouseEventHandler,
+  RefObject,
+  SetStateAction,
+  useCallback,
+  useEffect,
+  useLayoutEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import {
+  Direction,
+  EventTimeline,
+  EventTimelineSet,
+  EventTimelineSetHandlerMap,
+  EventType,
+  IEncryptedFile,
+  MatrixClient,
+  MatrixEvent,
+  RelationType,
+  Room,
+  RoomEvent,
+  RoomEventHandlerMap,
+} from 'matrix-js-sdk';
+import parse, { HTMLReactParserOptions } from 'html-react-parser';
+import classNames from 'classnames';
+import { ReactEditor } from 'slate-react';
+import { Editor } from 'slate';
+import to from 'await-to-js';
+import { useSetAtom } from 'jotai';
+import {
+  Badge,
+  Box,
+  Chip,
+  ContainerColor,
+  Icon,
+  Icons,
+  Line,
+  Scroll,
+  Text,
+  as,
+  color,
+  config,
+  toRem,
+} from 'folds';
+import Linkify from 'linkify-react';
+import {
+  decryptFile,
+  eventWithShortcode,
+  factoryEventSentBy,
+  getMxIdLocalPart,
+  isRoomId,
+  isUserId,
+  matrixEventByRecency,
+} from '../../utils/matrix';
+import { sanitizeCustomHtml } from '../../utils/sanitize';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
+import { useAlive } from '../../hooks/useAlive';
+import { scrollToBottom } from '../../utils/dom';
+import {
+  DefaultPlaceholder,
+  CompactPlaceholder,
+  Reply,
+  MessageBase,
+  MessageDeletedContent,
+  MessageBrokenContent,
+  MessageUnsupportedContent,
+  MessageEditedContent,
+  MessageEmptyContent,
+  AttachmentBox,
+  Attachment,
+  AttachmentContent,
+  AttachmentHeader,
+  Time,
+  MessageBadEncryptedContent,
+  MessageNotDecryptedContent,
+} from '../../components/message';
+import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
+import {
+  decryptAllTimelineEvent,
+  getMemberDisplayName,
+  getReactionContent,
+} from '../../utils/room';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { openJoinAlias, openProfileViewer, selectRoom } from '../../../client/action/navigation';
+import { useForceUpdate } from '../../hooks/useForceUpdate';
+import { parseGeoUri, scaleYDimension } from '../../utils/common';
+import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
+import { useRoomMsgContentRenderer } from '../../hooks/useRoomMsgContentRenderer';
+import { IAudioContent, IImageContent, IVideoContent } from '../../../types/matrix/common';
+import { getBlobSafeMimeType } from '../../utils/mimeTypes';
+import {
+  ImageContent,
+  VideoContent,
+  FileHeader,
+  fileRenderer,
+  AudioContent,
+  Reactions,
+  EventContent,
+  Message,
+  Event,
+  EncryptedContent,
+  StickerContent,
+} from './message';
+import { useMemberEventParser } from '../../hooks/useMemberEventParser';
+import * as customHtmlCss from '../../styles/CustomHtml.css';
+import { RoomIntro } from '../../components/room-intro';
+import {
+  OnIntersectionCallback,
+  getIntersectionObserverEntry,
+  useIntersectionObserver,
+} from '../../hooks/useIntersectionObserver';
+import { markAsRead } from '../../../client/action/notifications';
+import { useDebounce } from '../../hooks/useDebounce';
+import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
+import * as css from './RoomTimeline.css';
+import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
+import { createMentionElement, moveCursor } from '../../components/editor';
+import { roomIdToReplyDraftAtomFamily } from '../../state/roomInputDrafts';
+import { usePowerLevelsAPI } from '../../hooks/usePowerLevels';
+import { MessageEvent } from '../../../types/matrix/room';
+import initMatrix from '../../../client/initMatrix';
+
+const TimelineFloat = as<'div', css.TimelineFloatVariants>(
+  ({ position, className, ...props }, ref) => (
+    <Box
+      className={classNames(css.TimelineFloat({ position }), className)}
+      justifyContent="Center"
+      alignItems="Center"
+      gap="200"
+      {...props}
+      ref={ref}
+    />
+  )
+);
+
+const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
+  ({ variant, children, ...props }, ref) => (
+    <Box gap="100" justifyContent="Center" alignItems="Center" {...props} ref={ref}>
+      <Line style={{ flexGrow: 1 }} variant={variant} size="300" />
+      {children}
+      <Line style={{ flexGrow: 1 }} variant={variant} size="300" />
+    </Box>
+  )
+);
+
+export const getLiveTimeline = (room: Room): EventTimeline =>
+  room.getUnfilteredTimelineSet().getLiveTimeline();
+
+export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => {
+  const timelineSet = room.getUnfilteredTimelineSet();
+  return timelineSet.getTimelineForEvent(eventId) ?? undefined;
+};
+
+export const getFirstLinkedTimeline = (
+  timeline: EventTimeline,
+  direction: Direction
+): EventTimeline => {
+  const linkedTm = timeline.getNeighbouringTimeline(direction);
+  if (!linkedTm) return timeline;
+  return getFirstLinkedTimeline(linkedTm, direction);
+};
+
+export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
+  const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
+  const timelines = [];
+
+  for (
+    let nextTimeline: EventTimeline | null = firstTimeline;
+    nextTimeline;
+    nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward)
+  ) {
+    timelines.push(nextTimeline);
+  }
+  return timelines;
+};
+
+export const timelineToEventsCount = (t: EventTimeline) => t.getEvents().length;
+export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => {
+  const timelineEventCountReducer = (count: number, tm: EventTimeline) =>
+    count + timelineToEventsCount(tm);
+  return timelines.reduce(timelineEventCountReducer, 0);
+};
+
+export const getTimelineAndBaseIndex = (
+  timelines: EventTimeline[],
+  index: number
+): [EventTimeline | undefined, number] => {
+  let uptoTimelineLen = 0;
+  const timeline = timelines.find((t) => {
+    uptoTimelineLen += t.getEvents().length;
+    if (index < uptoTimelineLen) return true;
+    return false;
+  });
+  if (!timeline) return [undefined, 0];
+  return [timeline, uptoTimelineLen - timeline.getEvents().length];
+};
+
+export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) =>
+  absoluteIndex - timelineBaseIndex;
+
+export const getTimelineEvent = (timeline: EventTimeline, index: number): MatrixEvent | undefined =>
+  timeline.getEvents()[index];
+
+export const getEventIdAbsoluteIndex = (
+  timelines: EventTimeline[],
+  eventTimeline: EventTimeline,
+  eventId: string
+): number | undefined => {
+  const timelineIndex = timelines.findIndex((t) => t === eventTimeline);
+  if (timelineIndex === -1) return undefined;
+  const eventIndex = eventTimeline.getEvents().findIndex((evt) => evt.getId() === eventId);
+  if (eventIndex === -1) return undefined;
+  const baseIndex = timelines
+    .slice(0, timelineIndex)
+    .reduce((accValue, timeline) => timeline.getEvents().length + accValue, 0);
+  return baseIndex + eventIndex;
+};
+
+export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) =>
+  timelineSet.relations.getChildEventsForEvent(
+    eventId,
+    RelationType.Annotation,
+    EventType.Reaction
+  );
+
+export const getEventEdits = (timelineSet: EventTimelineSet, eventId: string, eventType: string) =>
+  timelineSet.relations.getChildEventsForEvent(eventId, RelationType.Replace, eventType);
+
+export const getLatestEdit = (
+  targetEvent: MatrixEvent,
+  editEvents: MatrixEvent[]
+): MatrixEvent | undefined => {
+  const eventByTargetSender = (rEvent: MatrixEvent) =>
+    rEvent.getSender() === targetEvent.getSender();
+  return editEvents.sort(matrixEventByRecency).find(eventByTargetSender);
+};
+
+export const getEditedEvent = (
+  mEventId: string,
+  mEvent: MatrixEvent,
+  timelineSet: EventTimelineSet
+): MatrixEvent | undefined => {
+  const edits = getEventEdits(timelineSet, mEventId, mEvent.getType());
+  return edits && getLatestEdit(mEvent, edits.getRelations());
+};
+
+export const factoryGetFileSrcUrl =
+  (httpUrl: string, mimeType: string, encFile?: IEncryptedFile) => async (): Promise<string> => {
+    if (encFile) {
+      if (typeof httpUrl !== 'string') throw new Error('Malformed event');
+      const encRes = await fetch(httpUrl, { method: 'GET' });
+      const encData = await encRes.arrayBuffer();
+      const decryptedBlob = await decryptFile(encData, mimeType, encFile);
+      return URL.createObjectURL(decryptedBlob);
+    }
+    return httpUrl;
+  };
+
+type RoomTimelineProps = {
+  room: Room;
+  eventId?: string;
+  roomInputRef: RefObject<HTMLElement>;
+  editor: Editor;
+};
+
+const PAGINATION_LIMIT = 80;
+
+type Timeline = {
+  linkedTimelines: EventTimeline[];
+  range: ItemRange;
+};
+
+const useEventTimelineLoader = (
+  mx: MatrixClient,
+  room: Room,
+  onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
+  onError: (err: Error | null) => void
+) => {
+  const loadEventTimeline = useCallback(
+    async (eventId: string) => {
+      const [err, replyEvtTimeline] = await to(
+        mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId)
+      );
+      if (!replyEvtTimeline) {
+        onError(err ?? null);
+        return;
+      }
+      const linkedTimelines = getLinkedTimelines(replyEvtTimeline);
+      const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
+
+      if (absIndex === undefined) {
+        onError(err ?? null);
+        return;
+      }
+
+      onLoad(eventId, linkedTimelines, absIndex);
+    },
+    [mx, room, onLoad, onError]
+  );
+
+  return loadEventTimeline;
+};
+
+const useTimelinePagination = (
+  mx: MatrixClient,
+  timeline: Timeline,
+  setTimeline: Dispatch<SetStateAction<Timeline>>,
+  limit: number
+) => {
+  const timelineRef = useRef(timeline);
+  timelineRef.current = timeline;
+  const alive = useAlive();
+
+  const handleTimelinePagination = useMemo(() => {
+    let fetching = false;
+
+    const recalibratePagination = (
+      linkedTimelines: EventTimeline[],
+      timelinesEventsCount: number[],
+      backwards: boolean
+    ) => {
+      const topTimeline = linkedTimelines[0];
+      const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt;
+
+      const newLTimelines = getLinkedTimelines(topTimeline);
+      const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline));
+      const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex);
+
+      const topTmAddedEvt =
+        timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0];
+      const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0);
+
+      setTimeline((currentTimeline) => ({
+        linkedTimelines: newLTimelines,
+        range:
+          offsetRange > 0
+            ? {
+                start: currentTimeline.range.start + offsetRange,
+                end: currentTimeline.range.end + offsetRange,
+              }
+            : { ...currentTimeline.range },
+      }));
+    };
+
+    return async (backwards: boolean) => {
+      if (fetching) return;
+      const { linkedTimelines: lTimelines } = timelineRef.current;
+      const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
+
+      const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1];
+      if (!timelineToPaginate) return;
+
+      const paginationToken = timelineToPaginate.getPaginationToken(
+        backwards ? Direction.Backward : Direction.Forward
+      );
+      if (
+        !paginationToken &&
+        getTimelinesEventsCount(lTimelines) !==
+          getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
+      ) {
+        recalibratePagination(lTimelines, timelinesEventsCount, backwards);
+        return;
+      }
+
+      fetching = true;
+      const [err] = await to(
+        mx.paginateEventTimeline(timelineToPaginate, {
+          backwards,
+          limit,
+        })
+      );
+      if (err) {
+        // TODO: handle pagination error.
+        return;
+      }
+      const fetchedTimeline =
+        timelineToPaginate.getNeighbouringTimeline(
+          backwards ? Direction.Backward : Direction.Forward
+        ) ?? timelineToPaginate;
+      // Decrypt all event ahead of render cycle
+      if (mx.isRoomEncrypted(fetchedTimeline.getRoomId() ?? '')) {
+        await to(decryptAllTimelineEvent(mx, fetchedTimeline));
+      }
+
+      fetching = false;
+      if (alive()) {
+        recalibratePagination(lTimelines, timelinesEventsCount, backwards);
+      }
+    };
+  }, [mx, alive, setTimeline, limit]);
+  return handleTimelinePagination;
+};
+
+const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) => {
+  useEffect(() => {
+    const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
+      mEvent,
+      eventRoom,
+      toStartOfTimeline,
+      removed,
+      data
+    ) => {
+      if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
+      onArrive(mEvent);
+    };
+    const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
+      if (eventRoom?.roomId !== room.roomId) return;
+      onArrive(mEvent);
+    };
+
+    room.on(RoomEvent.Timeline, handleTimelineEvent);
+    room.on(RoomEvent.Redaction, handleRedaction);
+    return () => {
+      room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
+      room.removeListener(RoomEvent.Redaction, handleRedaction);
+    };
+  }, [room, onArrive]);
+};
+
+const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
+  useEffect(() => {
+    const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => {
+      if (r.roomId !== room.roomId) return;
+      onRefresh();
+    };
+
+    room.on(RoomEvent.TimelineRefresh, handleTimelineRefresh);
+    return () => {
+      room.removeListener(RoomEvent.TimelineRefresh, handleTimelineRefresh);
+    };
+  }, [room, onRefresh]);
+};
+
+const getInitialTimeline = (room: Room) => {
+  const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
+  const evLength = getTimelinesEventsCount(linkedTimelines);
+  return {
+    linkedTimelines,
+    range: {
+      start: Math.max(evLength - PAGINATION_LIMIT, 0),
+      end: evLength,
+    },
+  };
+};
+
+const getEmptyTimeline = () => ({
+  range: { start: 0, end: 0 },
+  linkedTimelines: [],
+});
+
+const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
+  const readUptoEventId = room.getEventReadUpTo(room.client.getUserId() ?? '');
+  if (!readUptoEventId) return undefined;
+  const evtTimeline = getEventTimeline(room, readUptoEventId);
+  const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
+  return {
+    readUptoEventId,
+    inLiveTimeline: latestTimeline === room.getLiveTimeline(),
+    scrollTo,
+  };
+};
+
+export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
+  const mx = useMatrixClient();
+  const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
+  const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
+  const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
+  const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
+  const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
+  const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
+  const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
+  const { canDoAction, canSendEvent, getPowerLevel } = usePowerLevelsAPI();
+  const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
+  const canRedact = canDoAction('redact', myPowerLevel);
+  const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
+
+  const imagePackRooms: Room[] = useMemo(() => {
+    const allParentSpaces = [
+      room.roomId,
+      ...(initMatrix.roomList?.getAllParentSpaces(room.roomId) ?? []),
+    ];
+    return allParentSpaces.reduce<Room[]>((list, rId) => {
+      const r = mx.getRoom(rId);
+      if (r) list.push(r);
+      return list;
+    }, []);
+  }, [mx, room]);
+
+  const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
+  const readUptoEventIdRef = useRef<string>();
+  if (unreadInfo) {
+    readUptoEventIdRef.current = unreadInfo.readUptoEventId;
+  }
+
+  const atBottomAnchorRef = useRef<HTMLElement>(null);
+  const [atBottom, setAtBottom] = useState<boolean>();
+  const atBottomRef = useRef(atBottom);
+  atBottomRef.current = atBottom;
+
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const scrollToBottomRef = useRef({
+    count: 0,
+    smooth: true,
+  });
+
+  const focusItem = useRef<{
+    index: number;
+    scrollTo: boolean;
+    highlight: boolean;
+  }>();
+  const alive = useAlive();
+  const [, forceUpdate] = useForceUpdate();
+
+  const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
+    () =>
+      getReactCustomHtmlParser(mx, room, {
+        handleSpoilerClick: (evt) => {
+          const target = evt.currentTarget;
+          if (target.getAttribute('aria-pressed') === 'true') {
+            evt.stopPropagation();
+            target.setAttribute('aria-pressed', 'false');
+            target.style.cursor = 'initial';
+          }
+        },
+        handleMentionClick: (evt) => {
+          const target = evt.currentTarget;
+          const mentionId = target.getAttribute('data-mention-id');
+          if (typeof mentionId !== 'string') return;
+          if (isUserId(mentionId)) {
+            openProfileViewer(mentionId, room.roomId);
+            return;
+          }
+          if (isRoomId(mentionId) && mx.getRoom(mentionId)) {
+            selectRoom(mentionId);
+            return;
+          }
+          openJoinAlias(mentionId);
+        },
+      }),
+    [mx, room]
+  );
+  const parseMemberEvent = useMemberEventParser();
+
+  const [timeline, setTimeline] = useState<Timeline>(() =>
+    eventId ? getEmptyTimeline() : getInitialTimeline(room)
+  );
+  const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
+  const liveTimelineLinked =
+    timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
+  const canPaginateBack =
+    typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
+  const rangeAtStart = timeline.range.start === 0;
+  const rangeAtEnd = timeline.range.end === eventsLength;
+
+  const handleTimelinePagination = useTimelinePagination(
+    mx,
+    timeline,
+    setTimeline,
+    PAGINATION_LIMIT
+  );
+
+  const getScrollElement = useCallback(() => scrollRef.current, []);
+
+  const { getItems, scrollToItem, observeBackAnchor, observeFrontAnchor } = useVirtualPaginator({
+    count: eventsLength,
+    limit: PAGINATION_LIMIT,
+    range: timeline.range,
+    onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
+    getScrollElement,
+    getItemElement: useCallback(
+      (index: number) =>
+        (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
+        undefined,
+      []
+    ),
+    onEnd: handleTimelinePagination,
+  });
+
+  const loadEventTimeline = useEventTimelineLoader(
+    mx,
+    room,
+    useCallback(
+      (evtId, lTimelines, evtAbsIndex) => {
+        if (!alive()) return;
+        const evLength = getTimelinesEventsCount(lTimelines);
+
+        focusItem.current = {
+          index: evtAbsIndex,
+          scrollTo: true,
+          highlight: evtId !== unreadInfo?.readUptoEventId,
+        };
+        setTimeline({
+          linkedTimelines: lTimelines,
+          range: {
+            start: Math.max(evtAbsIndex - PAGINATION_LIMIT, 0),
+            end: Math.min(evtAbsIndex + PAGINATION_LIMIT, evLength),
+          },
+        });
+      },
+      [unreadInfo, alive]
+    ),
+    useCallback(() => {
+      if (!alive()) return;
+      setTimeline(getInitialTimeline(room));
+      scrollToBottomRef.current.count += 1;
+      scrollToBottomRef.current.smooth = false;
+    }, [alive, room])
+  );
+
+  useLiveEventArrive(
+    room,
+    useCallback(
+      (mEvt: MatrixEvent) => {
+        if (atBottomRef.current && document.hasFocus()) {
+          if (!unreadInfo && mEvt.getSender() !== mx.getUserId()) {
+            markAsRead(mEvt.getRoomId());
+          }
+
+          scrollToBottomRef.current.count += 1;
+          scrollToBottomRef.current.smooth = true;
+          setTimeline((ct) => ({
+            ...ct,
+            range: {
+              start: ct.range.start + 1,
+              end: ct.range.end + 1,
+            },
+          }));
+          return;
+        }
+        setTimeline((ct) => ({ ...ct }));
+        if (!unreadInfo) {
+          setUnreadInfo(getRoomUnreadInfo(room));
+        }
+      },
+      [mx, room, unreadInfo]
+    )
+  );
+
+  useLiveTimelineRefresh(
+    room,
+    useCallback(() => {
+      if (liveTimelineLinked) {
+        setTimeline(getInitialTimeline(room));
+      }
+    }, [room, liveTimelineLinked])
+  );
+
+  // Stay at bottom when room editor resize
+  useResizeObserver(
+    useCallback(
+      (entries) => {
+        if (!roomInputRef.current) return;
+        const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
+        const scrollElement = getScrollElement();
+        if (!editorBaseEntry || !scrollElement) return;
+
+        if (atBottomRef.current) {
+          scrollToBottom(scrollElement);
+        }
+      },
+      [getScrollElement, roomInputRef]
+    ),
+    useCallback(() => roomInputRef.current, [roomInputRef])
+  );
+
+  const handleAtBottomIntersection: OnIntersectionCallback = useCallback((entries) => {
+    const target = atBottomAnchorRef.current;
+    if (!target) return;
+    const targetEntry = getIntersectionObserverEntry(target, entries);
+
+    setAtBottom(targetEntry?.isIntersecting === true);
+  }, []);
+  useIntersectionObserver(
+    useDebounce(handleAtBottomIntersection, {
+      wait: 200,
+    }),
+    useMemo(
+      () => ({
+        root: getScrollElement(),
+        rootMargin: '100px',
+      }),
+      [getScrollElement]
+    ),
+    useCallback(() => atBottomAnchorRef.current, [])
+  );
+
+  useEffect(() => {
+    if (eventId) {
+      setTimeline(getEmptyTimeline());
+      loadEventTimeline(eventId);
+    }
+  }, [eventId, loadEventTimeline]);
+
+  // Scroll to bottom on initial timeline load
+  useLayoutEffect(() => {
+    const scrollEl = scrollRef.current;
+    if (scrollEl) scrollToBottom(scrollEl);
+  }, []);
+
+  // Scroll to last read message if it is linked to live timeline
+  useLayoutEffect(() => {
+    const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {};
+    if (readUptoEventId && inLiveTimeline && scrollTo) {
+      const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
+      const evtTimeline = getEventTimeline(room, readUptoEventId);
+      const absoluteIndex =
+        evtTimeline && getEventIdAbsoluteIndex(linkedTimelines, evtTimeline, readUptoEventId);
+      if (absoluteIndex)
+        scrollToItem(absoluteIndex, {
+          behavior: 'instant',
+          align: 'start',
+          stopInView: true,
+        });
+    }
+  }, [room, unreadInfo, scrollToItem]);
+
+  // scroll to focused message
+  const focusItm = focusItem.current;
+  useLayoutEffect(() => {
+    if (focusItm && focusItm.scrollTo) {
+      scrollToItem(focusItm.index, {
+        behavior: 'instant',
+        align: 'center',
+        stopInView: true,
+      });
+    }
+
+    focusItem.current = undefined;
+  }, [focusItm, scrollToItem]);
+
+  // scroll to bottom of timeline
+  const scrollToBottomCount = scrollToBottomRef.current.count;
+  useLayoutEffect(() => {
+    if (scrollToBottomCount > 0) {
+      const scrollEl = scrollRef.current;
+      if (scrollEl)
+        scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
+    }
+  }, [scrollToBottomCount]);
+
+  // send readReceipts when reach bottom
+  useEffect(() => {
+    if (liveTimelineLinked && rangeAtEnd && atBottom && document.hasFocus()) {
+      if (!unreadInfo) {
+        markAsRead(room.roomId);
+        return;
+      }
+      const evtTimeline = getEventTimeline(room, unreadInfo.readUptoEventId);
+      const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
+      if (latestTimeline === room.getLiveTimeline()) {
+        markAsRead();
+        setUnreadInfo(undefined);
+      }
+    }
+  }, [room, unreadInfo, liveTimelineLinked, rangeAtEnd, atBottom]);
+
+  const handleJumpToLatest = () => {
+    setTimeline(getInitialTimeline(room));
+    scrollToBottomRef.current.count += 1;
+    scrollToBottomRef.current.smooth = false;
+  };
+
+  const handleJumpToUnread = () => {
+    if (unreadInfo?.readUptoEventId) {
+      setTimeline(getEmptyTimeline());
+      loadEventTimeline(unreadInfo.readUptoEventId);
+    }
+  };
+
+  const handleMarkAsRead = () => {
+    markAsRead(room.roomId);
+    setUnreadInfo(undefined);
+  };
+
+  const handleOpenReply: MouseEventHandler<HTMLButtonElement> = useCallback(
+    async (evt) => {
+      const replyId = evt.currentTarget.getAttribute('data-reply-id');
+      if (typeof replyId !== 'string') return;
+      const replyTimeline = getEventTimeline(room, replyId);
+      const absoluteIndex =
+        replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, replyId);
+
+      if (typeof absoluteIndex === 'number') {
+        scrollToItem(absoluteIndex, {
+          behavior: 'smooth',
+          align: 'center',
+          stopInView: true,
+        });
+        focusItem.current = {
+          index: absoluteIndex,
+          scrollTo: false,
+          highlight: true,
+        };
+        forceUpdate();
+      } else {
+        setTimeline(getEmptyTimeline());
+        loadEventTimeline(replyId);
+      }
+    },
+    [room, timeline, scrollToItem, loadEventTimeline, forceUpdate]
+  );
+
+  const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
+    (evt) => {
+      evt.preventDefault();
+      evt.stopPropagation();
+      const userId = evt.currentTarget.getAttribute('data-user-id');
+      if (!userId) {
+        console.warn('Button should have "data-user-id" attribute!');
+        return;
+      }
+      openProfileViewer(userId, room.roomId);
+    },
+    [room]
+  );
+  const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
+    (evt) => {
+      evt.preventDefault();
+      const userId = evt.currentTarget.getAttribute('data-user-id');
+      if (!userId) {
+        console.warn('Button should have "data-user-id" attribute!');
+        return;
+      }
+      const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
+      editor.insertNode(
+        createMentionElement(
+          userId,
+          name.startsWith('@') ? name : `@${name}`,
+          userId === mx.getUserId()
+        )
+      );
+      ReactEditor.focus(editor);
+      moveCursor(editor);
+    },
+    [mx, room, editor]
+  );
+
+  const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
+    (evt) => {
+      const replyId = evt.currentTarget.getAttribute('data-event-id');
+      if (!replyId) {
+        console.warn('Button should have "data-event-id" attribute!');
+        return;
+      }
+      const replyEvt = room.findEventById(replyId);
+      if (!replyEvt) return;
+      const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
+      const { body, formatted_body: formattedBody }: Record<string, string> =
+        editedReply?.getContent()['m.new.content'] ?? replyEvt.getContent();
+      const senderId = replyEvt.getSender();
+      if (senderId && typeof body === 'string') {
+        setReplyDraft({
+          userId: senderId,
+          eventId: replyId,
+          body,
+          formattedBody,
+        });
+        setTimeout(() => ReactEditor.focus(editor), 100);
+      }
+    },
+    [room, setReplyDraft, editor]
+  );
+
+  const handleReactionToggle = useCallback(
+    (targetEventId: string, key: string, shortcode?: string) => {
+      const relations = getEventReactions(room.getUnfilteredTimelineSet(), targetEventId);
+      const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
+      const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
+      const reactions = reactionsSet ? Array.from(reactionsSet) : [];
+      const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));
+
+      if (myReaction && !!myReaction?.isRelation()) {
+        mx.redactEvent(room.roomId, myReaction.getId()!);
+        return;
+      }
+      const rShortcode =
+        shortcode ||
+        (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
+      mx.sendEvent(
+        room.roomId,
+        MessageEvent.Reaction,
+        getReactionContent(targetEventId, key, rShortcode)
+      );
+    },
+    [mx, room]
+  );
+
+  const renderBody = (body: string, customBody?: string) => {
+    if (body === '') <MessageEmptyContent />;
+    if (customBody) {
+      if (customBody === '') <MessageEmptyContent />;
+      return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
+    }
+    return <Linkify options={LINKIFY_OPTS}>{body}</Linkify>;
+  };
+
+  const renderRoomMsgContent = useRoomMsgContentRenderer<[EventTimelineSet]>({
+    renderText: (mEventId, mEvent, timelineSet) => {
+      const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
+      const { body, formatted_body: customBody }: Record<string, unknown> =
+        editedEvent?.getContent()['m.new.content'] ?? mEvent.getContent();
+
+      if (typeof body !== 'string') return null;
+      return (
+        <Text
+          as="div"
+          style={{
+            whiteSpace: typeof customBody === 'string' ? 'initial' : 'pre-wrap',
+            wordBreak: 'break-word',
+          }}
+          priority="400"
+        >
+          {renderBody(body, typeof customBody === 'string' ? customBody : undefined)}
+          {!!editedEvent && <MessageEditedContent />}
+        </Text>
+      );
+    },
+    renderEmote: (mEventId, mEvent, timelineSet) => {
+      const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
+      const { body, formatted_body: customBody } =
+        editedEvent?.getContent()['m.new.content'] ?? mEvent.getContent();
+      const senderId = mEvent.getSender() ?? '';
+
+      const senderDisplayName =
+        getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
+      return (
+        <Text
+          as="div"
+          style={{
+            color: color.Success.Main,
+            fontStyle: 'italic',
+            whiteSpace: customBody ? 'initial' : 'pre-wrap',
+            wordBreak: 'break-word',
+          }}
+          priority="400"
+        >
+          <b>{`${senderDisplayName} `}</b>
+          {renderBody(body, typeof customBody === 'string' ? customBody : undefined)}
+          {!!editedEvent && <MessageEditedContent />}
+        </Text>
+      );
+    },
+    renderNotice: (mEventId, mEvent, timelineSet) => {
+      const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
+      const { body, formatted_body: customBody }: Record<string, unknown> =
+        editedEvent?.getContent()['m.new.content'] ?? mEvent.getContent();
+
+      if (typeof body !== 'string') return null;
+      return (
+        <Text
+          as="div"
+          style={{
+            whiteSpace: typeof customBody === 'string' ? 'initial' : 'pre-wrap',
+            wordBreak: 'break-word',
+          }}
+          priority="300"
+        >
+          {renderBody(body, typeof customBody === 'string' ? customBody : undefined)}
+          {!!editedEvent && <MessageEditedContent />}
+        </Text>
+      );
+    },
+    renderImage: (mEventId, mEvent) => {
+      const content = mEvent.getContent<IImageContent>();
+      const imgInfo = content?.info;
+      const mxcUrl = content.file?.url ?? content.url;
+      if (!imgInfo || typeof imgInfo.mimetype !== 'string' || typeof mxcUrl !== 'string') {
+        if (mxcUrl) {
+          return fileRenderer(mEventId, mEvent);
+        }
+        return null;
+      }
+      const height = scaleYDimension(imgInfo.w || 400, 400, imgInfo.h || 400);
+
+      return (
+        <Attachment>
+          <AttachmentBox
+            style={{
+              height: toRem(height < 48 ? 48 : height),
+            }}
+          >
+            <ImageContent
+              body={content.body || 'Image'}
+              info={imgInfo}
+              mimeType={imgInfo.mimetype}
+              url={mxcUrl}
+              encInfo={content.file}
+              autoPlay={mediaAutoLoad}
+            />
+          </AttachmentBox>
+        </Attachment>
+      );
+    },
+    renderVideo: (mEventId, mEvent) => {
+      const content = mEvent.getContent<IVideoContent>();
+
+      const videoInfo = content?.info;
+      const mxcUrl = content.file?.url ?? content.url;
+      const safeMimeType = getBlobSafeMimeType(videoInfo?.mimetype ?? '');
+
+      if (!videoInfo || !safeMimeType.startsWith('video') || typeof mxcUrl !== 'string') {
+        if (mxcUrl) {
+          return fileRenderer(mEventId, mEvent);
+        }
+        return null;
+      }
+
+      const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
+
+      return (
+        <Attachment>
+          <AttachmentBox
+            style={{
+              height: toRem(height < 48 ? 48 : height),
+            }}
+          >
+            <VideoContent
+              body={content.body || 'Video'}
+              info={videoInfo}
+              mimeType={safeMimeType}
+              url={mxcUrl}
+              encInfo={content.file}
+              loadThumbnail={mediaAutoLoad}
+            />
+          </AttachmentBox>
+        </Attachment>
+      );
+    },
+    renderAudio: (mEventId, mEvent) => {
+      const content = mEvent.getContent<IAudioContent>();
+
+      const audioInfo = content?.info;
+      const mxcUrl = content.file?.url ?? content.url;
+      const safeMimeType = getBlobSafeMimeType(audioInfo?.mimetype ?? '');
+
+      if (!audioInfo || !safeMimeType.startsWith('audio') || typeof mxcUrl !== 'string') {
+        if (mxcUrl) {
+          return fileRenderer(mEventId, mEvent);
+        }
+        return null;
+      }
+
+      return (
+        <Attachment>
+          <AttachmentHeader>
+            <FileHeader body={content.body ?? 'Audio'} mimeType={safeMimeType} />
+          </AttachmentHeader>
+          <AttachmentBox>
+            <AttachmentContent>
+              <AudioContent
+                info={audioInfo}
+                mimeType={safeMimeType}
+                url={mxcUrl}
+                encInfo={content.file}
+              />
+            </AttachmentContent>
+          </AttachmentBox>
+        </Attachment>
+      );
+    },
+    renderLocation: (mEventId, mEvent) => {
+      const content = mEvent.getContent();
+      const geoUri = content.geo_uri;
+      if (typeof geoUri !== 'string') return null;
+      const location = parseGeoUri(geoUri);
+      return (
+        <Box direction="Column" alignItems="Start" gap="100">
+          <Text size="T400">{geoUri}</Text>
+          <Chip
+            as="a"
+            size="400"
+            href={`https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`}
+            target="_blank"
+            rel="noreferrer noopener"
+            variant="Primary"
+            radii="Pill"
+            before={<Icon src={Icons.External} size="50" />}
+          >
+            <Text size="B300">Open Location</Text>
+          </Chip>
+        </Box>
+      );
+    },
+    renderFile: fileRenderer,
+    renderBadEncrypted: () => (
+      <Text>
+        <MessageBadEncryptedContent />
+      </Text>
+    ),
+    renderUnsupported: (mEventId, mEvent) => {
+      if (mEvent.isRedacted()) {
+        const redactedEvt = mEvent.getRedactionEvent();
+        const reason =
+          redactedEvt && 'content' in redactedEvt ? redactedEvt.content.reason : undefined;
+
+        return (
+          <Text>
+            <MessageDeletedContent reason={reason} />
+          </Text>
+        );
+      }
+      return (
+        <Text>
+          <MessageUnsupportedContent />
+        </Text>
+      );
+    },
+    renderBrokenFallback: (mEventId, mEvent) => {
+      if (mEvent.isRedacted()) {
+        const redactedEvt = mEvent.getRedactionEvent();
+        const reason =
+          redactedEvt && 'content' in redactedEvt ? redactedEvt.content.reason : undefined;
+        return (
+          <Text>
+            <MessageDeletedContent reason={reason} />
+          </Text>
+        );
+      }
+      return (
+        <Text>
+          <MessageBrokenContent />
+        </Text>
+      );
+    },
+  });
+
+  const renderMatrixEvent = useMatrixEventRenderer<[number, EventTimelineSet, boolean]>({
+    renderRoomMessage: (mEventId, mEvent, item, timelineSet, collapse) => {
+      const reactionRelations = getEventReactions(timelineSet, mEventId);
+      const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
+      const hasReactions = reactions && reactions.length > 0;
+      const { replyEventId } = mEvent;
+      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+
+      return (
+        <Message
+          key={mEvent.getId()}
+          data-message-item={item}
+          room={room}
+          mEvent={mEvent}
+          messageSpacing={messageSpacing}
+          messageLayout={messageLayout}
+          collapse={collapse}
+          highlight={highlighted}
+          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+          canSendReaction={canSendReaction}
+          imagePackRooms={imagePackRooms}
+          relations={hasReactions ? reactionRelations : undefined}
+          onUserClick={handleUserClick}
+          onUsernameClick={handleUsernameClick}
+          onReplyClick={handleReplyClick}
+          onReactionToggle={handleReactionToggle}
+          reply={
+            replyEventId && (
+              <Reply
+                as="button"
+                mx={mx}
+                room={room}
+                timelineSet={timelineSet}
+                eventId={replyEventId}
+                data-reply-id={replyEventId}
+                onClick={handleOpenReply}
+              />
+            )
+          }
+          reactions={
+            reactionRelations && (
+              <Reactions
+                style={{ marginTop: config.space.S200 }}
+                room={room}
+                relations={reactionRelations}
+                mEventId={mEventId}
+                canSendReaction={canSendReaction}
+                onReactionToggle={handleReactionToggle}
+              />
+            )
+          }
+        >
+          {renderRoomMsgContent(mEventId, mEvent, timelineSet)}
+        </Message>
+      );
+    },
+    renderRoomEncrypted: (mEventId, mEvent, item, timelineSet, collapse) => {
+      const reactionRelations = getEventReactions(timelineSet, mEventId);
+      const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
+      const hasReactions = reactions && reactions.length > 0;
+      const { replyEventId } = mEvent;
+      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+
+      return (
+        <Message
+          key={mEvent.getId()}
+          data-message-item={item}
+          room={room}
+          mEvent={mEvent}
+          messageSpacing={messageSpacing}
+          messageLayout={messageLayout}
+          collapse={collapse}
+          highlight={highlighted}
+          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+          canSendReaction={canSendReaction}
+          imagePackRooms={imagePackRooms}
+          relations={hasReactions ? reactionRelations : undefined}
+          onUserClick={handleUserClick}
+          onUsernameClick={handleUsernameClick}
+          onReplyClick={handleReplyClick}
+          onReactionToggle={handleReactionToggle}
+          reply={
+            replyEventId && (
+              <Reply
+                as="button"
+                mx={mx}
+                room={room}
+                timelineSet={timelineSet}
+                eventId={replyEventId}
+                data-reply-id={replyEventId}
+                onClick={handleOpenReply}
+              />
+            )
+          }
+          reactions={
+            reactionRelations && (
+              <Reactions
+                style={{ marginTop: config.space.S200 }}
+                room={room}
+                relations={reactionRelations}
+                mEventId={mEventId}
+                canSendReaction={canSendReaction}
+                onReactionToggle={handleReactionToggle}
+              />
+            )
+          }
+        >
+          <EncryptedContent mEvent={mEvent}>
+            {() => {
+              if (mEvent.getType() === MessageEvent.Sticker)
+                return <StickerContent mEvent={mEvent} autoPlay={mediaAutoLoad} />;
+              if (mEvent.getType() === MessageEvent.RoomMessage)
+                return renderRoomMsgContent(mEventId, mEvent, timelineSet);
+              if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
+                return (
+                  <Text>
+                    <MessageNotDecryptedContent />
+                  </Text>
+                );
+              return (
+                <Text>
+                  <MessageUnsupportedContent />
+                </Text>
+              );
+            }}
+          </EncryptedContent>
+        </Message>
+      );
+    },
+    renderSticker: (mEventId, mEvent, item, timelineSet, collapse) => {
+      const reactionRelations = getEventReactions(timelineSet, mEventId);
+      const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
+      const hasReactions = reactions && reactions.length > 0;
+      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+
+      return (
+        <Message
+          key={mEvent.getId()}
+          data-message-item={item}
+          room={room}
+          mEvent={mEvent}
+          messageSpacing={messageSpacing}
+          messageLayout={messageLayout}
+          collapse={collapse}
+          highlight={highlighted}
+          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+          canSendReaction={canSendReaction}
+          imagePackRooms={imagePackRooms}
+          relations={hasReactions ? reactionRelations : undefined}
+          onUserClick={handleUserClick}
+          onUsernameClick={handleUsernameClick}
+          onReplyClick={handleReplyClick}
+          onReactionToggle={handleReactionToggle}
+          reactions={
+            reactionRelations && (
+              <Reactions
+                style={{ marginTop: config.space.S200 }}
+                room={room}
+                relations={reactionRelations}
+                mEventId={mEventId}
+                canSendReaction={canSendReaction}
+                onReactionToggle={handleReactionToggle}
+              />
+            )
+          }
+        >
+          <StickerContent mEvent={mEvent} autoPlay={mediaAutoLoad} />
+        </Message>
+      );
+    },
+    renderRoomMember: (mEventId, mEvent, item) => {
+      const membershipChanged =
+        mEvent.getContent().membership !== mEvent.getPrevContent().membership;
+      if (membershipChanged && hideMembershipEvents) return null;
+      if (!membershipChanged && hideNickAvatarEvents) return null;
+
+      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+      const parsed = parseMemberEvent(mEvent);
+
+      const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+      return (
+        <Event
+          key={mEvent.getId()}
+          data-message-item={item}
+          room={room}
+          mEvent={mEvent}
+          highlight={highlighted}
+          messageSpacing={messageSpacing}
+          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+        >
+          <EventContent
+            messageLayout={messageLayout}
+            time={timeJSX}
+            iconSrc={parsed.icon}
+            content={
+              <Box grow="Yes" direction="Column">
+                <Text size="T300" priority="300">
+                  {parsed.body}
+                </Text>
+              </Box>
+            }
+          />
+        </Event>
+      );
+    },
+    renderRoomName: (mEventId, mEvent, item) => {
+      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+      const senderId = mEvent.getSender() ?? '';
+      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+      const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+      return (
+        <Event
+          key={mEvent.getId()}
+          data-message-item={item}
+          room={room}
+          mEvent={mEvent}
+          highlight={highlighted}
+          messageSpacing={messageSpacing}
+          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+        >
+          <EventContent
+            messageLayout={messageLayout}
+            time={timeJSX}
+            iconSrc={Icons.Hash}
+            content={
+              <Box grow="Yes" direction="Column">
+                <Text size="T300" priority="300">
+                  <b>{senderName}</b>
+                  {' changed room name'}
+                </Text>
+              </Box>
+            }
+          />
+        </Event>
+      );
+    },
+    renderRoomTopic: (mEventId, mEvent, item) => {
+      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+      const senderId = mEvent.getSender() ?? '';
+      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+      const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+      return (
+        <Event
+          key={mEvent.getId()}
+          data-message-item={item}
+          room={room}
+          mEvent={mEvent}
+          highlight={highlighted}
+          messageSpacing={messageSpacing}
+          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+        >
+          <EventContent
+            messageLayout={messageLayout}
+            time={timeJSX}
+            iconSrc={Icons.Hash}
+            content={
+              <Box grow="Yes" direction="Column">
+                <Text size="T300" priority="300">
+                  <b>{senderName}</b>
+                  {' changed room topic'}
+                </Text>
+              </Box>
+            }
+          />
+        </Event>
+      );
+    },
+    renderRoomAvatar: (mEventId, mEvent, item) => {
+      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+      const senderId = mEvent.getSender() ?? '';
+      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+      const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+      return (
+        <Event
+          key={mEvent.getId()}
+          data-message-item={item}
+          room={room}
+          mEvent={mEvent}
+          highlight={highlighted}
+          messageSpacing={messageSpacing}
+          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+        >
+          <EventContent
+            messageLayout={messageLayout}
+            time={timeJSX}
+            iconSrc={Icons.Hash}
+            content={
+              <Box grow="Yes" direction="Column">
+                <Text size="T300" priority="300">
+                  <b>{senderName}</b>
+                  {' changed room avatar'}
+                </Text>
+              </Box>
+            }
+          />
+        </Event>
+      );
+    },
+    renderStateEvent: (mEventId, mEvent, item) => {
+      if (!showHiddenEvents) return null;
+      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+      const senderId = mEvent.getSender() ?? '';
+      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+      const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+      return (
+        <Event
+          key={mEvent.getId()}
+          data-message-item={item}
+          room={room}
+          mEvent={mEvent}
+          highlight={highlighted}
+          messageSpacing={messageSpacing}
+          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+        >
+          <EventContent
+            messageLayout={messageLayout}
+            time={timeJSX}
+            iconSrc={Icons.Code}
+            content={
+              <Box grow="Yes" direction="Column">
+                <Text size="T300" priority="300">
+                  <b>{senderName}</b>
+                  {' sent '}
+                  <code className={customHtmlCss.Code}>{mEvent.getType()}</code>
+                  {' state event'}
+                </Text>
+              </Box>
+            }
+          />
+        </Event>
+      );
+    },
+    renderEvent: (mEventId, mEvent, item) => {
+      if (!showHiddenEvents) return null;
+      if (Object.keys(mEvent.getContent()).length === 0) return null;
+      if (mEvent.getRelation()) return null;
+      if (mEvent.isRedaction()) return null;
+
+      const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+      const senderId = mEvent.getSender() ?? '';
+      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+      const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+      return (
+        <Event
+          key={mEvent.getId()}
+          data-message-item={item}
+          room={room}
+          mEvent={mEvent}
+          highlight={highlighted}
+          messageSpacing={messageSpacing}
+          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+        >
+          <EventContent
+            messageLayout={messageLayout}
+            time={timeJSX}
+            iconSrc={Icons.Code}
+            content={
+              <Box grow="Yes" direction="Column">
+                <Text size="T300" priority="300">
+                  <b>{senderName}</b>
+                  {' sent '}
+                  <code className={customHtmlCss.Code}>{mEvent.getType()}</code>
+                  {' event'}
+                </Text>
+              </Box>
+            }
+          />
+        </Event>
+      );
+    },
+  });
+
+  let prevEvent: MatrixEvent | undefined;
+  let isPrevRendered = false;
+  let newDivider = false;
+  let dayDivider = false;
+  const eventRenderer = (item: number) => {
+    const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
+    if (!eventTimeline) return null;
+    const timelineSet = eventTimeline?.getTimelineSet();
+    const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
+    const mEventId = mEvent?.getId();
+
+    if (!mEvent || !mEventId) return null;
+
+    if (!newDivider && readUptoEventIdRef.current) {
+      newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
+    }
+    if (!dayDivider) {
+      dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
+    }
+
+    const collapsed =
+      isPrevRendered &&
+      !dayDivider &&
+      (!newDivider || mEvent.getSender() === mx.getUserId()) &&
+      prevEvent !== undefined &&
+      prevEvent.getSender() === mEvent.getSender() &&
+      prevEvent.getType() === mEvent.getType() &&
+      minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
+
+    const eventJSX = mEvent.isRelation()
+      ? null
+      : renderMatrixEvent(mEventId, mEvent, item, timelineSet, collapsed);
+    prevEvent = mEvent;
+    isPrevRendered = !!eventJSX;
+
+    const newDividerJSX =
+      newDivider && eventJSX && mEvent.getSender() !== mx.getUserId() ? (
+        <MessageBase space={messageSpacing}>
+          <TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
+            <Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
+              <Text size="L400">New Messages</Text>
+            </Badge>
+          </TimelineDivider>
+        </MessageBase>
+      ) : null;
+
+    const dayDividerJSX =
+      dayDivider && eventJSX ? (
+        <MessageBase space={messageSpacing}>
+          <TimelineDivider variant="Surface">
+            <Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
+              <Text size="L400">
+                {(() => {
+                  if (today(mEvent.getTs())) return 'Today';
+                  if (yesterday(mEvent.getTs())) return 'Yesterday';
+                  return timeDayMonthYear(mEvent.getTs());
+                })()}
+              </Text>
+            </Badge>
+          </TimelineDivider>
+        </MessageBase>
+      ) : null;
+
+    if (eventJSX && (newDividerJSX || dayDividerJSX)) {
+      if (newDividerJSX) newDivider = false;
+      if (dayDividerJSX) dayDivider = false;
+
+      return (
+        <React.Fragment key={mEventId}>
+          {newDividerJSX}
+          {dayDividerJSX}
+          {eventJSX}
+        </React.Fragment>
+      );
+    }
+
+    return eventJSX;
+  };
+
+  return (
+    <Box style={{ height: '100%', color: color.Surface.OnContainer }} grow="Yes">
+      {unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
+        <TimelineFloat position="Top">
+          <Chip
+            variant="Primary"
+            radii="Pill"
+            outlined
+            before={<Icon size="50" src={Icons.MessageUnread} />}
+            onClick={handleJumpToUnread}
+          >
+            <Text size="L400">Jump to Unread</Text>
+          </Chip>
+
+          <Chip
+            variant="SurfaceVariant"
+            radii="Pill"
+            outlined
+            before={<Icon size="50" src={Icons.CheckTwice} />}
+            onClick={handleMarkAsRead}
+          >
+            <Text size="L400">Mark as Read</Text>
+          </Chip>
+        </TimelineFloat>
+      )}
+      <Scroll ref={scrollRef} visibility="Hover">
+        <Box
+          direction="Column"
+          justifyContent="End"
+          style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
+        >
+          {!canPaginateBack && rangeAtStart && getItems().length > 0 && (
+            <div
+              style={{
+                padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
+                  messageLayout === 1 ? config.space.S400 : toRem(64)
+                }`,
+              }}
+            >
+              <RoomIntro room={room} />
+            </div>
+          )}
+          {(canPaginateBack || !rangeAtStart) &&
+            (messageLayout === 1 ? (
+              <>
+                <CompactPlaceholder />
+                <CompactPlaceholder />
+                <CompactPlaceholder />
+                <CompactPlaceholder />
+                <CompactPlaceholder ref={observeBackAnchor} />
+              </>
+            ) : (
+              <>
+                <DefaultPlaceholder />
+                <DefaultPlaceholder />
+                <DefaultPlaceholder ref={observeBackAnchor} />
+              </>
+            ))}
+
+          {getItems().map(eventRenderer)}
+
+          {(!liveTimelineLinked || !rangeAtEnd) &&
+            (messageLayout === 1 ? (
+              <>
+                <CompactPlaceholder ref={observeFrontAnchor} />
+                <CompactPlaceholder />
+                <CompactPlaceholder />
+                <CompactPlaceholder />
+                <CompactPlaceholder />
+              </>
+            ) : (
+              <>
+                <DefaultPlaceholder ref={observeFrontAnchor} />
+                <DefaultPlaceholder />
+                <DefaultPlaceholder />
+              </>
+            ))}
+          <span ref={atBottomAnchorRef} />
+        </Box>
+      </Scroll>
+      {(atBottom === false || !liveTimelineLinked || !rangeAtEnd) && (
+        <TimelineFloat position="Bottom">
+          <Chip
+            variant="SurfaceVariant"
+            radii="Pill"
+            outlined
+            before={<Icon size="50" src={Icons.ArrowBottom} />}
+            onClick={handleJumpToLatest}
+          >
+            <Text size="L400">Jump to Latest</Text>
+          </Chip>
+        </TimelineFloat>
+      )}
+    </Box>
+  );
+}
index 9b3ae76fa4a99cd19ff828c1e0a175832f2c4729..e183c9510bfccec39677edff2d023179fb3c14bb 100644 (file)
@@ -4,34 +4,33 @@ import './RoomView.scss';
 import { Text, config } from 'folds';
 import { EventType } from 'matrix-js-sdk';
 
-import EventEmitter from 'events';
-
 import cons from '../../../client/state/cons';
 import navigation from '../../../client/state/navigation';
 
 import RoomViewHeader from './RoomViewHeader';
-import RoomViewContent from './RoomViewContent';
-import RoomViewFloating from './RoomViewFloating';
-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 { usePowerLevelsAPI } from '../../hooks/usePowerLevels';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { RoomInputPlaceholder } from './RoomInputPlaceholder';
+import { RoomTimeline } from './RoomTimeline';
+import { RoomViewTyping } from './RoomViewTyping';
+import { RoomViewFollowing } from './RoomViewFollowing';
+import { useEditor } from '../../components/editor';
 
-const viewEvent = new EventEmitter();
-
-function RoomView({ room, roomTimeline, eventId }) {
+function RoomView({ room, eventId }) {
   const roomInputRef = useRef(null);
   const roomViewRef = useRef(null);
+
   // eslint-disable-next-line react/prop-types
-  const { roomId } = roomTimeline;
+  const { roomId } = room;
+  const editor = useEditor();
 
   const mx = useMatrixClient();
   const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
-  const { getPowerLevel, canSendEvent } = usePowerLevels(room);
+  const { getPowerLevel, canSendEvent } = usePowerLevelsAPI();
   const myUserId = mx.getUserId();
   const canMessage = myUserId
     ? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId))
@@ -61,12 +60,14 @@ function RoomView({ room, roomTimeline, eventId }) {
       <RoomViewHeader roomId={roomId} />
       <div className="room-view__content-wrapper">
         <div className="room-view__scrollable">
-          <RoomViewContent
+          <RoomTimeline
+            key={roomId}
+            room={room}
             eventId={eventId}
-            roomTimeline={roomTimeline}
             roomInputRef={roomInputRef}
+            editor={editor}
           />
-          <RoomViewFloating roomId={roomId} roomTimeline={roomTimeline} />
+          <RoomViewTyping room={room} />
         </div>
         <div className="room-view__sticky">
           <div className="room-view__editor">
@@ -79,7 +80,12 @@ function RoomView({ room, roomTimeline, eventId }) {
             ) : (
               <>
                 {canMessage && (
-                  <RoomInput roomId={roomId} roomViewRef={roomViewRef} ref={roomInputRef} />
+                  <RoomInput
+                    editor={editor}
+                    roomId={roomId}
+                    roomViewRef={roomViewRef}
+                    ref={roomInputRef}
+                  />
                 )}
                 {!canMessage && (
                   <RoomInputPlaceholder
@@ -93,7 +99,7 @@ function RoomView({ room, roomTimeline, eventId }) {
               </>
             )}
           </div>
-          <RoomViewCmdBar roomId={roomId} roomTimeline={roomTimeline} viewEvent={viewEvent} />
+          <RoomViewFollowing room={room} />
         </div>
       </div>
     </div>
@@ -105,7 +111,6 @@ RoomView.defaultProps = {
 };
 RoomView.propTypes = {
   room: PropTypes.shape({}).isRequired,
-  roomTimeline: PropTypes.shape({}).isRequired,
   eventId: PropTypes.string,
 };
 
diff --git a/src/app/organisms/room/RoomViewFollowing.css.ts b/src/app/organisms/room/RoomViewFollowing.css.ts
new file mode 100644 (file)
index 0000000..0a0358e
--- /dev/null
@@ -0,0 +1,31 @@
+import { recipe } from '@vanilla-extract/recipes';
+import { DefaultReset, color, config, toRem } from 'folds';
+
+export const RoomViewFollowing = recipe({
+  base: [
+    DefaultReset,
+    {
+      minHeight: toRem(28),
+      padding: `0 ${config.space.S400}`,
+      width: '100%',
+      backgroundColor: color.Surface.Container,
+      color: color.Surface.OnContainer,
+      outline: 'none',
+    },
+  ],
+  variants: {
+    clickable: {
+      true: {
+        cursor: 'pointer',
+        selectors: {
+          '&:hover, &:focus-visible': {
+            color: color.Primary.Main,
+          },
+          '&:active': {
+            color: color.Primary.Main,
+          },
+        },
+      },
+    },
+  },
+});
diff --git a/src/app/organisms/room/RoomViewFollowing.tsx b/src/app/organisms/room/RoomViewFollowing.tsx
new file mode 100644 (file)
index 0000000..cd62c42
--- /dev/null
@@ -0,0 +1,141 @@
+import React, { useState } from 'react';
+import {
+  Box,
+  Icon,
+  Icons,
+  Modal,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Text,
+  as,
+  config,
+} from 'folds';
+import { Room, RoomMember } from 'matrix-js-sdk';
+import classNames from 'classnames';
+import FocusTrap from 'focus-trap-react';
+
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import * as css from './RoomViewFollowing.css';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useRoomLatestEvent } from '../../hooks/useRoomLatestEvent';
+import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
+import { EventReaders } from '../../components/event-readers';
+
+export type RoomViewFollowingProps = {
+  room: Room;
+};
+export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
+  ({ className, room, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const [open, setOpen] = useState(false);
+    const latestEvent = useRoomLatestEvent(room);
+    const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId());
+    const followingMembers = latestEventReaders
+      .map((readerId) => room.getMember(readerId))
+      .filter((member) => member && member.userId !== mx.getUserId()) as RoomMember[];
+
+    const names = followingMembers.map(
+      (member) => getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId)
+    );
+
+    const eventId = latestEvent?.getId();
+
+    return (
+      <>
+        {eventId && (
+          <Overlay open={open} backdrop={<OverlayBackdrop />}>
+            <OverlayCenter>
+              <FocusTrap
+                focusTrapOptions={{
+                  initialFocus: false,
+                  onDeactivate: () => setOpen(false),
+                  clickOutsideDeactivates: true,
+                }}
+              >
+                <Modal variant="Surface" size="300">
+                  <EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
+                </Modal>
+              </FocusTrap>
+            </OverlayCenter>
+          </Overlay>
+        )}
+        <Box
+          as={names.length > 0 ? 'button' : 'div'}
+          onClick={names.length > 0 ? () => setOpen(true) : undefined}
+          className={classNames(css.RoomViewFollowing({ clickable: names.length > 0 }), className)}
+          alignItems="Center"
+          justifyContent="End"
+          gap="200"
+          {...props}
+          ref={ref}
+        >
+          {names.length > 0 && (
+            <>
+              <Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
+              <Text size="T300" truncate>
+                {names.length === 1 && (
+                  <>
+                    <b>{names[0]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {' is following the conversation.'}
+                    </Text>
+                  </>
+                )}
+                {names.length === 2 && (
+                  <>
+                    <b>{names[0]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {' and '}
+                    </Text>
+                    <b>{names[1]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {' are following the conversation.'}
+                    </Text>
+                  </>
+                )}
+                {names.length === 3 && (
+                  <>
+                    <b>{names[0]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {', '}
+                    </Text>
+                    <b>{names[1]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {' and '}
+                    </Text>
+                    <b>{names[2]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {' are following the conversation.'}
+                    </Text>
+                  </>
+                )}
+                {names.length > 3 && (
+                  <>
+                    <b>{names[0]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {', '}
+                    </Text>
+                    <b>{names[1]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {', '}
+                    </Text>
+                    <b>{names[2]}</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {' and '}
+                    </Text>
+                    <b>{names.length - 3} others</b>
+                    <Text as="span" size="Inherit" priority="300">
+                      {' are following the conversation.'}
+                    </Text>
+                  </>
+                )}
+              </Text>
+            </>
+          )}
+        </Box>
+      </>
+    );
+  }
+);
diff --git a/src/app/organisms/room/RoomViewTyping.css.ts b/src/app/organisms/room/RoomViewTyping.css.ts
new file mode 100644 (file)
index 0000000..ef07316
--- /dev/null
@@ -0,0 +1,24 @@
+import { keyframes, style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
+
+const SlideUpAnime = keyframes({
+  from: {
+    transform: 'translateY(100%)',
+  },
+  to: {
+    transform: 'translateY(0)',
+  },
+});
+
+export const RoomViewTyping = style([
+  DefaultReset,
+  {
+    padding: `${config.space.S100} ${config.space.S500}`,
+    width: '100%',
+    backgroundColor: color.Surface.Container,
+    color: color.Surface.OnContainer,
+    position: 'absolute',
+    bottom: 0,
+    animation: `${SlideUpAnime} 100ms ease-in-out`,
+  },
+]);
diff --git a/src/app/organisms/room/RoomViewTyping.tsx b/src/app/organisms/room/RoomViewTyping.tsx
new file mode 100644 (file)
index 0000000..c7c15ea
--- /dev/null
@@ -0,0 +1,102 @@
+import React, { useMemo } from 'react';
+import { Box, Text, as } from 'folds';
+import { Room } from 'matrix-js-sdk';
+import classNames from 'classnames';
+import { useAtomValue } from 'jotai';
+import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../../state/typingMembers';
+import { TypingIndicator } from '../../components/typing-indicator';
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import * as css from './RoomViewTyping.css';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+
+export type RoomViewTypingProps = {
+  room: Room;
+};
+export const RoomViewTyping = as<'div', RoomViewTypingProps>(
+  ({ className, room, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const typingMembers = useAtomValue(
+      useMemo(() => selectRoomTypingMembersAtom(room.roomId, roomIdToTypingMembersAtom), [room])
+    );
+
+    const typingNames = typingMembers
+      .filter((member) => member.userId !== mx.getUserId())
+      .map((member) => getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId))
+      .reverse();
+
+    if (typingNames.length === 0) {
+      return null;
+    }
+
+    return (
+      <Box
+        className={classNames(css.RoomViewTyping, className)}
+        alignItems="Center"
+        gap="400"
+        {...props}
+        ref={ref}
+      >
+        <TypingIndicator />
+        <Text size="T300" truncate>
+          {typingNames.length === 1 && (
+            <>
+              <b>{typingNames[0]}</b>
+              <Text as="span" size="Inherit" priority="300">
+                {' is typing...'}
+              </Text>
+            </>
+          )}
+          {typingNames.length === 2 && (
+            <>
+              <b>{typingNames[0]}</b>
+              <Text as="span" size="Inherit" priority="300">
+                {' and '}
+              </Text>
+              <b>{typingNames[1]}</b>
+              <Text as="span" size="Inherit" priority="300">
+                {' are typing...'}
+              </Text>
+            </>
+          )}
+          {typingNames.length === 3 && (
+            <>
+              <b>{typingNames[0]}</b>
+              <Text as="span" size="Inherit" priority="300">
+                {', '}
+              </Text>
+              <b>{typingNames[1]}</b>
+              <Text as="span" size="Inherit" priority="300">
+                {' and '}
+              </Text>
+              <b>{typingNames[2]}</b>
+              <Text as="span" size="Inherit" priority="300">
+                {' are typing...'}
+              </Text>
+            </>
+          )}
+          {typingNames.length > 3 && (
+            <>
+              <b>{typingNames[0]}</b>
+              <Text as="span" size="Inherit" priority="300">
+                {', '}
+              </Text>
+              <b>{typingNames[1]}</b>
+              <Text as="span" size="Inherit" priority="300">
+                {', '}
+              </Text>
+              <b>{typingNames[2]}</b>
+              <Text as="span" size="Inherit" priority="300">
+                {' and '}
+              </Text>
+              <b>{typingNames.length - 3} others</b>
+              <Text as="span" size="Inherit" priority="300">
+                {' are typing...'}
+              </Text>
+            </>
+          )}
+        </Text>
+      </Box>
+    );
+  }
+);
diff --git a/src/app/organisms/room/message/AudioContent.tsx b/src/app/organisms/room/message/AudioContent.tsx
new file mode 100644 (file)
index 0000000..b5873f3
--- /dev/null
@@ -0,0 +1,192 @@
+/* eslint-disable jsx-a11y/media-has-caption */
+import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, as, toRem } from 'folds';
+import React, { useCallback, useRef, useState } from 'react';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { Range } from 'react-range';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { getFileSrcUrl } from './util';
+import { IAudioInfo } from '../../../../types/matrix/common';
+import { MediaControl } from '../../../components/media';
+import {
+  PlayTimeCallback,
+  useMediaLoading,
+  useMediaPlay,
+  useMediaPlayTimeCallback,
+  useMediaSeek,
+  useMediaVolume,
+} from '../../../hooks/media';
+import { useThrottle } from '../../../hooks/useThrottle';
+import { secondsToMinutesAndSeconds } from '../../../utils/common';
+
+const PLAY_TIME_THROTTLE_OPS = {
+  wait: 500,
+  immediate: true,
+};
+
+export type AudioContentProps = {
+  mimeType: string;
+  url: string;
+  info: IAudioInfo;
+  encInfo?: EncryptedAttachmentInfo;
+};
+export const AudioContent = as<'div', AudioContentProps>(
+  ({ mimeType, url, info, encInfo, ...props }, ref) => {
+    const mx = useMatrixClient();
+
+    const [srcState, loadSrc] = useAsyncCallback(
+      useCallback(
+        () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
+        [mx, url, mimeType, encInfo]
+      )
+    );
+
+    const audioRef = useRef<HTMLAudioElement | null>(null);
+
+    const [currentTime, setCurrentTime] = useState(0);
+    const [duration, setDuration] = useState(info.duration ?? 0);
+
+    const getAudioRef = useCallback(() => audioRef.current, []);
+    const { loading } = useMediaLoading(getAudioRef);
+    const { playing, setPlaying } = useMediaPlay(getAudioRef);
+    const { seek } = useMediaSeek(getAudioRef);
+    const { volume, mute, setMute, setVolume } = useMediaVolume(getAudioRef);
+    const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => {
+      setDuration(d);
+      setCurrentTime(ct);
+    }, []);
+    useMediaPlayTimeCallback(
+      getAudioRef,
+      useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS)
+    );
+
+    const handlePlay = () => {
+      if (srcState.status === AsyncStatus.Success) {
+        setPlaying(!playing);
+      } else if (srcState.status !== AsyncStatus.Loading) {
+        loadSrc();
+      }
+    };
+
+    return (
+      <MediaControl
+        after={
+          <Range
+            step={1}
+            min={0}
+            max={duration || 1}
+            values={[currentTime]}
+            onChange={(values) => seek(values[0])}
+            renderTrack={(params) => (
+              <div {...params.props}>
+                {params.children}
+                <ProgressBar
+                  as="div"
+                  variant="Secondary"
+                  size="300"
+                  min={0}
+                  max={duration}
+                  value={currentTime}
+                  radii="300"
+                />
+              </div>
+            )}
+            renderThumb={(params) => (
+              <Badge
+                size="300"
+                variant="Secondary"
+                fill="Solid"
+                radii="Pill"
+                outlined
+                {...params.props}
+                style={{
+                  ...params.props.style,
+                  zIndex: 0,
+                }}
+              />
+            )}
+          />
+        }
+        leftControl={
+          <>
+            <Chip
+              onClick={handlePlay}
+              variant="Secondary"
+              radii="300"
+              disabled={srcState.status === AsyncStatus.Loading}
+              before={
+                srcState.status === AsyncStatus.Loading || loading ? (
+                  <Spinner variant="Secondary" size="50" />
+                ) : (
+                  <Icon src={playing ? Icons.Pause : Icons.Play} size="50" filled={playing} />
+                )
+              }
+            >
+              <Text size="B300">{playing ? 'Pause' : 'Play'}</Text>
+            </Chip>
+
+            <Text size="T200">{`${secondsToMinutesAndSeconds(
+              currentTime
+            )} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
+          </>
+        }
+        rightControl={
+          <>
+            <IconButton
+              variant="SurfaceVariant"
+              size="300"
+              radii="Pill"
+              onClick={() => setMute(!mute)}
+              aria-pressed={mute}
+            >
+              <Icon src={mute ? Icons.VolumeMute : Icons.VolumeHigh} size="50" />
+            </IconButton>
+            <Range
+              step={0.1}
+              min={0}
+              max={1}
+              values={[volume]}
+              onChange={(values) => setVolume(values[0])}
+              renderTrack={(params) => (
+                <div {...params.props}>
+                  {params.children}
+                  <ProgressBar
+                    style={{ width: toRem(48) }}
+                    variant="Secondary"
+                    size="300"
+                    min={0}
+                    max={1}
+                    value={volume}
+                    radii="300"
+                  />
+                </div>
+              )}
+              renderThumb={(params) => (
+                <Badge
+                  size="300"
+                  variant="Secondary"
+                  fill="Solid"
+                  radii="Pill"
+                  outlined
+                  {...params.props}
+                  style={{
+                    ...params.props.style,
+                    zIndex: 0,
+                  }}
+                />
+              )}
+            />
+          </>
+        }
+        {...props}
+        ref={ref}
+      >
+        <audio controls={false} autoPlay ref={audioRef}>
+          {srcState.status === AsyncStatus.Success && (
+            <source src={srcState.data} type={mimeType} />
+          )}
+        </audio>
+      </MediaControl>
+    );
+  }
+);
diff --git a/src/app/organisms/room/message/EncryptedContent.tsx b/src/app/organisms/room/message/EncryptedContent.tsx
new file mode 100644 (file)
index 0000000..97aa9cc
--- /dev/null
@@ -0,0 +1,22 @@
+import { MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from 'matrix-js-sdk';
+import React, { ReactNode, useEffect, useState } from 'react';
+
+type EncryptedContentProps = {
+  mEvent: MatrixEvent;
+  children: () => ReactNode;
+};
+
+export function EncryptedContent({ mEvent, children }: EncryptedContentProps) {
+  const [, setDecrypted] = useState(mEvent.isBeingDecrypted());
+
+  useEffect(() => {
+    const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = () =>
+      setDecrypted(true);
+    mEvent.on(MatrixEventEvent.Decrypted, handleDecrypted);
+    return () => {
+      mEvent.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
+    };
+  }, [mEvent]);
+
+  return <>{children()}</>;
+}
diff --git a/src/app/organisms/room/message/EventContent.tsx b/src/app/organisms/room/message/EventContent.tsx
new file mode 100644 (file)
index 0000000..e60333d
--- /dev/null
@@ -0,0 +1,37 @@
+import { Box, Icon, IconSrc } from 'folds';
+import React, { ReactNode } from 'react';
+import { CompactLayout, ModernLayout } from '../../../components/message';
+
+export type EventContentProps = {
+  messageLayout: number;
+  time: ReactNode;
+  iconSrc: IconSrc;
+  content: ReactNode;
+};
+export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
+  const beforeJSX = (
+    <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
+      {messageLayout === 1 && time}
+      <Box
+        grow={messageLayout === 1 ? undefined : 'Yes'}
+        alignItems="Center"
+        justifyContent="Center"
+      >
+        <Icon style={{ opacity: 0.6 }} size="50" src={iconSrc} />
+      </Box>
+    </Box>
+  );
+
+  const msgContentJSX = (
+    <Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200">
+      {content}
+      {messageLayout !== 1 && time}
+    </Box>
+  );
+
+  return messageLayout === 1 ? (
+    <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
+  ) : (
+    <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>
+  );
+}
diff --git a/src/app/organisms/room/message/FileContent.tsx b/src/app/organisms/room/message/FileContent.tsx
new file mode 100644 (file)
index 0000000..8484d84
--- /dev/null
@@ -0,0 +1,250 @@
+import React, { useCallback, useState } from 'react';
+import {
+  Box,
+  Button,
+  Icon,
+  Icons,
+  Modal,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Spinner,
+  Text,
+  Tooltip,
+  TooltipProvider,
+  as,
+} from 'folds';
+import FileSaver from 'file-saver';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import FocusTrap from 'focus-trap-react';
+import { IFileInfo } from '../../../../types/matrix/common';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getFileSrcUrl, getSrcFile } from './util';
+import { bytesToSize } from '../../../utils/common';
+import { TextViewer } from '../../../components/text-viewer';
+import { READABLE_TEXT_MIME_TYPES } from '../../../utils/mimeTypes';
+import { PdfViewer } from '../../../components/Pdf-viewer';
+
+export type FileContentProps = {
+  body: string;
+  mimeType: string;
+  url: string;
+  info: IFileInfo;
+  encInfo?: EncryptedAttachmentInfo;
+};
+
+const renderErrorButton = (retry: () => void, text: string) => (
+  <TooltipProvider
+    tooltip={
+      <Tooltip variant="Critical">
+        <Text>Failed to load file!</Text>
+      </Tooltip>
+    }
+    position="Top"
+    align="Center"
+  >
+    {(triggerRef) => (
+      <Button
+        ref={triggerRef}
+        size="400"
+        variant="Critical"
+        fill="Soft"
+        outlined
+        radii="300"
+        onClick={retry}
+        before={<Icon size="100" src={Icons.Warning} filled />}
+      >
+        <Text size="B400" truncate>
+          {text}
+        </Text>
+      </Button>
+    )}
+  </TooltipProvider>
+);
+
+function ReadTextFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, 'info'>) {
+  const mx = useMatrixClient();
+  const [textViewer, setTextViewer] = useState(false);
+
+  const loadSrc = useCallback(
+    () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
+    [mx, url, mimeType, encInfo]
+  );
+
+  const [textState, loadText] = useAsyncCallback(
+    useCallback(async () => {
+      const src = await loadSrc();
+      const blob = await getSrcFile(src);
+      const text = blob.text();
+      setTextViewer(true);
+      return text;
+    }, [loadSrc])
+  );
+
+  return (
+    <>
+      {textState.status === AsyncStatus.Success && (
+        <Overlay open={textViewer} backdrop={<OverlayBackdrop />}>
+          <OverlayCenter>
+            <FocusTrap
+              focusTrapOptions={{
+                initialFocus: false,
+                onDeactivate: () => setTextViewer(false),
+                clickOutsideDeactivates: true,
+              }}
+            >
+              <Modal size="500">
+                <TextViewer
+                  name={body}
+                  text={textState.data}
+                  mimeType={mimeType}
+                  requestClose={() => setTextViewer(false)}
+                />
+              </Modal>
+            </FocusTrap>
+          </OverlayCenter>
+        </Overlay>
+      )}
+      {textState.status === AsyncStatus.Error ? (
+        renderErrorButton(loadText, 'Open File')
+      ) : (
+        <Button
+          variant="Secondary"
+          fill="Solid"
+          radii="300"
+          size="400"
+          onClick={() =>
+            textState.status === AsyncStatus.Success ? setTextViewer(true) : loadText()
+          }
+          disabled={textState.status === AsyncStatus.Loading}
+          before={
+            textState.status === AsyncStatus.Loading ? (
+              <Spinner fill="Solid" size="100" variant="Secondary" />
+            ) : (
+              <Icon size="100" src={Icons.ArrowRight} filled />
+            )
+          }
+        >
+          <Text size="B400" truncate>
+            Open File
+          </Text>
+        </Button>
+      )}
+    </>
+  );
+}
+
+function ReadPdfFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, 'info'>) {
+  const mx = useMatrixClient();
+  const [pdfViewer, setPdfViewer] = useState(false);
+
+  const [pdfState, loadPdf] = useAsyncCallback(
+    useCallback(async () => {
+      const httpUrl = await getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo);
+      setPdfViewer(true);
+      return httpUrl;
+    }, [mx, url, mimeType, encInfo])
+  );
+
+  return (
+    <>
+      {pdfState.status === AsyncStatus.Success && (
+        <Overlay open={pdfViewer} backdrop={<OverlayBackdrop />}>
+          <OverlayCenter>
+            <FocusTrap
+              focusTrapOptions={{
+                initialFocus: false,
+                onDeactivate: () => setPdfViewer(false),
+                clickOutsideDeactivates: true,
+              }}
+            >
+              <Modal size="500">
+                <PdfViewer
+                  name={body}
+                  src={pdfState.data}
+                  requestClose={() => setPdfViewer(false)}
+                />
+              </Modal>
+            </FocusTrap>
+          </OverlayCenter>
+        </Overlay>
+      )}
+      {pdfState.status === AsyncStatus.Error ? (
+        renderErrorButton(loadPdf, 'Open PDF')
+      ) : (
+        <Button
+          variant="Secondary"
+          fill="Solid"
+          radii="300"
+          size="400"
+          onClick={() => (pdfState.status === AsyncStatus.Success ? setPdfViewer(true) : loadPdf())}
+          disabled={pdfState.status === AsyncStatus.Loading}
+          before={
+            pdfState.status === AsyncStatus.Loading ? (
+              <Spinner fill="Solid" size="100" variant="Secondary" />
+            ) : (
+              <Icon size="100" src={Icons.ArrowRight} filled />
+            )
+          }
+        >
+          <Text size="B400" truncate>
+            Open PDF
+          </Text>
+        </Button>
+      )}
+    </>
+  );
+}
+
+function DownloadFile({ body, mimeType, url, info, encInfo }: FileContentProps) {
+  const mx = useMatrixClient();
+
+  const [downloadState, download] = useAsyncCallback(
+    useCallback(async () => {
+      const httpUrl = await getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo);
+      FileSaver.saveAs(httpUrl, body);
+      return httpUrl;
+    }, [mx, url, mimeType, encInfo, body])
+  );
+
+  return downloadState.status === AsyncStatus.Error ? (
+    renderErrorButton(download, `Retry Download (${bytesToSize(info.size ?? 0)})`)
+  ) : (
+    <Button
+      variant="Secondary"
+      fill="Soft"
+      radii="300"
+      size="400"
+      onClick={() =>
+        downloadState.status === AsyncStatus.Success
+          ? FileSaver.saveAs(downloadState.data, body)
+          : download()
+      }
+      disabled={downloadState.status === AsyncStatus.Loading}
+      before={
+        downloadState.status === AsyncStatus.Loading ? (
+          <Spinner fill="Soft" size="100" variant="Secondary" />
+        ) : (
+          <Icon size="100" src={Icons.Download} filled />
+        )
+      }
+    >
+      <Text size="B400" truncate>{`Download (${bytesToSize(info.size ?? 0)})`}</Text>
+    </Button>
+  );
+}
+
+export const FileContent = as<'div', FileContentProps>(
+  ({ body, mimeType, url, info, encInfo, ...props }, ref) => (
+    <Box direction="Column" gap="300" {...props} ref={ref}>
+      {READABLE_TEXT_MIME_TYPES.includes(mimeType) && (
+        <ReadTextFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
+      )}
+      {mimeType === 'application/pdf' && (
+        <ReadPdfFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
+      )}
+      <DownloadFile body={body} mimeType={mimeType} url={url} info={info} encInfo={encInfo} />
+    </Box>
+  )
+);
diff --git a/src/app/organisms/room/message/FileHeader.tsx b/src/app/organisms/room/message/FileHeader.tsx
new file mode 100644 (file)
index 0000000..8d523b3
--- /dev/null
@@ -0,0 +1,22 @@
+import { Badge, Box, Text, as, toRem } from 'folds';
+import React from 'react';
+import { mimeTypeToExt } from '../../../utils/mimeTypes';
+
+const badgeStyles = { maxWidth: toRem(100) };
+
+export type FileHeaderProps = {
+  body: string;
+  mimeType: string;
+};
+export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, ...props }, ref) => (
+  <Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}>
+    <Badge style={badgeStyles} variant="Secondary" radii="Pill">
+      <Text size="O400" truncate>
+        {mimeTypeToExt(mimeType)}
+      </Text>
+    </Badge>
+    <Text size="T300" truncate>
+      {body}
+    </Text>
+  </Box>
+));
diff --git a/src/app/organisms/room/message/ImageContent.tsx b/src/app/organisms/room/message/ImageContent.tsx
new file mode 100644 (file)
index 0000000..2005b3a
--- /dev/null
@@ -0,0 +1,170 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import {
+  Badge,
+  Box,
+  Button,
+  Icon,
+  Icons,
+  Modal,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Spinner,
+  Text,
+  Tooltip,
+  TooltipProvider,
+  as,
+} from 'folds';
+import classNames from 'classnames';
+import { BlurhashCanvas } from 'react-blurhash';
+import FocusTrap from 'focus-trap-react';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getFileSrcUrl } from './util';
+import { Image } from '../../../components/media';
+import * as css from './styles.css';
+import { bytesToSize } from '../../../utils/common';
+import { ImageViewer } from '../../../components/image-viewer';
+
+export type ImageContentProps = {
+  body: string;
+  mimeType: string;
+  url: string;
+  info: IImageInfo;
+  encInfo?: EncryptedAttachmentInfo;
+  autoPlay?: boolean;
+};
+export const ImageContent = as<'div', ImageContentProps>(
+  ({ className, body, mimeType, url, info, encInfo, autoPlay, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const blurHash = info[MATRIX_BLUR_HASH_PROPERTY_NAME];
+
+    const [load, setLoad] = useState(false);
+    const [error, setError] = useState(false);
+    const [viewer, setViewer] = useState(false);
+
+    const [srcState, loadSrc] = useAsyncCallback(
+      useCallback(
+        () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
+        [mx, url, mimeType, encInfo]
+      )
+    );
+
+    const handleLoad = () => {
+      setLoad(true);
+    };
+    const handleError = () => {
+      setLoad(false);
+      setError(true);
+    };
+
+    const handleRetry = () => {
+      setError(false);
+      loadSrc();
+    };
+
+    useEffect(() => {
+      if (autoPlay) loadSrc();
+    }, [autoPlay, loadSrc]);
+
+    return (
+      <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
+        {srcState.status === AsyncStatus.Success && (
+          <Overlay open={viewer} backdrop={<OverlayBackdrop />}>
+            <OverlayCenter>
+              <FocusTrap
+                focusTrapOptions={{
+                  initialFocus: false,
+                  onDeactivate: () => setViewer(false),
+                  clickOutsideDeactivates: true,
+                }}
+              >
+                <Modal size="500">
+                  <ImageViewer
+                    src={srcState.data}
+                    alt={body}
+                    requestClose={() => setViewer(false)}
+                  />
+                </Modal>
+              </FocusTrap>
+            </OverlayCenter>
+          </Overlay>
+        )}
+        {typeof blurHash === 'string' && !load && (
+          <BlurhashCanvas style={{ width: '100%', height: '100%' }} hash={blurHash} punch={1} />
+        )}
+        {!autoPlay && srcState.status === AsyncStatus.Idle && (
+          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+            <Button
+              variant="Secondary"
+              fill="Solid"
+              radii="300"
+              size="300"
+              onClick={loadSrc}
+              before={<Icon size="Inherit" src={Icons.Photo} filled />}
+            >
+              <Text size="B300">View</Text>
+            </Button>
+          </Box>
+        )}
+        {srcState.status === AsyncStatus.Success && (
+          <Box className={css.AbsoluteContainer}>
+            <Image
+              alt={body}
+              title={body}
+              src={srcState.data}
+              loading="lazy"
+              onLoad={handleLoad}
+              onError={handleError}
+              onClick={() => setViewer(true)}
+              tabIndex={0}
+            />
+          </Box>
+        )}
+        {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
+          !load && (
+            <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+              <Spinner variant="Secondary" />
+            </Box>
+          )}
+        {(error || srcState.status === AsyncStatus.Error) && (
+          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+            <TooltipProvider
+              tooltip={
+                <Tooltip variant="Critical">
+                  <Text>Failed to load image!</Text>
+                </Tooltip>
+              }
+              position="Top"
+              align="Center"
+            >
+              {(triggerRef) => (
+                <Button
+                  ref={triggerRef}
+                  size="300"
+                  variant="Critical"
+                  fill="Soft"
+                  outlined
+                  radii="300"
+                  onClick={handleRetry}
+                  before={<Icon size="Inherit" src={Icons.Warning} filled />}
+                >
+                  <Text size="B300">Retry</Text>
+                </Button>
+              )}
+            </TooltipProvider>
+          </Box>
+        )}
+        {!load && typeof info.size === 'number' && (
+          <Box className={css.AbsoluteFooter} justifyContent="End" alignContent="Center" gap="200">
+            <Badge variant="Secondary" fill="Soft">
+              <Text size="L400">{bytesToSize(info.size)}</Text>
+            </Badge>
+          </Box>
+        )}
+      </Box>
+    );
+  }
+);
diff --git a/src/app/organisms/room/message/Message.tsx b/src/app/organisms/room/message/Message.tsx
new file mode 100644 (file)
index 0000000..8f25861
--- /dev/null
@@ -0,0 +1,993 @@
+import {
+  Avatar,
+  AvatarFallback,
+  AvatarImage,
+  Box,
+  Button,
+  Dialog,
+  Header,
+  Icon,
+  IconButton,
+  Icons,
+  Input,
+  Line,
+  Menu,
+  MenuItem,
+  Modal,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  PopOut,
+  Spinner,
+  Text,
+  as,
+  color,
+  config,
+} from 'folds';
+import React, {
+  FormEventHandler,
+  MouseEventHandler,
+  ReactNode,
+  useCallback,
+  useState,
+} from 'react';
+import FocusTrap from 'focus-trap-react';
+import { MatrixEvent, Room } from 'matrix-js-sdk';
+import { Relations } from 'matrix-js-sdk/lib/models/relations';
+import classNames from 'classnames';
+import {
+  AvatarBase,
+  BubbleLayout,
+  CompactLayout,
+  MessageBase,
+  ModernLayout,
+  Time,
+  Username,
+} from '../../../components/message';
+import colorMXID from '../../../../util/colorMXID';
+import { getMemberAvatarMxc, getMemberDisplayName } from '../../../utils/room';
+import { getMxIdLocalPart } from '../../../utils/matrix';
+import { MessageLayout, MessageSpacing } from '../../../state/settings';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
+import * as css from './styles.css';
+import { EventReaders } from '../../../components/event-readers';
+import { TextViewer } from '../../../components/text-viewer';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { EmojiBoard } from '../../../components/emoji-board';
+import { ReactionViewer } from '../reaction-viewer';
+
+export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
+
+type MessageQuickReactionsProps = {
+  onReaction: ReactionHandler;
+};
+export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
+  ({ onReaction, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const recentEmojis = useRecentEmoji(mx, 4);
+
+    if (recentEmojis.length === 0) return <span />;
+    return (
+      <>
+        <Box
+          style={{ padding: config.space.S200 }}
+          alignItems="Center"
+          justifyContent="Center"
+          gap="200"
+          {...props}
+          ref={ref}
+        >
+          {recentEmojis.map((emoji) => (
+            <IconButton
+              key={emoji.unicode}
+              className={css.MessageQuickReaction}
+              size="300"
+              variant="SurfaceVariant"
+              radii="Pill"
+              title={emoji.shortcode}
+              aria-label={emoji.shortcode}
+              onClick={() => onReaction(emoji.unicode, emoji.shortcode)}
+            >
+              <Text size="T500">{emoji.unicode}</Text>
+            </IconButton>
+          ))}
+        </Box>
+        <Line size="300" />
+      </>
+    );
+  }
+);
+
+export const MessageAllReactionItem = as<
+  'button',
+  {
+    room: Room;
+    relations: Relations;
+    onClose?: () => void;
+  }
+>(({ room, relations, onClose, ...props }, ref) => {
+  const [open, setOpen] = useState(false);
+
+  const handleClose = () => {
+    setOpen(false);
+    onClose?.();
+  };
+
+  return (
+    <>
+      <Overlay
+        onContextMenu={(evt: any) => {
+          evt.stopPropagation();
+        }}
+        open={open}
+        backdrop={<OverlayBackdrop />}
+      >
+        <OverlayCenter>
+          <FocusTrap
+            focusTrapOptions={{
+              initialFocus: false,
+              returnFocusOnDeactivate: false,
+              onDeactivate: () => handleClose(),
+              clickOutsideDeactivates: true,
+            }}
+          >
+            <Modal variant="Surface" size="300">
+              <ReactionViewer
+                room={room}
+                relations={relations}
+                requestClose={() => setOpen(false)}
+              />
+            </Modal>
+          </FocusTrap>
+        </OverlayCenter>
+      </Overlay>
+      <MenuItem
+        size="300"
+        after={<Icon size="100" src={Icons.Smile} />}
+        radii="300"
+        onClick={() => setOpen(true)}
+        {...props}
+        ref={ref}
+        aria-pressed={open}
+      >
+        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+          View Reactions
+        </Text>
+      </MenuItem>
+    </>
+  );
+});
+
+export const MessageReadReceiptItem = as<
+  'button',
+  {
+    room: Room;
+    eventId: string;
+    onClose?: () => void;
+  }
+>(({ room, eventId, onClose, ...props }, ref) => {
+  const [open, setOpen] = useState(false);
+
+  const handleClose = () => {
+    setOpen(false);
+    onClose?.();
+  };
+
+  return (
+    <>
+      <Overlay open={open} backdrop={<OverlayBackdrop />}>
+        <OverlayCenter>
+          <FocusTrap
+            focusTrapOptions={{
+              initialFocus: false,
+              onDeactivate: handleClose,
+              clickOutsideDeactivates: true,
+            }}
+          >
+            <Modal variant="Surface" size="300">
+              <EventReaders room={room} eventId={eventId} requestClose={handleClose} />
+            </Modal>
+          </FocusTrap>
+        </OverlayCenter>
+      </Overlay>
+      <MenuItem
+        size="300"
+        after={<Icon size="100" src={Icons.CheckTwice} />}
+        radii="300"
+        onClick={() => setOpen(true)}
+        {...props}
+        ref={ref}
+        aria-pressed={open}
+      >
+        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+          Read Receipts
+        </Text>
+      </MenuItem>
+    </>
+  );
+});
+
+export const MessageSourceCodeItem = as<
+  'button',
+  {
+    mEvent: MatrixEvent;
+    onClose?: () => void;
+  }
+>(({ mEvent, onClose, ...props }, ref) => {
+  const [open, setOpen] = useState(false);
+  const text = JSON.stringify(
+    mEvent.isEncrypted()
+      ? {
+          [`<== DECRYPTED_EVENT ==>`]: mEvent.getEffectiveEvent(),
+          [`<== ORIGINAL_EVENT ==>`]: mEvent.event,
+        }
+      : mEvent.event,
+    null,
+    2
+  );
+
+  const handleClose = () => {
+    setOpen(false);
+    onClose?.();
+  };
+
+  return (
+    <>
+      <Overlay open={open} backdrop={<OverlayBackdrop />}>
+        <OverlayCenter>
+          <FocusTrap
+            focusTrapOptions={{
+              initialFocus: false,
+              onDeactivate: handleClose,
+              clickOutsideDeactivates: true,
+            }}
+          >
+            <Modal variant="Surface" size="500">
+              <TextViewer
+                name="Source Code"
+                mimeType="application/json"
+                text={text}
+                requestClose={handleClose}
+              />
+            </Modal>
+          </FocusTrap>
+        </OverlayCenter>
+      </Overlay>
+      <MenuItem
+        size="300"
+        after={<Icon size="100" src={Icons.BlockCode} />}
+        radii="300"
+        onClick={() => setOpen(true)}
+        {...props}
+        ref={ref}
+        aria-pressed={open}
+      >
+        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+          View Source
+        </Text>
+      </MenuItem>
+    </>
+  );
+});
+
+export const MessageDeleteItem = as<
+  'button',
+  {
+    room: Room;
+    mEvent: MatrixEvent;
+    onClose?: () => void;
+  }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+  const mx = useMatrixClient();
+  const [open, setOpen] = useState(false);
+
+  const [deleteState, deleteMessage] = useAsyncCallback(
+    useCallback(
+      (eventId: string, reason?: string) =>
+        mx.redactEvent(room.roomId, eventId, undefined, reason ? { reason } : undefined),
+      [mx, room]
+    )
+  );
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    const eventId = mEvent.getId();
+    if (
+      !eventId ||
+      deleteState.status === AsyncStatus.Loading ||
+      deleteState.status === AsyncStatus.Success
+    )
+      return;
+    const target = evt.target as HTMLFormElement | undefined;
+    const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
+    const reason = reasonInput && reasonInput.value.trim();
+    deleteMessage(eventId, reason);
+  };
+
+  const handleClose = () => {
+    setOpen(false);
+    onClose?.();
+  };
+
+  return (
+    <>
+      <Overlay open={open} backdrop={<OverlayBackdrop />}>
+        <OverlayCenter>
+          <FocusTrap
+            focusTrapOptions={{
+              initialFocus: false,
+              onDeactivate: handleClose,
+              clickOutsideDeactivates: true,
+            }}
+          >
+            <Dialog variant="Surface">
+              <Header
+                style={{
+                  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+                  borderBottomWidth: config.borderWidth.B300,
+                }}
+                variant="Surface"
+                size="500"
+              >
+                <Box grow="Yes">
+                  <Text size="H4">Delete Message</Text>
+                </Box>
+                <IconButton size="300" onClick={handleClose} radii="300">
+                  <Icon src={Icons.Cross} />
+                </IconButton>
+              </Header>
+              <Box
+                as="form"
+                onSubmit={handleSubmit}
+                style={{ padding: config.space.S400 }}
+                direction="Column"
+                gap="400"
+              >
+                <Text priority="400">
+                  This action is irreversible! Are you sure that you want to delete this message?
+                </Text>
+                <Box direction="Column" gap="100">
+                  <Text size="L400">
+                    Reason{' '}
+                    <Text as="span" size="T200">
+                      (optional)
+                    </Text>
+                  </Text>
+                  <Input name="reasonInput" variant="Background" />
+                  {deleteState.status === AsyncStatus.Error && (
+                    <Text style={{ color: color.Critical.Main }} size="T300">
+                      Failed to delete message! Please try again.
+                    </Text>
+                  )}
+                </Box>
+                <Button
+                  type="submit"
+                  variant="Critical"
+                  before={
+                    deleteState.status === AsyncStatus.Loading ? (
+                      <Spinner fill="Soft" variant="Critical" size="200" />
+                    ) : undefined
+                  }
+                  aria-disabled={deleteState.status === AsyncStatus.Loading}
+                >
+                  <Text size="B400">
+                    {deleteState.status === AsyncStatus.Loading ? 'Deleting...' : 'Delete'}
+                  </Text>
+                </Button>
+              </Box>
+            </Dialog>
+          </FocusTrap>
+        </OverlayCenter>
+      </Overlay>
+      <Button
+        variant="Critical"
+        fill="None"
+        size="300"
+        after={<Icon size="100" src={Icons.Delete} />}
+        radii="300"
+        onClick={() => setOpen(true)}
+        aria-pressed={open}
+        {...props}
+        ref={ref}
+      >
+        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+          Delete
+        </Text>
+      </Button>
+    </>
+  );
+});
+
+export const MessageReportItem = as<
+  'button',
+  {
+    room: Room;
+    mEvent: MatrixEvent;
+    onClose?: () => void;
+  }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+  const mx = useMatrixClient();
+  const [open, setOpen] = useState(false);
+
+  const [reportState, reportMessage] = useAsyncCallback(
+    useCallback(
+      (eventId: string, score: number, reason: string) =>
+        mx.reportEvent(room.roomId, eventId, score, reason),
+      [mx, room]
+    )
+  );
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+    evt.preventDefault();
+    const eventId = mEvent.getId();
+    if (
+      !eventId ||
+      reportState.status === AsyncStatus.Loading ||
+      reportState.status === AsyncStatus.Success
+    )
+      return;
+    const target = evt.target as HTMLFormElement | undefined;
+    const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
+    const reason = reasonInput && reasonInput.value.trim();
+    if (reasonInput) reasonInput.value = '';
+    reportMessage(eventId, reason ? -100 : -50, reason || 'No reason provided');
+  };
+
+  const handleClose = () => {
+    setOpen(false);
+    onClose?.();
+  };
+
+  return (
+    <>
+      <Overlay open={open} backdrop={<OverlayBackdrop />}>
+        <OverlayCenter>
+          <FocusTrap
+            focusTrapOptions={{
+              initialFocus: false,
+              onDeactivate: handleClose,
+              clickOutsideDeactivates: true,
+            }}
+          >
+            <Dialog variant="Surface">
+              <Header
+                style={{
+                  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+                  borderBottomWidth: config.borderWidth.B300,
+                }}
+                variant="Surface"
+                size="500"
+              >
+                <Box grow="Yes">
+                  <Text size="H4">Report Message</Text>
+                </Box>
+                <IconButton size="300" onClick={handleClose} radii="300">
+                  <Icon src={Icons.Cross} />
+                </IconButton>
+              </Header>
+              <Box
+                as="form"
+                onSubmit={handleSubmit}
+                style={{ padding: config.space.S400 }}
+                direction="Column"
+                gap="400"
+              >
+                <Text priority="400">
+                  Report this message to server, which may then notify the appropriate people to
+                  take action.
+                </Text>
+                <Box direction="Column" gap="100">
+                  <Text size="L400">Reason</Text>
+                  <Input name="reasonInput" variant="Background" required />
+                  {reportState.status === AsyncStatus.Error && (
+                    <Text style={{ color: color.Critical.Main }} size="T300">
+                      Failed to report message! Please try again.
+                    </Text>
+                  )}
+                  {reportState.status === AsyncStatus.Success && (
+                    <Text style={{ color: color.Success.Main }} size="T300">
+                      Message has been reported to server.
+                    </Text>
+                  )}
+                </Box>
+                <Button
+                  type="submit"
+                  variant="Critical"
+                  before={
+                    reportState.status === AsyncStatus.Loading ? (
+                      <Spinner fill="Soft" variant="Critical" size="200" />
+                    ) : undefined
+                  }
+                  aria-disabled={
+                    reportState.status === AsyncStatus.Loading ||
+                    reportState.status === AsyncStatus.Success
+                  }
+                >
+                  <Text size="B400">
+                    {reportState.status === AsyncStatus.Loading ? 'Reporting...' : 'Report'}
+                  </Text>
+                </Button>
+              </Box>
+            </Dialog>
+          </FocusTrap>
+        </OverlayCenter>
+      </Overlay>
+      <Button
+        variant="Critical"
+        fill="None"
+        size="300"
+        after={<Icon size="100" src={Icons.Warning} />}
+        radii="300"
+        onClick={() => setOpen(true)}
+        aria-pressed={open}
+        {...props}
+        ref={ref}
+      >
+        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+          Report
+        </Text>
+      </Button>
+    </>
+  );
+});
+
+export type MessageProps = {
+  room: Room;
+  mEvent: MatrixEvent;
+  collapse: boolean;
+  highlight: boolean;
+  canDelete?: boolean;
+  canSendReaction?: boolean;
+  imagePackRooms?: Room[];
+  relations?: Relations;
+  messageLayout: MessageLayout;
+  messageSpacing: MessageSpacing;
+  onUserClick: MouseEventHandler<HTMLButtonElement>;
+  onUsernameClick: MouseEventHandler<HTMLButtonElement>;
+  onReplyClick: MouseEventHandler<HTMLButtonElement>;
+  onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
+  reply?: ReactNode;
+  reactions?: ReactNode;
+};
+export const Message = as<'div', MessageProps>(
+  (
+    {
+      className,
+      room,
+      mEvent,
+      collapse,
+      highlight,
+      canDelete,
+      canSendReaction,
+      imagePackRooms,
+      relations,
+      messageLayout,
+      messageSpacing,
+      onUserClick,
+      onUsernameClick,
+      onReplyClick,
+      onReactionToggle,
+      reply,
+      reactions,
+      children,
+      ...props
+    },
+    ref
+  ) => {
+    const mx = useMatrixClient();
+    const senderId = mEvent.getSender() ?? '';
+    const [hover, setHover] = useState(false);
+    const [menu, setMenu] = useState(false);
+    const [emojiBoard, setEmojiBoard] = useState(false);
+
+    const senderDisplayName =
+      getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
+    const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
+
+    const headerJSX = !collapse && (
+      <Box
+        gap="300"
+        direction={messageLayout === 1 ? 'RowReverse' : 'Row'}
+        justifyContent="SpaceBetween"
+        alignItems="Baseline"
+        grow="Yes"
+      >
+        <Username
+          as="button"
+          style={{ color: colorMXID(senderId) }}
+          data-user-id={senderId}
+          onContextMenu={onUserClick}
+          onClick={onUsernameClick}
+        >
+          <Text as="span" size={messageLayout === 2 ? 'T300' : 'T400'} truncate>
+            <b>{senderDisplayName}</b>
+          </Text>
+        </Username>
+        <Box shrink="No" gap="100">
+          {messageLayout !== 1 && hover && (
+            <>
+              <Text as="span" size="T200" priority="300">
+                {senderId}
+              </Text>
+              <Text as="span" size="T200" priority="300">
+                |
+              </Text>
+            </>
+          )}
+          <Time ts={mEvent.getTs()} compact={messageLayout === 1} />
+        </Box>
+      </Box>
+    );
+
+    const avatarJSX = !collapse && messageLayout !== 1 && (
+      <AvatarBase>
+        <Avatar as="button" size="300" data-user-id={senderId} onClick={onUserClick}>
+          {senderAvatarMxc ? (
+            <AvatarImage
+              src={mx.mxcUrlToHttp(senderAvatarMxc, 48, 48, 'crop') ?? senderAvatarMxc}
+            />
+          ) : (
+            <AvatarFallback
+              style={{
+                background: colorMXID(senderId),
+                color: 'white',
+              }}
+            >
+              <Text size="H4">{senderDisplayName[0]}</Text>
+            </AvatarFallback>
+          )}
+        </Avatar>
+      </AvatarBase>
+    );
+
+    const msgContentJSX = (
+      <Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
+        {reply}
+        {children}
+        {reactions}
+      </Box>
+    );
+
+    const showOptions = () => setHover(true);
+    const hideOptions = () => setHover(false);
+
+    const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
+      if (evt.altKey) return;
+      const tag = (evt.target as any).tagName;
+      if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
+      evt.preventDefault();
+      setMenu(true);
+    };
+
+    const closeMenu = () => {
+      setMenu(false);
+    };
+
+    return (
+      <MessageBase
+        className={classNames(css.MessageBase, className)}
+        tabIndex={0}
+        space={messageSpacing}
+        collapse={collapse}
+        highlight={highlight}
+        selected={menu || emojiBoard}
+        {...props}
+        onMouseEnter={showOptions}
+        onMouseLeave={hideOptions}
+        ref={ref}
+      >
+        {(hover || menu || emojiBoard) && (
+          <div className={css.MessageOptionsBase}>
+            <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
+              <Box gap="100">
+                {canSendReaction && (
+                  <PopOut
+                    alignOffset={-65}
+                    position="Bottom"
+                    align="End"
+                    open={emojiBoard}
+                    content={
+                      <EmojiBoard
+                        imagePackRooms={imagePackRooms ?? []}
+                        returnFocusOnDeactivate={false}
+                        onEmojiSelect={(key) => {
+                          onReactionToggle(mEvent.getId()!, key);
+                          setEmojiBoard(false);
+                        }}
+                        onCustomEmojiSelect={(mxc, shortcode) => {
+                          onReactionToggle(mEvent.getId()!, mxc, shortcode);
+                          setEmojiBoard(false);
+                        }}
+                        requestClose={() => {
+                          setEmojiBoard(false);
+                        }}
+                      />
+                    }
+                  >
+                    {(anchorRef) => (
+                      <IconButton
+                        ref={anchorRef}
+                        onClick={() => setEmojiBoard(true)}
+                        variant="SurfaceVariant"
+                        size="300"
+                        radii="300"
+                        aria-pressed={emojiBoard}
+                      >
+                        <Icon src={Icons.SmilePlus} size="100" />
+                      </IconButton>
+                    )}
+                  </PopOut>
+                )}
+                <IconButton
+                  onClick={onReplyClick}
+                  data-event-id={mEvent.getId()}
+                  variant="SurfaceVariant"
+                  size="300"
+                  radii="300"
+                >
+                  <Icon src={Icons.ReplyArrow} size="100" />
+                </IconButton>
+                <PopOut
+                  open={menu}
+                  alignOffset={-5}
+                  position="Bottom"
+                  align="End"
+                  content={
+                    <FocusTrap
+                      focusTrapOptions={{
+                        initialFocus: false,
+                        onDeactivate: () => setMenu(false),
+                        clickOutsideDeactivates: true,
+                        isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                        isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+                      }}
+                    >
+                      <Menu {...props} ref={ref}>
+                        {canSendReaction && (
+                          <MessageQuickReactions
+                            onReaction={(key, shortcode) => {
+                              onReactionToggle(mEvent.getId()!, key, shortcode);
+                              closeMenu();
+                            }}
+                          />
+                        )}
+                        <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
+                          {canSendReaction && (
+                            <MenuItem
+                              size="300"
+                              after={<Icon size="100" src={Icons.SmilePlus} />}
+                              radii="300"
+                              onClick={() => {
+                                closeMenu();
+                                // open it with timeout because closeMenu
+                                // FocusTrap will return focus from emojiBoard
+                                setTimeout(() => setEmojiBoard(true), 100);
+                              }}
+                            >
+                              <Text
+                                className={css.MessageMenuItemText}
+                                as="span"
+                                size="T300"
+                                truncate
+                              >
+                                Add Reaction
+                              </Text>
+                            </MenuItem>
+                          )}
+                          {relations && (
+                            <MessageAllReactionItem
+                              room={room}
+                              relations={relations}
+                              onClose={closeMenu}
+                            />
+                          )}
+                          <MenuItem
+                            size="300"
+                            after={<Icon size="100" src={Icons.ReplyArrow} />}
+                            radii="300"
+                            data-event-id={mEvent.getId()}
+                            onClick={(evt: any) => {
+                              onReplyClick(evt);
+                              closeMenu();
+                            }}
+                          >
+                            <Text
+                              className={css.MessageMenuItemText}
+                              as="span"
+                              size="T300"
+                              truncate
+                            >
+                              Reply
+                            </Text>
+                          </MenuItem>
+                          <MessageReadReceiptItem
+                            room={room}
+                            eventId={mEvent.getId() ?? ''}
+                            onClose={closeMenu}
+                          />
+                          <MessageSourceCodeItem mEvent={mEvent} onClose={closeMenu} />
+                        </Box>
+                        {((!mEvent.isRedacted() && canDelete) ||
+                          mEvent.getSender() !== mx.getUserId()) && (
+                          <>
+                            <Line size="300" />
+                            <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
+                              {!mEvent.isRedacted() && canDelete && (
+                                <MessageDeleteItem
+                                  room={room}
+                                  mEvent={mEvent}
+                                  onClose={closeMenu}
+                                />
+                              )}
+                              {mEvent.getSender() !== mx.getUserId() && (
+                                <MessageReportItem
+                                  room={room}
+                                  mEvent={mEvent}
+                                  onClose={closeMenu}
+                                />
+                              )}
+                            </Box>
+                          </>
+                        )}
+                      </Menu>
+                    </FocusTrap>
+                  }
+                >
+                  {(targetRef) => (
+                    <IconButton
+                      ref={targetRef}
+                      variant="SurfaceVariant"
+                      size="300"
+                      radii="300"
+                      onClick={() => setMenu((v) => !v)}
+                      aria-pressed={menu}
+                    >
+                      <Icon src={Icons.VerticalDots} size="100" />
+                    </IconButton>
+                  )}
+                </PopOut>
+              </Box>
+            </Menu>
+          </div>
+        )}
+        {messageLayout === 1 && (
+          <CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
+            {msgContentJSX}
+          </CompactLayout>
+        )}
+        {messageLayout === 2 && (
+          <BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
+            {headerJSX}
+            {msgContentJSX}
+          </BubbleLayout>
+        )}
+        {messageLayout !== 1 && messageLayout !== 2 && (
+          <ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
+            {headerJSX}
+            {msgContentJSX}
+          </ModernLayout>
+        )}
+      </MessageBase>
+    );
+  }
+);
+
+export type EventProps = {
+  room: Room;
+  mEvent: MatrixEvent;
+  highlight: boolean;
+  canDelete?: boolean;
+  messageSpacing: MessageSpacing;
+};
+export const Event = as<'div', EventProps>(
+  ({ className, room, mEvent, highlight, canDelete, messageSpacing, children, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const [hover, setHover] = useState(false);
+    const [menu, setMenu] = useState(false);
+    const stateEvent = typeof mEvent.getStateKey() === 'string';
+
+    const showOptions = () => setHover(true);
+    const hideOptions = () => setHover(false);
+
+    const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
+      if (evt.altKey) return;
+      const tag = (evt.target as any).tagName;
+      if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
+      evt.preventDefault();
+      setMenu(true);
+    };
+
+    const closeMenu = () => {
+      setMenu(false);
+    };
+
+    return (
+      <MessageBase
+        className={classNames(css.MessageBase, className)}
+        tabIndex={0}
+        space={messageSpacing}
+        autoCollapse
+        highlight={highlight}
+        selected={menu}
+        {...props}
+        onMouseEnter={showOptions}
+        onMouseLeave={hideOptions}
+        ref={ref}
+      >
+        {(hover || menu) && (
+          <div className={css.MessageOptionsBase}>
+            <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
+              <Box gap="100">
+                <PopOut
+                  open={menu}
+                  alignOffset={-5}
+                  position="Bottom"
+                  align="End"
+                  content={
+                    <FocusTrap
+                      focusTrapOptions={{
+                        initialFocus: false,
+                        onDeactivate: () => setMenu(false),
+                        clickOutsideDeactivates: true,
+                        isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                        isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+                      }}
+                    >
+                      <Menu {...props} ref={ref}>
+                        <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
+                          <MessageReadReceiptItem
+                            room={room}
+                            eventId={mEvent.getId() ?? ''}
+                            onClose={closeMenu}
+                          />
+                          <MessageSourceCodeItem mEvent={mEvent} onClose={closeMenu} />
+                        </Box>
+                        {((!mEvent.isRedacted() && canDelete && !stateEvent) ||
+                          (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
+                          <>
+                            <Line size="300" />
+                            <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
+                              {!mEvent.isRedacted() && canDelete && (
+                                <MessageDeleteItem
+                                  room={room}
+                                  mEvent={mEvent}
+                                  onClose={closeMenu}
+                                />
+                              )}
+                              {mEvent.getSender() !== mx.getUserId() && (
+                                <MessageReportItem
+                                  room={room}
+                                  mEvent={mEvent}
+                                  onClose={closeMenu}
+                                />
+                              )}
+                            </Box>
+                          </>
+                        )}
+                      </Menu>
+                    </FocusTrap>
+                  }
+                >
+                  {(targetRef) => (
+                    <IconButton
+                      ref={targetRef}
+                      variant="SurfaceVariant"
+                      size="300"
+                      radii="300"
+                      onClick={() => setMenu((v) => !v)}
+                      aria-pressed={menu}
+                    >
+                      <Icon src={Icons.VerticalDots} size="100" />
+                    </IconButton>
+                  )}
+                </PopOut>
+              </Box>
+            </Menu>
+          </div>
+        )}
+        <div onContextMenu={handleContextMenu}>{children}</div>
+      </MessageBase>
+    );
+  }
+);
diff --git a/src/app/organisms/room/message/Reactions.tsx b/src/app/organisms/room/message/Reactions.tsx
new file mode 100644 (file)
index 0000000..354820c
--- /dev/null
@@ -0,0 +1,133 @@
+import React, { MouseEventHandler, useCallback, useState } from 'react';
+import {
+  Box,
+  Modal,
+  Overlay,
+  OverlayBackdrop,
+  OverlayCenter,
+  Text,
+  Tooltip,
+  TooltipProvider,
+  as,
+  toRem,
+} from 'folds';
+import classNames from 'classnames';
+import { EventTimelineSet, EventType, RelationType, Room } from 'matrix-js-sdk';
+import { type Relations } from 'matrix-js-sdk/lib/models/relations';
+import FocusTrap from 'focus-trap-react';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { factoryEventSentBy } from '../../../utils/matrix';
+import { Reaction, ReactionTooltipMsg } from '../../../components/message';
+import { useRelations } from '../../../hooks/useRelations';
+import * as css from './styles.css';
+import { ReactionViewer } from '../reaction-viewer';
+
+export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) =>
+  timelineSet.relations.getChildEventsForEvent(
+    eventId,
+    RelationType.Annotation,
+    EventType.Reaction
+  );
+
+export type ReactionsProps = {
+  room: Room;
+  mEventId: string;
+  canSendReaction?: boolean;
+  relations: Relations;
+  onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
+};
+export const Reactions = as<'div', ReactionsProps>(
+  ({ className, room, relations, mEventId, canSendReaction, onReactionToggle, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const [viewer, setViewer] = useState<boolean | string>(false);
+    const myUserId = mx.getUserId();
+    const reactions = useRelations(
+      relations,
+      useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
+    );
+
+    const handleViewReaction: MouseEventHandler<HTMLButtonElement> = (evt) => {
+      evt.stopPropagation();
+      evt.preventDefault();
+      const key = evt.currentTarget.getAttribute('data-reaction-key');
+      console.log(key);
+      if (!key) setViewer(true);
+      else setViewer(key);
+    };
+
+    return (
+      <Box
+        className={classNames(css.ReactionsContainer, className)}
+        gap="200"
+        wrap="Wrap"
+        {...props}
+        ref={ref}
+      >
+        {reactions.map(([key, events]) => {
+          const rEvents = Array.from(events);
+          if (rEvents.length === 0) return null;
+          const myREvent = myUserId ? rEvents.find(factoryEventSentBy(myUserId)) : undefined;
+          const isPressed = !!myREvent?.getRelation();
+
+          return (
+            <TooltipProvider
+              key={key}
+              position="Top"
+              tooltip={
+                <Tooltip style={{ maxWidth: toRem(200) }}>
+                  <Text size="T300">
+                    <ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
+                  </Text>
+                </Tooltip>
+              }
+            >
+              {(targetRef) => (
+                <Reaction
+                  ref={targetRef}
+                  data-reaction-key={key}
+                  aria-pressed={isPressed}
+                  key={key}
+                  mx={mx}
+                  reaction={key}
+                  count={events.size}
+                  onClick={canSendReaction ? () => onReactionToggle(mEventId, key) : undefined}
+                  onContextMenu={handleViewReaction}
+                  aria-disabled={!canSendReaction}
+                />
+              )}
+            </TooltipProvider>
+          );
+        })}
+        {reactions.length > 0 && (
+          <Overlay
+            onContextMenu={(evt: any) => {
+              evt.stopPropagation();
+            }}
+            open={!!viewer}
+            backdrop={<OverlayBackdrop />}
+          >
+            <OverlayCenter>
+              <FocusTrap
+                focusTrapOptions={{
+                  initialFocus: false,
+                  returnFocusOnDeactivate: false,
+                  onDeactivate: () => setViewer(false),
+                  clickOutsideDeactivates: true,
+                }}
+              >
+                <Modal variant="Surface" size="300">
+                  <ReactionViewer
+                    room={room}
+                    initialKey={typeof viewer === 'string' ? viewer : undefined}
+                    relations={relations}
+                    requestClose={() => setViewer(false)}
+                  />
+                </Modal>
+              </FocusTrap>
+            </OverlayCenter>
+          </Overlay>
+        )}
+      </Box>
+    );
+  }
+);
diff --git a/src/app/organisms/room/message/StickerContent.tsx b/src/app/organisms/room/message/StickerContent.tsx
new file mode 100644 (file)
index 0000000..b6dcc61
--- /dev/null
@@ -0,0 +1,41 @@
+import React from 'react';
+import { as, toRem } from 'folds';
+import { MatrixEvent } from 'matrix-js-sdk';
+import { AttachmentBox, MessageBrokenContent } from '../../../components/message';
+import { ImageContent } from './ImageContent';
+import { scaleYDimension } from '../../../utils/common';
+import { IImageContent } from '../../../../types/matrix/common';
+
+type StickerContentProps = {
+  mEvent: MatrixEvent;
+  autoPlay: boolean;
+};
+export const StickerContent = as<'div', StickerContentProps>(({ mEvent, autoPlay, ...props }, ref) => {
+  const content = mEvent.getContent<IImageContent>();
+  const imgInfo = content?.info;
+  const mxcUrl = content.file?.url ?? content.url;
+  if (!imgInfo || typeof imgInfo.mimetype !== 'string' || typeof mxcUrl !== 'string') {
+    return <MessageBrokenContent />;
+  }
+  const height = scaleYDimension(imgInfo.w || 152, 152, imgInfo.h || 152);
+
+  return (
+    <AttachmentBox
+      style={{
+        height: toRem(height < 48 ? 48 : height),
+        width: toRem(152),
+      }}
+      {...props}
+      ref={ref}
+    >
+      <ImageContent
+        autoPlay={autoPlay}
+        body={content.body || 'Image'}
+        info={imgInfo}
+        mimeType={imgInfo.mimetype}
+        url={mxcUrl}
+        encInfo={content.file}
+      />
+    </AttachmentBox>
+  );
+});
diff --git a/src/app/organisms/room/message/VideoContent.tsx b/src/app/organisms/room/message/VideoContent.tsx
new file mode 100644 (file)
index 0000000..107d5f9
--- /dev/null
@@ -0,0 +1,176 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import {
+  Badge,
+  Box,
+  Button,
+  Icon,
+  Icons,
+  Spinner,
+  Text,
+  Tooltip,
+  TooltipProvider,
+  as,
+} from 'folds';
+import classNames from 'classnames';
+import { BlurhashCanvas } from 'react-blurhash';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import {
+  IThumbnailContent,
+  IVideoInfo,
+  MATRIX_BLUR_HASH_PROPERTY_NAME,
+} from '../../../../types/matrix/common';
+import * as css from './styles.css';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { getFileSrcUrl } from './util';
+import { Image, Video } from '../../../components/media';
+import { bytesToSize } from '../../../../util/common';
+import { millisecondsToMinutesAndSeconds } from '../../../utils/common';
+
+export type VideoContentProps = {
+  body: string;
+  mimeType: string;
+  url: string;
+  info: IVideoInfo & IThumbnailContent;
+  encInfo?: EncryptedAttachmentInfo;
+  autoPlay?: boolean;
+  loadThumbnail?: boolean;
+};
+export const VideoContent = as<'div', VideoContentProps>(
+  ({ className, body, mimeType, url, info, encInfo, autoPlay, loadThumbnail, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const blurHash = info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
+
+    const [load, setLoad] = useState(false);
+    const [error, setError] = useState(false);
+
+    const [srcState, loadSrc] = useAsyncCallback(
+      useCallback(
+        () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
+        [mx, url, mimeType, encInfo]
+      )
+    );
+    const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
+      useCallback(() => {
+        const thumbInfo = info.thumbnail_info;
+        const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
+        if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
+          throw new Error('Failed to load thumbnail');
+        }
+        return getFileSrcUrl(
+          mx.mxcUrlToHttp(thumbMxcUrl) ?? '',
+          thumbInfo.mimetype,
+          info.thumbnail_file
+        );
+      }, [mx, info])
+    );
+
+    const handleLoad = () => {
+      setLoad(true);
+    };
+    const handleError = () => {
+      setLoad(false);
+      setError(true);
+    };
+
+    const handleRetry = () => {
+      setError(false);
+      loadSrc();
+    };
+
+    useEffect(() => {
+      if (autoPlay) loadSrc();
+    }, [autoPlay, loadSrc]);
+    useEffect(() => {
+      if (loadThumbnail) loadThumbSrc();
+    }, [loadThumbnail, loadThumbSrc]);
+
+    return (
+      <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
+        {typeof blurHash === 'string' && !load && (
+          <BlurhashCanvas style={{ width: '100%', height: '100%' }} hash={blurHash} punch={1} />
+        )}
+        {thumbSrcState.status === AsyncStatus.Success && !load && (
+          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+            <Image alt={body} title={body} src={thumbSrcState.data} loading="lazy" />
+          </Box>
+        )}
+        {!autoPlay && srcState.status === AsyncStatus.Idle && (
+          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+            <Button
+              variant="Secondary"
+              fill="Solid"
+              radii="300"
+              size="300"
+              onClick={loadSrc}
+              before={<Icon size="Inherit" src={Icons.Play} filled />}
+            >
+              <Text size="B300">Watch</Text>
+            </Button>
+          </Box>
+        )}
+        {srcState.status === AsyncStatus.Success && (
+          <Box className={css.AbsoluteContainer}>
+            <Video
+              title={body}
+              src={srcState.data}
+              onLoadedMetadata={handleLoad}
+              onError={handleError}
+              autoPlay
+              controls
+            />
+          </Box>
+        )}
+        {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
+          !load && (
+            <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+              <Spinner variant="Secondary" />
+            </Box>
+          )}
+        {(error || srcState.status === AsyncStatus.Error) && (
+          <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+            <TooltipProvider
+              tooltip={
+                <Tooltip variant="Critical">
+                  <Text>Failed to load video!</Text>
+                </Tooltip>
+              }
+              position="Top"
+              align="Center"
+            >
+              {(triggerRef) => (
+                <Button
+                  ref={triggerRef}
+                  size="300"
+                  variant="Critical"
+                  fill="Soft"
+                  outlined
+                  radii="300"
+                  onClick={handleRetry}
+                  before={<Icon size="Inherit" src={Icons.Warning} filled />}
+                >
+                  <Text size="B300">Retry</Text>
+                </Button>
+              )}
+            </TooltipProvider>
+          </Box>
+        )}
+        {!load && typeof info.size === 'number' && (
+          <Box
+            className={css.AbsoluteFooter}
+            justifyContent="SpaceBetween"
+            alignContent="Center"
+            gap="200"
+          >
+            <Badge variant="Secondary" fill="Soft">
+              <Text size="L400">{millisecondsToMinutesAndSeconds(info.duration ?? 0)}</Text>
+            </Badge>
+            <Badge variant="Secondary" fill="Soft">
+              <Text size="L400">{bytesToSize(info.size)}</Text>
+            </Badge>
+          </Box>
+        )}
+      </Box>
+    );
+  }
+);
diff --git a/src/app/organisms/room/message/fileRenderer.tsx b/src/app/organisms/room/message/fileRenderer.tsx
new file mode 100644 (file)
index 0000000..5ff70b3
--- /dev/null
@@ -0,0 +1,45 @@
+import React from 'react';
+import { MatrixEvent } from 'matrix-js-sdk';
+import { IFileContent } from '../../../../types/matrix/common';
+import {
+  Attachment,
+  AttachmentBox,
+  AttachmentContent,
+  AttachmentHeader,
+} from '../../../components/message';
+import { FileHeader } from './FileHeader';
+import { FileContent } from './FileContent';
+import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
+
+export const fileRenderer = (mEventId: string, mEvent: MatrixEvent) => {
+  const content = mEvent.getContent<IFileContent>();
+
+  const fileInfo = content?.info;
+  const mxcUrl = content.file?.url ?? content.url;
+
+  if (typeof mxcUrl !== 'string') {
+    return null;
+  }
+
+  return (
+    <Attachment>
+      <AttachmentHeader>
+        <FileHeader
+          body={content.body ?? 'Unnamed File'}
+          mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
+        />
+      </AttachmentHeader>
+      <AttachmentBox>
+        <AttachmentContent>
+          <FileContent
+            body={content.body ?? 'File'}
+            info={fileInfo ?? {}}
+            mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
+            url={mxcUrl}
+            encInfo={content.file}
+          />
+        </AttachmentContent>
+      </AttachmentBox>
+    </Attachment>
+  );
+};
diff --git a/src/app/organisms/room/message/index.ts b/src/app/organisms/room/message/index.ts
new file mode 100644 (file)
index 0000000..d890861
--- /dev/null
@@ -0,0 +1,10 @@
+export * from './ImageContent';
+export * from './VideoContent';
+export * from './FileHeader';
+export * from './fileRenderer';
+export * from './AudioContent';
+export * from './Reactions';
+export * from './EventContent';
+export * from './Message';
+export * from './EncryptedContent';
+export * from './StickerContent';
diff --git a/src/app/organisms/room/message/styles.css.ts b/src/app/organisms/room/message/styles.css.ts
new file mode 100644 (file)
index 0000000..1fe7fe0
--- /dev/null
@@ -0,0 +1,72 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, config, toRem } from 'folds';
+
+export const RelativeBase = style([
+  DefaultReset,
+  {
+    position: 'relative',
+    width: '100%',
+    height: '100%',
+  },
+]);
+
+export const AbsoluteContainer = style([
+  DefaultReset,
+  {
+    position: 'absolute',
+    top: 0,
+    left: 0,
+    width: '100%',
+    height: '100%',
+  },
+]);
+
+export const AbsoluteFooter = style([
+  DefaultReset,
+  {
+    position: 'absolute',
+    bottom: config.space.S100,
+    left: config.space.S100,
+    right: config.space.S100,
+  },
+]);
+
+export const MessageBase = style({
+  position: 'relative',
+});
+
+export const MessageOptionsBase = style([
+  DefaultReset,
+  {
+    position: 'absolute',
+    top: toRem(-30),
+    right: 0,
+    zIndex: 1,
+  },
+]);
+export const MessageOptionsBar = style([
+  DefaultReset,
+  {
+    padding: config.space.S100,
+  },
+]);
+
+export const MessageQuickReaction = style({
+  minWidth: toRem(32),
+});
+
+export const MessageMenuGroup = style({
+  padding: config.space.S100,
+});
+
+export const MessageMenuItemText = style({
+  flexGrow: 1,
+});
+
+export const ReactionsContainer = style({
+  selectors: {
+    '&:empty': {
+      display: 'none',
+    },
+  },
+});
diff --git a/src/app/organisms/room/message/util.ts b/src/app/organisms/room/message/util.ts
new file mode 100644 (file)
index 0000000..2cc4341
--- /dev/null
@@ -0,0 +1,23 @@
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { decryptFile } from '../../../utils/matrix';
+
+export const getFileSrcUrl = async (
+  httpUrl: string,
+  mimeType: string,
+  encInfo?: EncryptedAttachmentInfo
+): Promise<string> => {
+  if (encInfo) {
+    if (typeof httpUrl !== 'string') throw new Error('Malformed event');
+    const encRes = await fetch(httpUrl, { method: 'GET' });
+    const encData = await encRes.arrayBuffer();
+    const decryptedBlob = await decryptFile(encData, mimeType, encInfo);
+    return URL.createObjectURL(decryptedBlob);
+  }
+  return httpUrl;
+};
+
+export const getSrcFile = async (src: string): Promise<Blob> => {
+  const res = await fetch(src, { method: 'GET' });
+  const blob = await res.blob();
+  return blob;
+};
index 2b0c50edbd4785532c14e16de3af5c2278dc5c5b..5b03af91b82348bf21822587b3f10823f7205ec9 100644 (file)
@@ -1,6 +1,6 @@
 import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
 import to from 'await-to-js';
-import { IThumbnailContent } from '../../../types/matrix/common';
+import { IThumbnailContent, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../types/matrix/common';
 import {
   getImageFileUrl,
   getThumbnail,
@@ -11,7 +11,7 @@ import {
 } 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';
+import { encodeBlurHash } from '../../utils/blurHash';
 
 const generateThumbnailContent = async (
   mx: MatrixClient,
@@ -38,7 +38,11 @@ const generateThumbnailContent = async (
   return thumbnailContent;
 };
 
-export const getImageMsgContent = async (item: TUploadItem, mxc: string): Promise<IContent> => {
+export const getImageMsgContent = async (
+  mx: MatrixClient,
+  item: TUploadItem,
+  mxc: string
+): Promise<IContent> => {
   const { file, originalFile, encInfo } = item;
   const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
   if (imgError) console.warn(imgError);
@@ -48,9 +52,24 @@ export const getImageMsgContent = async (item: TUploadItem, mxc: string): Promis
     body: file.name,
   };
   if (imgEl) {
+    const blurHash = encodeBlurHash(imgEl);
+    const [thumbError, thumbContent] = await to(
+      generateThumbnailContent(
+        mx,
+        imgEl,
+        getThumbnailDimensions(imgEl.width, imgEl.height),
+        !!encInfo
+      )
+    );
+
+    if (thumbContent && thumbContent.thumbnail_info) {
+      thumbContent.thumbnail_info[MATRIX_BLUR_HASH_PROPERTY_NAME] = blurHash;
+    }
+    if (thumbError) console.warn(thumbError);
     content.info = {
       ...getImageInfo(imgEl, file),
-      [MATRIX_BLUR_HASH_PROPERTY_NAME]: encodeBlurHash(imgEl),
+      [MATRIX_BLUR_HASH_PROPERTY_NAME]: blurHash,
+      ...thumbContent,
     };
   }
   if (encInfo) {
@@ -87,6 +106,9 @@ export const getVideoMsgContent = async (
         !!encInfo
       )
     );
+    if (thumbContent && thumbContent.thumbnail_info) {
+      thumbContent.thumbnail_info[MATRIX_BLUR_HASH_PROPERTY_NAME] = encodeBlurHash(videoEl);
+    }
     if (thumbError) console.warn(thumbError);
     content.info = {
       ...getVideoInfo(videoEl, file),
diff --git a/src/app/organisms/room/reaction-viewer/ReactionViewer.css.ts b/src/app/organisms/room/reaction-viewer/ReactionViewer.css.ts
new file mode 100644 (file)
index 0000000..a8a85b0
--- /dev/null
@@ -0,0 +1,31 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
+
+export const ReactionViewer = style([
+  DefaultReset,
+  {
+    height: '100%',
+  },
+]);
+
+export const Sidebar = style({
+  backgroundColor: color.Background.Container,
+  color: color.Background.OnContainer,
+});
+export const SidebarContent = style({
+  padding: config.space.S200,
+  paddingRight: 0,
+});
+
+export const Header = style({
+  paddingLeft: config.space.S400,
+  paddingRight: config.space.S300,
+
+  flexShrink: 0,
+  gap: config.space.S200,
+});
+
+export const Content = style({
+  paddingLeft: config.space.S200,
+  paddingBottom: config.space.S400,
+});
diff --git a/src/app/organisms/room/reaction-viewer/ReactionViewer.tsx b/src/app/organisms/room/reaction-viewer/ReactionViewer.tsx
new file mode 100644 (file)
index 0000000..702f04a
--- /dev/null
@@ -0,0 +1,155 @@
+import React, { useCallback, useState } from 'react';
+import classNames from 'classnames';
+import {
+  Avatar,
+  AvatarFallback,
+  AvatarImage,
+  Box,
+  Header,
+  Icon,
+  IconButton,
+  Icons,
+  Line,
+  MenuItem,
+  Scroll,
+  Text,
+  as,
+  config,
+} from 'folds';
+import { MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
+import { Relations } from 'matrix-js-sdk/lib/models/relations';
+import { getMemberDisplayName } from '../../../utils/room';
+import { eventWithShortcode, getMxIdLocalPart } from '../../../utils/matrix';
+import * as css from './ReactionViewer.css';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import colorMXID from '../../../../util/colorMXID';
+import { openProfileViewer } from '../../../../client/action/navigation';
+import { useRelations } from '../../../hooks/useRelations';
+import { Reaction } from '../../../components/message';
+import { getHexcodeForEmoji, getShortcodeFor } from '../../../plugins/emoji';
+
+export type ReactionViewerProps = {
+  room: Room;
+  initialKey?: string;
+  relations: Relations;
+  requestClose: () => void;
+};
+export const ReactionViewer = as<'div', ReactionViewerProps>(
+  ({ className, room, initialKey, relations, requestClose, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const reactions = useRelations(
+      relations,
+      useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
+    );
+    const [selectedKey, setSelectedKey] = useState<string>(initialKey ?? reactions[0][0]);
+
+    const getName = (member: RoomMember) =>
+      getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
+
+    const getReactionsForKey = (key: string): MatrixEvent[] => {
+      const reactSet = reactions.find(([k]) => k === key)?.[1];
+      if (!reactSet) return [];
+      return Array.from(reactSet);
+    };
+
+    const selectedReactions = getReactionsForKey(selectedKey);
+    const selectedShortcode =
+      selectedReactions.find(eventWithShortcode)?.getContent().shortcode ??
+      getShortcodeFor(getHexcodeForEmoji(selectedKey)) ??
+      selectedKey;
+
+    return (
+      <Box
+        className={classNames(css.ReactionViewer, className)}
+        direction="Row"
+        {...props}
+        ref={ref}
+      >
+        <Box shrink="No" className={css.Sidebar}>
+          <Scroll visibility="Hover" hideTrack size="300">
+            <Box className={css.SidebarContent} direction="Column" gap="200">
+              {reactions.map(([key, evts]) => (
+                <Reaction
+                  key={key}
+                  mx={mx}
+                  reaction={key}
+                  count={evts.size}
+                  aria-selected={key === selectedKey}
+                  onClick={() => setSelectedKey(key)}
+                />
+              ))}
+            </Box>
+          </Scroll>
+        </Box>
+        <Line variant="Surface" direction="Vertical" size="300" />
+        <Box grow="Yes" direction="Column">
+          <Header className={css.Header} variant="Surface" size="600">
+            <Box grow="Yes">
+              <Text size="H3" truncate>{`Reacted with :${selectedShortcode}:`}</Text>
+            </Box>
+            <IconButton size="300" onClick={requestClose}>
+              <Icon src={Icons.Cross} />
+            </IconButton>
+          </Header>
+
+          <Box grow="Yes">
+            <Scroll visibility="Hover" hideTrack size="300">
+              <Box className={css.Content} direction="Column">
+                {selectedReactions.map((mEvent) => {
+                  const senderId = mEvent.getSender();
+                  if (!senderId) return null;
+                  const member = room.getMember(senderId);
+                  if (!member) return null;
+                  const name = getName(member);
+
+                  const avatarUrl = member.getAvatarUrl(
+                    mx.baseUrl,
+                    100,
+                    100,
+                    'crop',
+                    undefined,
+                    false
+                  );
+
+                  return (
+                    <MenuItem
+                      key={member.userId}
+                      style={{ padding: `0 ${config.space.S200}` }}
+                      radii="400"
+                      onClick={() => {
+                        requestClose();
+                        openProfileViewer(member.userId, room.roomId);
+                      }}
+                      before={
+                        <Avatar size="200">
+                          {avatarUrl ? (
+                            <AvatarImage src={avatarUrl} />
+                          ) : (
+                            <AvatarFallback
+                              style={{
+                                background: colorMXID(member.userId),
+                                color: 'white',
+                              }}
+                            >
+                              <Text size="H6">{name[0]}</Text>
+                            </AvatarFallback>
+                          )}
+                        </Avatar>
+                      }
+                    >
+                      <Box grow="Yes">
+                        <Text size="T400" truncate>
+                          {name}
+                        </Text>
+                      </Box>
+                    </MenuItem>
+                  );
+                })}
+              </Box>
+            </Scroll>
+          </Box>
+        </Box>
+      </Box>
+    );
+  }
+);
diff --git a/src/app/organisms/room/reaction-viewer/index.ts b/src/app/organisms/room/reaction-viewer/index.ts
new file mode 100644 (file)
index 0000000..172e6f3
--- /dev/null
@@ -0,0 +1 @@
+export * from './ReactionViewer';
index a0869b6154afa5fcbfb757e11a63918b6c19dde2..fef158675d2c5ae5ac947bd2ce12f8ca38228d30 100644 (file)
@@ -6,7 +6,7 @@ import cons from '../../../client/state/cons';
 import settings from '../../../client/state/settings';
 import navigation from '../../../client/state/navigation';
 import {
-  toggleSystemTheme, toggleMarkdown, toggleMembershipEvents, toggleNickAvatarEvents,
+  toggleSystemTheme, toggleMarkdown,
   toggleNotifications, toggleNotificationSounds,
 } from '../../../client/action/settings';
 import { usePermission } from '../../hooks/usePermission';
@@ -43,10 +43,21 @@ import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
 
 import CinnySVG from '../../../../public/res/svg/cinny.svg';
 import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
 
 function AppearanceSection() {
   const [, updateState] = useState({});
 
+  const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
+  const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
+  const [useSystemEmoji, setUseSystemEmoji] = useSetting(settingsAtom, 'useSystemEmoji');
+  const [hideMembershipEvents, setHideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
+  const [hideNickAvatarEvents, setHideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
+  const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
+  const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
+  const spacings = ['0', '100', '200', '300', '400', '500']
+
   return (
     <div className="settings-appearance">
       <div className="settings-appearance__card">
@@ -80,9 +91,52 @@ function AppearanceSection() {
             />
         )}
         />
+        <SettingTile
+          title="Use System Emoji"
+          options={(
+            <Toggle
+              isActive={useSystemEmoji}
+              onToggle={() => setUseSystemEmoji(!useSystemEmoji)}
+            />
+          )}
+          content={<Text variant="b3">Use system emoji instead of Twitter emojis.</Text>}
+        />
       </div>
       <div className="settings-appearance__card">
         <MenuHeader>Room messages</MenuHeader>
+        <SettingTile
+          title="Message Layout"
+          content={
+            <SegmentedControls
+            selected={messageLayout}
+            segments={[
+              { text: 'Modern' },
+              { text: 'Compact' },
+              { text: 'Bubble' },
+            ]}
+            onSelect={(index) => setMessageLayout(index)}
+          />
+          }
+        />
+        <SettingTile
+          title="Message Spacing"
+          content={
+            <SegmentedControls
+            selected={spacings.findIndex((s) => s === messageSpacing)}
+            segments={[
+              { text: 'No' },
+              { text: 'XXS' },
+              { text: 'XS' },
+              { text: 'S' },
+              { text: 'M' },
+              { text: 'L' },
+            ]}
+            onSelect={(index) => {
+              setMessageSpacing(spacings[index])
+            }}
+          />
+          }
+        />
         <SettingTile
           title="Markdown formatting"
           options={(
@@ -97,8 +151,8 @@ function AppearanceSection() {
           title="Hide membership events"
           options={(
             <Toggle
-              isActive={settings.hideMembershipEvents}
-              onToggle={() => { toggleMembershipEvents(); updateState({}); }}
+              isActive={hideMembershipEvents}
+              onToggle={() => setHideMembershipEvents(!hideMembershipEvents)}
             />
           )}
           content={<Text variant="b3">Hide membership change messages from room timeline. (Join, Leave, Invite, Kick and Ban)</Text>}
@@ -107,12 +161,32 @@ function AppearanceSection() {
           title="Hide nick/avatar events"
           options={(
             <Toggle
-              isActive={settings.hideNickAvatarEvents}
-              onToggle={() => { toggleNickAvatarEvents(); updateState({}); }}
+              isActive={hideNickAvatarEvents}
+              onToggle={() => setHideNickAvatarEvents(!hideNickAvatarEvents)}
             />
           )}
           content={<Text variant="b3">Hide nick and avatar change messages from room timeline.</Text>}
         />
+        <SettingTile
+          title="Disable media auto load"
+          options={(
+            <Toggle
+              isActive={!mediaAutoLoad}
+              onToggle={() => setMediaAutoLoad(!mediaAutoLoad)}
+            />
+          )}
+          content={<Text variant="b3">Prevent images and videos from auto loading to save bandwidth.</Text>}
+        />
+        <SettingTile
+          title="Show hidden events"
+          options={(
+            <Toggle
+              isActive={showHiddenEvents}
+              onToggle={() => setShowHiddenEvents(!showHiddenEvents)}
+            />
+          )}
+          content={<Text variant="b3">Show hidden state and message events.</Text>}
+        />
       </div>
     </div>
   );
index 36bd044e5bbbe57e4a9fee869b9f05e8c99d12eb..2462b7ffef61618a1008d69e1e55ba79733b027a 100644 (file)
@@ -1,4 +1,4 @@
-import { CompactEmoji } from 'emojibase';
+import { CompactEmoji, fromUnicodeToHexcode } 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';
@@ -24,6 +24,16 @@ export type IEmojiGroup = {
   emojis: IEmoji[];
 };
 
+export const getShortcodesFor = (hexcode: string): string[] | string | undefined =>
+  joypixels[hexcode] || emojibase[hexcode];
+
+export const getShortcodeFor = (hexcode: string): string | undefined => {
+  const shortcode = joypixels[hexcode] || emojibase[hexcode];
+  return Array.isArray(shortcode) ? shortcode[0] : shortcode;
+};
+
+export const getHexcodeForEmoji = fromUnicodeToHexcode;
+
 export const emojiGroups: IEmojiGroup[] = [
   {
     id: EmojiGroupId.People,
@@ -86,7 +96,7 @@ function getGroupIndex(emoji: IEmoji): number | undefined {
 }
 
 emojisData.forEach((emoji) => {
-  const myShortCodes = joypixels[emoji.hexcode] || emojibase[emoji.hexcode];
+  const myShortCodes = getShortcodesFor(emoji.hexcode);
   if (!myShortCodes) return;
   if (Array.isArray(myShortCodes) && myShortCodes.length === 0) return;
 
diff --git a/src/app/plugins/pdfjs-dist.ts b/src/app/plugins/pdfjs-dist.ts
new file mode 100644 (file)
index 0000000..ccdef5e
--- /dev/null
@@ -0,0 +1,47 @@
+import { useCallback } from 'react';
+import type * as PdfJsDist from 'pdfjs-dist';
+import type { GetViewportParameters } from 'pdfjs-dist/types/src/display/api';
+import { useAsyncCallback } from '../hooks/useAsyncCallback';
+
+export const usePdfJSLoader = () =>
+  useAsyncCallback(
+    useCallback(async () => {
+      const pdf = await import('pdfjs-dist');
+      pdf.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';
+      return pdf;
+    }, [])
+  );
+
+export const usePdfDocumentLoader = (pdfJS: typeof PdfJsDist | undefined, src: string) =>
+  useAsyncCallback(
+    useCallback(async () => {
+      if (!pdfJS) {
+        throw new Error('PdfJS is not loaded');
+      }
+      const doc = await pdfJS.getDocument(src).promise;
+      return doc;
+    }, [pdfJS, src])
+  );
+
+export const createPage = async (
+  doc: PdfJsDist.PDFDocumentProxy,
+  pNo: number,
+  opts: GetViewportParameters
+): Promise<HTMLCanvasElement> => {
+  const page = await doc.getPage(pNo);
+  const pageViewport = page.getViewport(opts);
+  const canvas = document.createElement('canvas');
+  const context = canvas.getContext('2d');
+
+  if (!context) throw new Error('failed to render page.');
+
+  canvas.width = pageViewport.width;
+  canvas.height = pageViewport.height;
+
+  page.render({
+    canvasContext: context,
+    viewport: pageViewport,
+  });
+
+  return canvas;
+};
diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx
new file mode 100644 (file)
index 0000000..aba5997
--- /dev/null
@@ -0,0 +1,274 @@
+/* eslint-disable jsx-a11y/alt-text */
+import React, { ReactEventHandler, Suspense, lazy } from 'react';
+import {
+  Element,
+  Text as DOMText,
+  HTMLReactParserOptions,
+  attributesToProps,
+  domToReact,
+} from 'html-react-parser';
+import { MatrixClient, Room } from 'matrix-js-sdk';
+import classNames from 'classnames';
+import { Scroll, Text } from 'folds';
+import { Opts as LinkifyOpts } from 'linkifyjs';
+import Linkify from 'linkify-react';
+import { ErrorBoundary } from 'react-error-boundary';
+import * as css from '../styles/CustomHtml.css';
+import { getMxIdLocalPart, getRoomWithCanonicalAlias } from '../utils/matrix';
+import { getMemberDisplayName } from '../utils/room';
+
+const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
+
+export const LINKIFY_OPTS: LinkifyOpts = {
+  attributes: {
+    target: '_blank',
+    rel: 'noreferrer noopener',
+  },
+  validate: {
+    url: (value) => /^(https|http|ftp|mailto|magnet)?:/.test(value),
+  },
+};
+
+export const getReactCustomHtmlParser = (
+  mx: MatrixClient,
+  room: Room,
+  params: {
+    handleSpoilerClick?: ReactEventHandler<HTMLElement>;
+    handleMentionClick?: ReactEventHandler<HTMLElement>;
+  }
+): HTMLReactParserOptions => {
+  const opts: HTMLReactParserOptions = {
+    replace: (domNode) => {
+      if (domNode instanceof Element && 'name' in domNode) {
+        const { name, attribs, children, parent } = domNode;
+        const props = attributesToProps(attribs);
+
+        if (name === 'h1') {
+          return (
+            <Text className={css.Heading} size="H2" {...props}>
+              {domToReact(children, opts)}
+            </Text>
+          );
+        }
+
+        if (name === 'h2') {
+          return (
+            <Text className={css.Heading} size="H3" {...props}>
+              {domToReact(children, opts)}
+            </Text>
+          );
+        }
+
+        if (name === 'h3') {
+          return (
+            <Text className={css.Heading} size="H4" {...props}>
+              {domToReact(children, opts)}
+            </Text>
+          );
+        }
+
+        if (name === 'h4') {
+          return (
+            <Text className={css.Heading} size="H4" {...props}>
+              {domToReact(children, opts)}
+            </Text>
+          );
+        }
+
+        if (name === 'h5') {
+          return (
+            <Text className={css.Heading} size="H5" {...props}>
+              {domToReact(children, opts)}
+            </Text>
+          );
+        }
+
+        if (name === 'h6') {
+          return (
+            <Text className={css.Heading} size="H6" {...props}>
+              {domToReact(children, opts)}
+            </Text>
+          );
+        }
+
+        if (name === 'p') {
+          return (
+            <Text className={classNames(css.Paragraph, css.MarginSpaced)} size="Inherit" {...props}>
+              {domToReact(children, opts)}
+            </Text>
+          );
+        }
+
+        if (name === 'pre') {
+          return (
+            <Text as="pre" className={css.CodeBlock} {...props}>
+              <Scroll
+                direction="Horizontal"
+                variant="Secondary"
+                size="300"
+                visibility="Hover"
+                hideTrack
+              >
+                <div className={css.CodeBlockInternal}>{domToReact(children, opts)}</div>
+              </Scroll>
+            </Text>
+          );
+        }
+
+        if (name === 'blockquote') {
+          return (
+            <Text size="Inherit" as="blockquote" className={css.BlockQuote} {...props}>
+              {domToReact(children, opts)}
+            </Text>
+          );
+        }
+
+        if (name === 'ul') {
+          return (
+            <ul className={css.List} {...props}>
+              {domToReact(children, opts)}
+            </ul>
+          );
+        }
+        if (name === 'ol') {
+          return (
+            <ol className={css.List} {...props}>
+              {domToReact(children, opts)}
+            </ol>
+          );
+        }
+
+        if (name === 'code') {
+          if (parent && 'name' in parent && parent.name === 'pre') {
+            const codeReact = domToReact(children, opts);
+            if (typeof codeReact === 'string') {
+              let lang = props.className;
+              if (lang === 'language-rs') lang = 'language-rust';
+              return (
+                <ErrorBoundary fallback={<code {...props}>{codeReact}</code>}>
+                  <Suspense fallback={<code {...props}>{codeReact}</code>}>
+                    <ReactPrism>
+                      {(ref) => (
+                        <code ref={ref} {...props} className={lang}>
+                          {codeReact}
+                        </code>
+                      )}
+                    </ReactPrism>
+                  </Suspense>
+                </ErrorBoundary>
+              );
+            }
+          } else {
+            return (
+              <code className={css.Code} {...props}>
+                {domToReact(children, opts)}
+              </code>
+            );
+          }
+        }
+
+        if (name === 'a') {
+          const mention = decodeURIComponent(props.href).match(
+            /^https?:\/\/matrix.to\/#\/((@|#|!).+:[^?/]+)/
+          );
+          if (mention) {
+            // convert mention link to pill
+            const mentionId = mention[1];
+            const mentionPrefix = mention[2];
+            if (mentionPrefix === '#' || mentionPrefix === '!') {
+              const mentionRoom =
+                mentionPrefix === '#'
+                  ? getRoomWithCanonicalAlias(mx, mentionId)
+                  : mx.getRoom(mentionId);
+              const mentionName = mentionRoom?.name;
+
+              const mentionDisplayName =
+                mentionName && (mentionName.startsWith('#') ? mentionName : `#${mentionName}`);
+              return (
+                <span
+                  {...props}
+                  className={css.Mention({
+                    highlight: room.roomId === (mentionRoom?.roomId ?? mentionId),
+                  })}
+                  data-mention-id={mentionRoom?.roomId ?? mentionId}
+                  data-mention-href={props.href}
+                  role="button"
+                  tabIndex={params.handleMentionClick ? 0 : -1}
+                  onKeyDown={params.handleMentionClick}
+                  onClick={params.handleMentionClick}
+                  style={{ cursor: 'pointer' }}
+                >
+                  {mentionDisplayName ?? mentionId}
+                </span>
+              );
+            }
+            if (mentionPrefix === '@')
+              return (
+                <span
+                  {...props}
+                  className={css.Mention({ highlight: mx.getUserId() === mentionId })}
+                  data-mention-id={mentionId}
+                  data-mention-href={props.href}
+                  role="button"
+                  tabIndex={params.handleMentionClick ? 0 : -1}
+                  onKeyDown={params.handleMentionClick}
+                  onClick={params.handleMentionClick}
+                  style={{ cursor: 'pointer' }}
+                >
+                  {`@${getMemberDisplayName(room, mentionId) ?? getMxIdLocalPart(mentionId)}`}
+                </span>
+              );
+          }
+        }
+
+        if (name === 'span' && 'data-mx-spoiler' in props) {
+          return (
+            <span
+              {...props}
+              role="button"
+              tabIndex={params.handleSpoilerClick ? 0 : -1}
+              onKeyDown={params.handleSpoilerClick}
+              onClick={params.handleSpoilerClick}
+              className={css.Spoiler()}
+              aria-pressed
+              style={{ cursor: 'pointer' }}
+            >
+              {domToReact(children, opts)}
+            </span>
+          );
+        }
+
+        if (name === 'img') {
+          const htmlSrc = mx.mxcUrlToHttp(props.src);
+          if (htmlSrc && props.src.startsWith('mxc://') === false) {
+            return (
+              <a href={htmlSrc} target="_blank" rel="noreferrer noopener">
+                {props.alt && htmlSrc}
+              </a>
+            );
+          }
+          if (htmlSrc && 'data-mx-emoticon' in props) {
+            return (
+              <span className={css.EmoticonBase}>
+                <span className={css.Emoticon()} contentEditable={false}>
+                  <img className={css.EmoticonImg} src={htmlSrc} data-mx-emoticon />
+                </span>
+              </span>
+            );
+          }
+          if (htmlSrc) return <img className={css.Img} {...props} src={htmlSrc} />;
+        }
+      }
+
+      if (
+        domNode instanceof DOMText &&
+        !(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'code') &&
+        !(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'a')
+      ) {
+        return <Linkify options={LINKIFY_OPTS}>{domNode.data}</Linkify>;
+      }
+      return undefined;
+    },
+  };
+  return opts;
+};
diff --git a/src/app/plugins/react-prism/ReactPrism.css b/src/app/plugins/react-prism/ReactPrism.css
new file mode 100644 (file)
index 0000000..e6a1217
--- /dev/null
@@ -0,0 +1,97 @@
+.prism-light {
+  --prism-comment: #0f4777;
+  --prism-punctuation: #6d5050;
+  --prism-property: #9b1144;
+  --prism-boolean: #4816a3;
+  --prism-selector: #659604;
+  --prism-operator: #2a2a2a;
+  --prism-atrule: #7e6d00;
+  --prism-keyword: #00829f;
+  --prism-regex: #9b6426;
+}
+
+.prism-dark {
+  --prism-comment: #8292a2;
+  --prism-punctuation: #f8f8f2;
+  --prism-property: #f92672;
+  --prism-boolean: #ae81ff;
+  --prism-selector: #a6e22e;
+  --prism-operator: #f8f8f2;
+  --prism-atrule: #e6db74;
+  --prism-keyword: #66d9ef;
+  --prism-regex: #fd971f;
+}
+
+code .token.comment,
+code .token.prolog,
+code .token.doctype,
+code .token.cdata {
+  color: var(--prism-comment);
+}
+
+code .token.punctuation {
+  color: var(--prism-punctuation);
+}
+
+code .token.namespace {
+  opacity: 0.7;
+}
+
+code .token.property,
+code .token.tag,
+code .token.constant,
+code .token.symbol,
+code .token.deleted {
+  color: var(--prism-property);
+}
+
+code .token.boolean,
+code .token.number {
+  color: var(--prism-boolean);
+}
+
+code .token.selector,
+code .token.attr-name,
+code .token.string,
+code .token.char,
+code .token.builtin,
+code .token.inserted {
+  color: var(--prism-selector);
+}
+
+code .token.operator,
+code .token.entity,
+code .token.url,
+.language-css code .token.string,
+.style code .token.string,
+code .token.variable {
+  color: var(--prism-operator);
+}
+
+code .token.atrule,
+code .token.attr-value,
+code .token.function,
+code .token.class-name {
+  color: var(--prism-atrule);
+}
+
+code .token.keyword {
+  color: var(--prism-keyword);
+}
+
+code .token.regex,
+code .token.important {
+  color: var(--prism-regex);
+}
+
+code .token.important,
+code .token.bold {
+  font-weight: bold;
+}
+code .token.italic {
+  font-style: italic;
+}
+
+code .token.entity {
+  cursor: help;
+}
diff --git a/src/app/plugins/react-prism/ReactPrism.tsx b/src/app/plugins/react-prism/ReactPrism.tsx
new file mode 100644 (file)
index 0000000..1aa7954
--- /dev/null
@@ -0,0 +1,35 @@
+import React, { MutableRefObject, ReactNode, useEffect, useRef } from 'react';
+
+import Prism from 'prismjs';
+
+import 'prismjs/components/prism-json';
+import 'prismjs/components/prism-javascript';
+import 'prismjs/components/prism-typescript';
+import 'prismjs/components/prism-css';
+import 'prismjs/components/prism-sass';
+import 'prismjs/components/prism-swift';
+import 'prismjs/components/prism-rust';
+import 'prismjs/components/prism-go';
+import 'prismjs/components/prism-c';
+import 'prismjs/components/prism-cpp';
+import 'prismjs/components/prism-java';
+import 'prismjs/components/prism-python';
+
+import './ReactPrism.css';
+// we apply theme in client/state/settings.js
+// using classNames .prism-dark .prism-light from ReactPrism.css
+
+export default function ReactPrism({
+  children,
+}: {
+  children: (ref: MutableRefObject<null>) => ReactNode;
+}) {
+  const codeRef = useRef<HTMLElement>(null);
+
+  useEffect(() => {
+    const el = codeRef.current;
+    if (el) Prism.highlightElement(el);
+  }, []);
+
+  return <>{children(codeRef as MutableRefObject<null>)}</>;
+}
index 7739c589e1594a74725a57e2a98027535d33c2e5..667c7c275d06fd1cc2ccc19932c34be885ab52b5 100644 (file)
@@ -1,15 +1,22 @@
 import { atom } from 'jotai';
 
 const STORAGE_KEY = 'settings';
+export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
+export type MessageLayout = 0 | 1 | 2;
 export interface Settings {
   themeIndex: number;
   useSystemTheme: boolean;
   isMarkdown: boolean;
   editorToolbar: boolean;
   isPeopleDrawer: boolean;
+  useSystemEmoji: boolean;
 
+  messageLayout: MessageLayout;
+  messageSpacing: MessageSpacing;
   hideMembershipEvents: boolean;
   hideNickAvatarEvents: boolean;
+  mediaAutoLoad: boolean;
+  showHiddenEvents: boolean;
 
   showNotifications: boolean;
   isNotificationSounds: boolean;
@@ -21,9 +28,14 @@ const defaultSettings: Settings = {
   isMarkdown: true,
   editorToolbar: false,
   isPeopleDrawer: true,
+  useSystemEmoji: false,
 
+  messageLayout: 0,
+  messageSpacing: '400',
   hideMembershipEvents: false,
   hideNickAvatarEvents: true,
+  mediaAutoLoad: true,
+  showHiddenEvents: false,
 
   showNotifications: true,
   isNotificationSounds: true,
@@ -32,7 +44,10 @@ const defaultSettings: Settings = {
 export const getSettings = () => {
   const settings = localStorage.getItem(STORAGE_KEY);
   if (settings === null) return defaultSettings;
-  return JSON.parse(settings) as Settings;
+  return {
+    ...defaultSettings,
+    ...(JSON.parse(settings) as Settings),
+  };
 };
 
 export const setSettings = (settings: Settings) => {
diff --git a/src/app/state/typingMembers.ts b/src/app/state/typingMembers.ts
new file mode 100644 (file)
index 0000000..b87817d
--- /dev/null
@@ -0,0 +1,70 @@
+import { atom, useSetAtom } from 'jotai';
+import { selectAtom } from 'jotai/utils';
+import {
+  MatrixClient,
+  RoomMember,
+  RoomMemberEvent,
+  RoomMemberEventHandlerMap,
+} from 'matrix-js-sdk';
+import { useEffect } from 'react';
+
+export type IRoomIdToTypingMembers = Map<string, RoomMember[]>;
+
+export type IRoomIdToTypingMembersAction =
+  | {
+      type: 'PUT';
+      roomId: string;
+      member: RoomMember;
+    }
+  | {
+      type: 'DELETE';
+      roomId: string;
+      member: RoomMember;
+    };
+
+const baseRoomIdToTypingMembersAtom = atom<IRoomIdToTypingMembers>(new Map());
+export const roomIdToTypingMembersAtom = atom<IRoomIdToTypingMembers, IRoomIdToTypingMembersAction>(
+  (get) => get(baseRoomIdToTypingMembersAtom),
+  (get, set, action) => {
+    const roomIdToTypingMembers = get(baseRoomIdToTypingMembersAtom);
+    let typingMembers = roomIdToTypingMembers.get(action.roomId) ?? [];
+
+    typingMembers = typingMembers.filter((member) => member.userId !== action.member.userId);
+
+    if (action.type === 'PUT') {
+      typingMembers = [...typingMembers, action.member];
+    }
+    roomIdToTypingMembers.set(action.roomId, typingMembers);
+    set(baseRoomIdToTypingMembersAtom, new Map([...roomIdToTypingMembers]));
+  }
+);
+
+export const useBindRoomIdToTypingMembersAtom = (
+  mx: MatrixClient,
+  typingMembersAtom: typeof roomIdToTypingMembersAtom
+) => {
+  const setTypingMembers = useSetAtom(typingMembersAtom);
+
+  useEffect(() => {
+    const handleTypingEvent: RoomMemberEventHandlerMap[RoomMemberEvent.Typing] = (
+      event,
+      member
+    ) => {
+      setTypingMembers({
+        type: member.typing ? 'PUT' : 'DELETE',
+        roomId: member.roomId,
+        member,
+      });
+    };
+
+    mx.on(RoomMemberEvent.Typing, handleTypingEvent);
+    return () => {
+      mx.removeListener(RoomMemberEvent.Typing, handleTypingEvent);
+    };
+  }, [mx, setTypingMembers]);
+};
+
+export const selectRoomTypingMembersAtom = (
+  roomId: string,
+  typingMembersAtom: typeof roomIdToTypingMembersAtom
+) => selectAtom(typingMembersAtom, (atoms) => atoms.get(roomId) ?? []);
diff --git a/src/app/styles/CustomHtml.css.ts b/src/app/styles/CustomHtml.css.ts
new file mode 100644 (file)
index 0000000..0ace90c
--- /dev/null
@@ -0,0 +1,188 @@
+import { style } from '@vanilla-extract/css';
+import { recipe } from '@vanilla-extract/recipes';
+import { color, config, DefaultReset, toRem } from 'folds';
+
+export const MarginSpaced = style({
+  marginBottom: config.space.S200,
+  marginTop: config.space.S200,
+  selectors: {
+    '&:first-child': {
+      marginTop: 0,
+    },
+    '&:last-child': {
+      marginBottom: 0,
+    },
+  },
+});
+
+export const Paragraph = style([DefaultReset]);
+
+export const Heading = style([
+  DefaultReset,
+  MarginSpaced,
+  {
+    marginTop: config.space.S400,
+    selectors: {
+      '&:first-child': {
+        marginTop: 0,
+      },
+    },
+  },
+]);
+
+export const BlockQuote = style([
+  DefaultReset,
+  MarginSpaced,
+  {
+    paddingLeft: config.space.S200,
+    borderLeft: `${config.borderWidth.B700} solid ${color.SurfaceVariant.ContainerLine}`,
+    fontStyle: 'italic',
+  },
+]);
+
+const BaseCode = style({
+  fontFamily: 'monospace',
+  color: color.Secondary.OnContainer,
+  background: color.Secondary.Container,
+  border: `${config.borderWidth.B300} solid ${color.Secondary.ContainerLine}`,
+  borderRadius: config.radii.R300,
+});
+
+export const Code = style([
+  DefaultReset,
+  BaseCode,
+  {
+    padding: `0 ${config.space.S100}`,
+  },
+]);
+
+export const Spoiler = recipe({
+  base: [
+    DefaultReset,
+    {
+      padding: `0 ${config.space.S100}`,
+      backgroundColor: color.SurfaceVariant.ContainerActive,
+      borderRadius: config.radii.R300,
+      selectors: {
+        '&[aria-pressed=true]': {
+          color: 'transparent',
+        },
+      },
+    },
+  ],
+  variants: {
+    active: {
+      true: {
+        color: 'transparent',
+      },
+    },
+  },
+});
+
+export const CodeBlock = style([
+  DefaultReset,
+  BaseCode,
+  MarginSpaced,
+  {
+    fontStyle: 'normal',
+  },
+]);
+export const CodeBlockInternal = style({
+  padding: `${config.space.S200} ${config.space.S200} 0`,
+});
+
+export const List = style([
+  DefaultReset,
+  MarginSpaced,
+  {
+    padding: `0 ${config.space.S100}`,
+    paddingLeft: config.space.S600,
+  },
+]);
+
+export const Img = style([
+  DefaultReset,
+  MarginSpaced,
+  {
+    maxWidth: toRem(296),
+    borderRadius: config.radii.R300,
+  },
+]);
+
+export const InlineChromiumBugfix = style({
+  fontSize: 0,
+  lineHeight: 0,
+});
+
+export const Mention = recipe({
+  base: [
+    DefaultReset,
+    {
+      backgroundColor: color.SurfaceVariant.Container,
+      color: color.SurfaceVariant.OnContainer,
+      boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
+      padding: `0 ${toRem(2)}`,
+      borderRadius: config.radii.R300,
+      fontWeight: config.fontWeight.W500,
+    },
+  ],
+  variants: {
+    highlight: {
+      true: {
+        backgroundColor: color.Success.Container,
+        color: color.Success.OnContainer,
+        boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Success.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',
+  },
+]);
index cc9d88fa043d3edd6c4ef1e6717873f0f07ff10c..77db4115a8cce216bb20003e4ce76cbda32c78df 100644 (file)
@@ -10,10 +10,8 @@ import Navigation from '../../organisms/navigation/Navigation';
 import ContextMenu, { MenuItem } from '../../atoms/context-menu/ContextMenu';
 import IconButton from '../../atoms/button/IconButton';
 import ReusableContextMenu from '../../atoms/context-menu/ReusableContextMenu';
-import Room from '../../organisms/room/Room';
 import Windows from '../../organisms/pw/Windows';
 import Dialogs from '../../organisms/pw/Dialogs';
-import EmojiBoardOpener from '../../organisms/emoji-board/EmojiBoardOpener';
 
 import initMatrix from '../../../client/initMatrix';
 import navigation from '../../../client/state/navigation';
@@ -21,6 +19,21 @@ import cons from '../../../client/state/cons';
 
 import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
 import { MatrixClientProvider } from '../../hooks/useMatrixClient';
+import { ClientContent } from './ClientContent';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+
+function SystemEmojiFeature() {
+  const [systemEmoji] = useSetting(settingsAtom, 'useSystemEmoji');
+
+  if (systemEmoji) {
+    document.documentElement.style.setProperty('--font-emoji', 'Twemoji_DISABLED');
+  } else {
+    document.documentElement.style.setProperty('--font-emoji', 'Twemoji');
+  }
+
+  return null;
+}
 
 function Client() {
   const [isLoading, changeLoading] = useState(true);
@@ -111,12 +124,12 @@ function Client() {
           <Navigation />
         </div>
         <div className={`room__wrapper ${classNameHidden}`} ref={roomWrapperRef}>
-          <Room />
+          <ClientContent />
         </div>
         <Windows />
         <Dialogs />
-        <EmojiBoardOpener />
         <ReusableContextMenu />
+        <SystemEmojiFeature />
       </div>
     </MatrixClientProvider>
   );
diff --git a/src/app/templates/client/ClientContent.jsx b/src/app/templates/client/ClientContent.jsx
new file mode 100644 (file)
index 0000000..ada7008
--- /dev/null
@@ -0,0 +1,49 @@
+import React, { useState, useEffect } from 'react';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+import navigation from '../../../client/state/navigation';
+import { openNavigation } from '../../../client/action/navigation';
+
+import Welcome from '../../organisms/welcome/Welcome';
+import { RoomBaseView } from '../../organisms/room/Room';
+
+export function ClientContent() {
+  const [roomInfo, setRoomInfo] = useState({
+    room: null,
+    eventId: null,
+  });
+
+  const mx = initMatrix.matrixClient;
+
+  useEffect(() => {
+    const handleRoomSelected = (rId, pRoomId, eId) => {
+      roomInfo.roomTimeline?.removeInternalListeners();
+      const r = mx.getRoom(rId);
+      if (r) {
+        setRoomInfo({
+          room: r,
+          eventId: eId ?? null,
+        });
+      } else {
+        setRoomInfo({
+          room: null,
+          eventId: null,
+        });
+      }
+    };
+
+    navigation.on(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
+    return () => {
+      navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
+    };
+  }, [roomInfo, mx]);
+
+  const { room, eventId } = roomInfo;
+  if (!room) {
+    setTimeout(() => openNavigation());
+    return <Welcome />;
+  }
+
+  return <RoomBaseView room={room} eventId={eventId} />;
+}
index 0de5a9225b3d0d0b68b797d34170fe358c8fbb0b..3fe1ade0e7f8ffc635a9877f3e5724637dae782d 100644 (file)
@@ -1,15 +1,15 @@
 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 imgWidth = img instanceof HTMLVideoElement ? img.videoWidth : img.width;
+  const imgHeight = img instanceof HTMLVideoElement ? img.videoHeight : img.height;
   const canvas = document.createElement('canvas');
-  canvas.width = width || img.width;
-  canvas.height = height || img.height;
+  canvas.width = width || imgWidth;
+  canvas.height = height || imgHeight;
   const context = canvas.getContext('2d');
 
   if (!context) return undefined;
index d3804ae8e885b4931bc17c7fed5cf768690a3290..e007f222f98eb9e9bef5db4a186857b78fb11fa6 100644 (file)
@@ -11,6 +11,19 @@ export const bytesToSize = (bytes: number): string => {
   return `${(bytes / 1000 ** sizeIndex).toFixed(1)} ${sizes[sizeIndex]}`;
 };
 
+export const millisecondsToMinutesAndSeconds = (milliseconds: number): string => {
+  const seconds = Math.floor(milliseconds / 1000);
+  const mm = Math.floor(seconds / 60);
+  const ss = Math.round(seconds % 60);
+  return `${mm}:${ss < 10 ? '0' : ''}${ss}`;
+};
+
+export const secondsToMinutesAndSeconds = (seconds: number): string => {
+  const mm = Math.floor(seconds / 60);
+  const ss = Math.round(seconds % 60);
+  return `${mm}:${ss < 10 ? '0' : ''}${ss}`;
+};
+
 export const getFileTypeIcon = (icons: Record<IconName, IconSrc>, fileType: string): IconSrc => {
   const type = fileType.toLowerCase();
   if (type.startsWith('audio')) {
@@ -30,3 +43,37 @@ export const fulfilledPromiseSettledResult = <T>(prs: PromiseSettledResult<T>[])
     if (pr.status === 'fulfilled') values.push(pr.value);
     return values;
   }, []);
+
+export const binarySearch = <T>(items: T[], match: (item: T) => -1 | 0 | 1): T | undefined => {
+  const search = (start: number, end: number): T | undefined => {
+    if (start > end) return undefined;
+
+    const mid = Math.floor((start + end) / 2);
+
+    const result = match(items[mid]);
+    if (result === 0) return items[mid];
+
+    if (result === 1) return search(start, mid - 1);
+    return search(mid + 1, end);
+  };
+
+  return search(0, items.length - 1);
+};
+
+export const randomNumberBetween = (min: number, max: number) =>
+  Math.floor(Math.random() * (max - min + 1)) + min;
+
+export const scaleYDimension = (x: number, scaledX: number, y: number): number => {
+  const scaleFactor = scaledX / x;
+  return scaleFactor * y;
+};
+
+export const parseGeoUri = (location: string) => {
+  const [, data] = location.split(':');
+  const [cords] = data.split(';');
+  const [latitude, longitude] = cords.split(',');
+  return {
+    latitude,
+    longitude,
+  };
+};
index d717adf2e66282d7352b9c55167da4f097b4c862..a8dc4be2c8cb3dce0661bd8a91b4d7aff791c9e7 100644 (file)
@@ -7,7 +7,7 @@ export const editableActiveElement = (): boolean =>
   !!document.activeElement &&
   /^(input)|(textarea)$/.test(document.activeElement.nodeName.toLowerCase());
 
-export const inVisibleScrollArea = (
+export const isIntersectingScrollView = (
   scrollElement: HTMLElement,
   childElement: HTMLElement
 ): boolean => {
@@ -18,10 +18,25 @@ export const inVisibleScrollArea = (
   const childBottom = childTop + childElement.clientHeight;
 
   if (childTop >= scrollTop && childTop < scrollBottom) return true;
-  if (childTop < scrollTop && childBottom > scrollTop) return true;
+  if (childBottom > scrollTop && childBottom <= scrollBottom) return true;
+  if (childTop < scrollTop && childBottom > scrollBottom) return true;
   return false;
 };
 
+export const isInScrollView = (scrollElement: HTMLElement, childElement: HTMLElement): boolean => {
+  const scrollTop = scrollElement.offsetTop + scrollElement.scrollTop;
+  const scrollBottom = scrollTop + scrollElement.offsetHeight;
+  return (
+    childElement.offsetTop >= scrollTop &&
+    childElement.offsetTop + childElement.offsetHeight <= scrollBottom
+  );
+};
+
+export const canFitInScrollView = (
+  scrollElement: HTMLElement,
+  childElement: HTMLElement
+): boolean => childElement.offsetHeight < scrollElement.offsetHeight;
+
 export type FilesOrFile<T extends boolean | undefined = undefined> = T extends true ? File[] : File;
 
 export const selectFile = <M extends boolean | undefined = undefined>(
@@ -131,3 +146,43 @@ export const getThumbnail = (
       resolve(thumbnail ?? undefined);
     }, thumbnailMimeType ?? 'image/jpeg');
   });
+
+export type ScrollInfo = {
+  offsetTop: number;
+  top: number;
+  height: number;
+  viewHeight: number;
+  scrollable: boolean;
+};
+export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({
+  offsetTop: Math.round(target.offsetTop),
+  top: Math.round(target.scrollTop),
+  height: Math.round(target.scrollHeight),
+  viewHeight: Math.round(target.offsetHeight),
+  scrollable: target.scrollHeight > target.offsetHeight,
+});
+
+export const scrollToBottom = (scrollEl: HTMLElement, behavior?: 'auto' | 'instant' | 'smooth') => {
+  scrollEl.scrollTo({
+    top: Math.round(scrollEl.scrollHeight - scrollEl.offsetHeight),
+    behavior,
+  });
+};
+
+export const copyToClipboard = (text: string) => {
+  if (navigator.clipboard) {
+    navigator.clipboard.writeText(text);
+  } else {
+    const host = document.body;
+    const copyInput = document.createElement('input');
+    copyInput.style.position = 'fixed';
+    copyInput.style.opacity = '0';
+    copyInput.value = text;
+    host.append(copyInput);
+
+    copyInput.select();
+    copyInput.setSelectionRange(0, 99999);
+    document.execCommand('Copy');
+    copyInput.remove();
+  }
+};
index 7f2fc0f24f5e7f91c3a1d50489fb728eb7225675..91bd80f39063ea4a00600872682249988b0dbcc6 100644 (file)
@@ -1,5 +1,16 @@
-import { EncryptedAttachmentInfo, encryptAttachment } from 'browser-encrypt-attachment';
-import { MatrixClient, MatrixError, UploadProgress, UploadResponse } from 'matrix-js-sdk';
+import {
+  EncryptedAttachmentInfo,
+  decryptAttachment,
+  encryptAttachment,
+} from 'browser-encrypt-attachment';
+import {
+  MatrixClient,
+  MatrixError,
+  MatrixEvent,
+  Room,
+  UploadProgress,
+  UploadResponse,
+} from 'matrix-js-sdk';
 import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
 
 export const matchMxId = (id: string): RegExpMatchArray | null =>
@@ -13,6 +24,13 @@ export const getMxIdLocalPart = (userId: string): string | undefined => matchMxI
 
 export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@');
 
+export const isRoomId = (id: string): boolean => validMxId(id) && id.startsWith('!');
+
+export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#');
+
+export const getRoomWithCanonicalAlias = (mx: MatrixClient, alias: string): Room | undefined =>
+  mx.getRooms()?.find((room) => room.getCanonicalAlias() === alias);
+
 export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => {
   const info: IImageInfo = {};
   info.w = img.width;
@@ -24,7 +42,7 @@ export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): II
 
 export const getVideoInfo = (video: HTMLVideoElement, fileOrBlob: File | Blob): IVideoInfo => {
   const info: IVideoInfo = {};
-  info.duration = Number.isNaN(video.duration) ? undefined : video.duration;
+  info.duration = Number.isNaN(video.duration) ? undefined : Math.floor(video.duration * 1000);
   info.w = video.videoWidth;
   info.h = video.videoHeight;
   info.mimetype = fileOrBlob.type;
@@ -79,6 +97,16 @@ export const encryptFile = async (
   };
 };
 
+export const decryptFile = async (
+  dataBuffer: ArrayBuffer,
+  type: string,
+  encInfo: EncryptedAttachmentInfo
+): Promise<Blob> => {
+  const dataArray = await decryptAttachment(dataBuffer, encInfo);
+  const blob = new Blob([dataArray], { type });
+  return blob;
+};
+
 export type TUploadContent = File | Blob;
 
 export type ContentUploadOptions = {
@@ -116,3 +144,19 @@ export const uploadContent = async (
     onError(new MatrixError({ error, errcode }));
   }
 };
+
+export const matrixEventByRecency = (m1: MatrixEvent, m2: MatrixEvent) => m2.getTs() - m1.getTs();
+
+export const factoryEventSentBy = (senderId: string) => (ev: MatrixEvent) =>
+  ev.getSender() === senderId;
+
+export const eventWithShortcode = (ev: MatrixEvent) =>
+  typeof ev.getContent().shortcode === 'string';
+
+export const trimReplyFromBody = (body: string): string => {
+  if (body.match(/^> <.+>/) === null) return body;
+
+  const trimmedBody = body.slice(body.indexOf('\n\n') + 2);
+
+  return trimmedBody || body;
+};
index c432bdc3cafb1c811509955fa97ba9657cf8c7be..c883ddb9bc8ad605695684058d92d9fc8ee289d9 100644 (file)
@@ -1,17 +1,15 @@
-// https://github.com/matrix-org/matrix-react-sdk/blob/cd15e08fc285da42134817cce50de8011809cd53/src/utils/blobs.ts
-export const ALLOWED_BLOB_MIMETYPES = [
+export const IMAGE_MIME_TYPES = [
   'image/jpeg',
   'image/gif',
   'image/png',
   'image/apng',
   'image/webp',
   'image/avif',
+];
 
-  'video/mp4',
-  'video/webm',
-  'video/ogg',
-  'video/quicktime',
+export const VIDEO_MIME_TYPES = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'];
 
+export const AUDIO_MIME_TYPES = [
   'audio/mp4',
   'audio/webm',
   'audio/aac',
@@ -25,11 +23,55 @@ export const ALLOWED_BLOB_MIMETYPES = [
   'audio/x-flac',
 ];
 
+export const APPLICATION_MIME_TYPES = [
+  'application/pdf',
+  'application/json',
+  'application/x-sh',
+  'application/ecmascript',
+  'application/javascript',
+  'application/xhtml+xml',
+  'application/xml',
+];
+
+export const TEXT_MIME_TYPE = [
+  'text/plain',
+  'text/html',
+  'text/css',
+  'text/javascript',
+  'text/x-c',
+  'text/csv',
+  'text/tab-separated-values',
+  'text/yaml',
+  'text/x-java-source,java',
+  'text/markdown',
+];
+
+export const READABLE_TEXT_MIME_TYPES = [
+  'application/json',
+  'application/x-sh',
+  'application/ecmascript',
+  'application/javascript',
+  'application/xhtml+xml',
+  'application/xml',
+
+  ...TEXT_MIME_TYPE,
+];
+
+export const ALLOWED_BLOB_MIME_TYPES = [
+  ...IMAGE_MIME_TYPES,
+  ...VIDEO_MIME_TYPES,
+  ...AUDIO_MIME_TYPES,
+  ...APPLICATION_MIME_TYPES,
+  ...TEXT_MIME_TYPE,
+];
+
+export const FALLBACK_MIMETYPE = 'application/octet-stream';
+
 export const getBlobSafeMimeType = (mimeType: string) => {
-  if (typeof mimeType !== 'string') return 'application/octet-stream';
+  if (typeof mimeType !== 'string') return FALLBACK_MIMETYPE;
   const [type] = mimeType.split(';');
-  if (!ALLOWED_BLOB_MIMETYPES.includes(type)) {
-    return 'application/octet-stream';
+  if (!ALLOWED_BLOB_MIME_TYPES.includes(type)) {
+    return FALLBACK_MIMETYPE;
   }
   // Required for Chromium browsers
   if (type === 'video/quicktime') {
@@ -45,3 +87,8 @@ export const safeFile = (f: File) => {
   }
   return f;
 };
+
+export const mimeTypeToExt = (mimeType: string): string => {
+  const extStart = mimeType.lastIndexOf('/') + 1;
+  return mimeType.slice(extStart);
+};
index daf956009a2ae9ac8bb673700a095a1ec3f84ae3..f86378337d3874a4b875d1fb3bbd57efb58c1293 100644 (file)
@@ -1,6 +1,7 @@
 import { IconName, IconSrc } from 'folds';
 
 import {
+  EventTimeline,
   IPushRule,
   IPushRules,
   JoinRule,
@@ -9,6 +10,7 @@ import {
   NotificationCountType,
   Room,
 } from 'matrix-js-sdk';
+import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
 import { AccountDataEvent } from '../../types/matrix/accountData';
 import {
   NotificationType,
@@ -263,3 +265,35 @@ export const parseReplyFormattedBody = (
 
   return `<mx-reply><blockquote>${replyToLink}${userLink}<br />${formattedBody}</blockquote></mx-reply>`;
 };
+
+export const getMemberDisplayName = (room: Room, userId: string): string | undefined => {
+  const member = room.getMember(userId);
+  const name = member?.rawDisplayName;
+  if (name === userId) return undefined;
+  return name;
+};
+
+export const getMemberAvatarMxc = (room: Room, userId: string): string | undefined => {
+  const member = room.getMember(userId);
+  return member?.getMxcAvatarUrl();
+};
+
+export const decryptAllTimelineEvent = async (mx: MatrixClient, timeline: EventTimeline) => {
+  const crypto = mx.getCrypto();
+  if (!crypto) return;
+  const decryptionPromises = timeline
+    .getEvents()
+    .filter((event) => event.isEncrypted())
+    .reverse()
+    .map((event) => event.attemptDecryption(crypto as CryptoBackend, { isRetry: true }));
+  await Promise.allSettled(decryptionPromises);
+};
+
+export const getReactionContent = (eventId: string, key: string, shortcode?: string) => ({
+  'm.relates_to': {
+    event_id: eventId,
+    key,
+    rel_type: 'm.annotation',
+  },
+  shortcode,
+});
index 555089de2ab44b6041ab8e96b5728c5e62dc9ef6..6a03ca7d587095750d84d7f22662c69c3b5bd0c7 100644 (file)
@@ -1,3 +1,145 @@
+import sanitizeHtml, { Transformer } from 'sanitize-html';
+
+const MAX_TAG_NESTING = 100;
+
+const permittedHtmlTags = [
+  'font',
+  'del',
+  'h1',
+  'h2',
+  'h3',
+  'h4',
+  'h5',
+  'h6',
+  'blockquote',
+  'p',
+  'a',
+  'ul',
+  'ol',
+  'sup',
+  'sub',
+  'li',
+  'b',
+  'i',
+  'u',
+  'strong',
+  'em',
+  'strike',
+  's',
+  'code',
+  'hr',
+  'br',
+  'div',
+  'table',
+  'thead',
+  'tbody',
+  'tr',
+  'th',
+  'td',
+  'caption',
+  'pre',
+  'span',
+  'img',
+  'details',
+  'summary',
+];
+
+const urlSchemes = ['https', 'http', 'ftp', 'mailto', 'magnet'];
+
+const permittedTagToAttributes = {
+  font: ['style', 'data-mx-bg-color', 'data-mx-color', 'color'],
+  span: [
+    'style',
+    'data-mx-bg-color',
+    'data-mx-color',
+    'data-mx-spoiler',
+    'data-mx-maths',
+    'data-mx-pill',
+    'data-mx-ping',
+  ],
+  div: ['data-mx-maths'],
+  a: ['name', 'target', 'href', 'rel'],
+  img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
+  ol: ['start'],
+  code: ['class'],
+};
+
+const transformFontTag: Transformer = (tagName, attribs) => ({
+  tagName,
+  attribs: {
+    ...attribs,
+    style: `background-color: ${attribs['data-mx-bg-color']}; color: ${attribs['data-mx-color']}`,
+  },
+});
+
+const transformSpanTag: Transformer = (tagName, attribs) => ({
+  tagName,
+  attribs: {
+    ...attribs,
+    style: `background-color: ${attribs['data-mx-bg-color']}; color: ${attribs['data-mx-color']}`,
+  },
+});
+
+const transformATag: Transformer = (tagName, attribs) => ({
+  tagName,
+  attribs: {
+    ...attribs,
+    rel: 'noopener',
+    target: '_blank',
+  },
+});
+
+const transformImgTag: Transformer = (tagName, attribs) => {
+  const { src } = attribs;
+  if (src.startsWith('mxc://') === false) {
+    return {
+      tagName: 'a',
+      attribs: {
+        href: src,
+        rel: 'noopener',
+        target: '_blank',
+      },
+      text: attribs.alt || src,
+    };
+  }
+  return {
+    tagName,
+    attribs: {
+      ...attribs,
+    },
+  };
+};
+
+export const sanitizeCustomHtml = (customHtml: string): string =>
+  sanitizeHtml(customHtml, {
+    allowedTags: permittedHtmlTags,
+    allowedAttributes: permittedTagToAttributes,
+    disallowedTagsMode: 'discard',
+    allowedSchemes: urlSchemes,
+    allowedSchemesByTag: {
+      a: urlSchemes,
+    },
+    allowedSchemesAppliedToAttributes: ['href'],
+    allowProtocolRelative: false,
+    allowedClasses: {
+      code: ['language-*'],
+    },
+    allowedStyles: {
+      '*': {
+        color: [/^#(?:[0-9a-fA-F]{3}){1,2}$/],
+        'background-color': [/^#(?:[0-9a-fA-F]{3}){1,2}$/],
+      },
+    },
+    transformTags: {
+      font: transformFontTag,
+      span: transformSpanTag,
+      a: transformATag,
+      img: transformImgTag,
+    },
+    nonTextTags: ['style', 'script', 'textarea', 'option', 'noscript', 'mx-reply'],
+    nestingLimit: MAX_TAG_NESTING,
+  });
+
 export const sanitizeText = (body: string) => {
   const tagsToReplace: Record<string, string> = {
     '&': '&amp;',
diff --git a/src/app/utils/time.ts b/src/app/utils/time.ts
new file mode 100644 (file)
index 0000000..3ee6720
--- /dev/null
@@ -0,0 +1,35 @@
+import dayjs from 'dayjs';
+import isToday from 'dayjs/plugin/isToday';
+import isYesterday from 'dayjs/plugin/isYesterday';
+
+dayjs.extend(isToday);
+dayjs.extend(isYesterday);
+
+export const today = (ts: number): boolean => dayjs(ts).isToday();
+
+export const yesterday = (ts: number): boolean => dayjs(ts).isYesterday();
+
+export const timeHourMinute = (ts: number): string => dayjs(ts).format('hh:mm A');
+
+export const timeDayMonYear = (ts: number): string => dayjs(ts).format('D MMM YYYY');
+
+export const timeDayMonthYear = (ts: number): string => dayjs(ts).format('D MMMM YYYY');
+
+export const inSameDay = (ts1: number, ts2: number): boolean => {
+  const dt1 = new Date(ts1);
+  const dt2 = new Date(ts2);
+  return (
+    dt2.getFullYear() === dt1.getFullYear() &&
+    dt2.getMonth() === dt1.getMonth() &&
+    dt2.getDate() === dt1.getDate()
+  );
+};
+
+export const minuteDifference = (ts1: number, ts2: number): number => {
+  const dt1 = new Date(ts1);
+  const dt2 = new Date(ts2);
+
+  let diff = (dt2.getTime() - dt1.getTime()) / 1000;
+  diff /= 60;
+  return Math.abs(Math.round(diff));
+};
index af2e279ad32a92151ad6fb3bc1b21c712b573963..cc1193cef2eba827896fc4a62e0f7656f48d49b6 100644 (file)
@@ -59,6 +59,8 @@ class Settings extends EventEmitter {
     this.themes.forEach((themeName, index) => {
       if (themeName !== '') document.body.classList.remove(themeName);
       document.body.classList.remove(this.themeClasses[index]);
+      document.body.classList.remove('prism-light')
+      document.body.classList.remove('prism-dark')
     });
   }
 
@@ -69,6 +71,7 @@ class Settings extends EventEmitter {
     if (this.themes[themeIndex] === undefined) return
     if (this.themes[themeIndex]) document.body.classList.add(this.themes[themeIndex]);
     document.body.classList.add(this.themeClasses[themeIndex]);
+    document.body.classList.add(themeIndex < 2 ? 'prism-light' : 'prism-dark');
   }
 
   setTheme(themeIndex) {
index 55f59327c886a8c62c30a0fc50dacd4169e4d436..5593b6e7be84a7fb8512c28b679255c1c361b070 100644 (file)
@@ -20,4 +20,9 @@ declare module 'browser-encrypt-attachment' {
   }
 
   export function encryptAttachment(dataBuffer: ArrayBuffer): Promise<EncryptedAttachment>;
+
+  export function decryptAttachment(
+    dataBuffer: ArrayBuffer,
+    info: EncryptedAttachmentInfo
+  ): Promise<ArrayBuffer>;
 }
index 93443fe91179e2ffff2b4f3f07f9b46448eab0a7..04125a1ca0fb537aaee465247d2d69b2eea188e6 100644 (file)
 .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-surface-low: hsl(208, 8%, 16%);
-  --bg-surface-low-transparent: hsla(208, 8%, 16%, 0);
-  --bg-surface-extra-low: hsl(208, 8%, 14%);
-  --bg-surface-extra-low-transparent: hsla(208, 8%, 14%, 0);
-  --bg-surface-hover: rgba(255, 255, 255, 3%);
-  --bg-surface-active: rgba(255, 255, 255, 5%);
+  --bg-surface: #1f2326;
+  --bg-surface-transparent: #1f232600;
+  --bg-surface-low: #15171a;
+  --bg-surface-low-transparent: #15171a00;
+  --bg-surface-extra-low: #15171a;
+  --bg-surface-extra-low-transparent: #15171a00;
+  --bg-surface-hover: #1f2326;
+  --bg-surface-active: #2a2e33;
   --bg-surface-border: rgba(0, 0, 0, 20%);
 
   --bg-primary: rgb(42, 98, 166);
index 94a46a90a8c8816a9351bcf4b355e74f2166256c..cc20d453c516f2e62fd2913c87e3ce0e617a96d8 100644 (file)
@@ -1,16 +1,35 @@
 import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { MsgType } from 'matrix-js-sdk';
+
+export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash';
 
 export type IImageInfo = {
   w?: number;
   h?: number;
   mimetype?: string;
   size?: number;
+  [MATRIX_BLUR_HASH_PROPERTY_NAME]?: string;
+};
+
+export type IVideoInfo = {
+  w?: number;
+  h?: number;
+  mimetype?: string;
+  size?: number;
+  duration?: number;
 };
 
-export type IVideoInfo = IImageInfo & {
+export type IAudioInfo = {
+  mimetype?: string;
+  size?: number;
   duration?: number;
 };
 
+export type IFileInfo = {
+  mimetype?: string;
+  size?: number;
+};
+
 export type IEncryptedFile = EncryptedAttachmentInfo & {
   url: string;
 };
@@ -20,3 +39,42 @@ export type IThumbnailContent = {
   thumbnail_file?: IEncryptedFile;
   thumbnail_url?: string;
 };
+
+export type IImageContent = {
+  msgtype: MsgType.Image;
+  body?: string;
+  url?: string;
+  info?: IImageInfo & IThumbnailContent;
+  file?: IEncryptedFile;
+};
+
+export type IVideoContent = {
+  msgtype: MsgType.Video;
+  body?: string;
+  url?: string;
+  info?: IVideoInfo & IThumbnailContent;
+  file?: IEncryptedFile;
+};
+
+export type IAudioContent = {
+  msgtype: MsgType.Audio;
+  body?: string;
+  url?: string;
+  info?: IAudioInfo;
+  file?: IEncryptedFile;
+};
+
+export type IFileContent = {
+  msgtype: MsgType.File;
+  body?: string;
+  url?: string;
+  info?: IFileInfo & IThumbnailContent;
+  file?: IEncryptedFile;
+};
+
+export type ILocationContent = {
+  msgtype: MsgType.Location;
+  body?: string;
+  geo_uri?: string;
+  info?: IThumbnailContent;
+};
index 93e8761522627ac8f07421e4534cfe52c3ba3172..33419ce5df919b035f053f54b13294a41210aa20 100644 (file)
@@ -6,6 +6,14 @@ export enum Membership {
   Ban = 'ban',
 }
 
+export type IMemberContent = {
+  avatar_url?: string;
+  displayname?: string;
+  membership?: Membership;
+  reason?: string;
+  is_direct?: boolean;
+};
+
 export enum StateEvent {
   RoomCanonicalAlias = 'm.room.canonical_alias',
   RoomCreate = 'm.room.create',
@@ -29,6 +37,14 @@ export enum StateEvent {
   PoniesRoomEmotes = 'im.ponies.room_emotes',
 }
 
+export enum MessageEvent {
+  RoomMessage = 'm.room.message',
+  RoomMessageEncrypted = 'm.room.encrypted',
+  Sticker = 'm.sticker',
+  RoomRedaction = 'm.room.redaction',
+  Reaction = 'm.reaction',
+}
+
 export enum RoomType {
   Space = 'm.space',
 }
@@ -40,6 +56,17 @@ export enum NotificationType {
   Mute = 'mute',
 }
 
+export type IRoomCreateContent = {
+  creator?: string;
+  ['m.federate']?: boolean;
+  room_version: string;
+  type?: string;
+  predecessor?: {
+    event_id: string;
+    room_id: string;
+  };
+};
+
 export type RoomToParents = Map<string, Set<string>>;
 export type RoomToUnread = Map<
   string,
index 02eb1843bf67dd80252d19e1056afc7e8e668c71..d2f1e8a1081bae64c5844645b25699e85732e808 100644 (file)
@@ -3,6 +3,7 @@
     "sourceMap": true,
     "jsx": "react",
     "target": "ES2016",
+    "module": "ES2020",
     "allowJs": true,
     "strict": true,
     "esModuleInterop": true,
index f09aa71e876ca7c24614caa3c4e2d019a74ac3ac..8357339842d3b1e0ec74825cad1c767fd26bab41 100644 (file)
@@ -13,6 +13,10 @@ const copyFiles = {
       src: 'node_modules/@matrix-org/olm/olm.wasm',
       dest: '',
     },
+    {
+      src: 'node_modules/pdfjs-dist/build/pdf.worker.min.js',
+      dest: '',
+    },
     {
       src: '_redirects',
       dest: '',