Compare commits

...

76 Commits

Author SHA1 Message Date
Krishan
19cf700d61 Release v4.5.0 (#2247) 2025-03-04 17:47:28 +11:00
Ajay Bura
0c5ff65639 fix backslash inserted in links upon edit (#2246)
* fix backslash appear in url with inline markdown sequences

* fix markdown chars not escaping on edit
2025-03-04 17:32:13 +11:00
Ajay Bura
1206ffced2 Hide deleted events by default (#2237) 2025-03-01 18:48:11 +11:00
Ajay Bura
5fbd0c13db Hide existing messages from ignored users (#2236)
* add ignored users hook

* remove messages from timeline for ignored users
2025-02-28 18:47:23 +11:00
sophie
36a8ce5561 make readme easier to read (#2228) 2025-02-28 18:39:10 +11:00
sophie
dbadbe34b3 add example caddyfile (#2227) 2025-02-28 18:31:54 +11:00
Ajay Bura
2b8b0dcffd open account data in same window instead of popup (#2234)
* refactor TextViewer Content component

* open account data inside setting window

* close account data edit window on cancel when adding new
2025-02-27 19:34:55 +11:00
Ajay Bura
b7e5e0db3e Hidden Typing & Read Receipts (#2230)
* add hide activity toggle

* stop sending/receiving typing status

* send private read receipt when setting toggle is activated

* prevent showing read-receipt when feature toggle in on
2025-02-26 21:44:53 +11:00
Ajay Bura
5c94471956 Show image preview in upload window (#2231)
* memoize metadata callback properly

* add image preview on upload

* show spoiler image button inside image preview
2025-02-26 21:43:43 +11:00
Ajay Bura
ccfe30cd68 Fix editor focus after autocomplete (#2233)
* upgrade slatejs

* collapse autocomplete on escape

* make FN_KEYS_REGEX const in module scope
2025-02-26 21:42:42 +11:00
renovate[bot]
2c7038cd1f Migrate config .github/renovate.json (#2232)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-26 12:40:29 +11:00
Krishan
d70ca86d7c Release v4.4.0 (#2225) 2025-02-23 22:27:50 +11:00
nexy7574
8d95758ed7 Remove fallback replies & implement intentional mentions (#2138)
* Remove reply fallbacks & add m.mentions

(WIP) the typing on line 301 and 303 needs fixing but apart from that this is mint

* Less jank typing

* Mention the reply author in m.mentions

* Improve typing

* Fix typing in m.mentions finder

* Correctly iterate through editor children, properly handle @room, ...

..., don't mention the reply author when the reply author is ourself, don't add own user IDs when mentioning intentionally

* Formatting

* Add intentional mentions to edited messages

* refactor reusable code and fix todo

* parse mentions from all nodes

---------

Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
2025-02-23 22:08:08 +11:00
Ginger
dd4c1a94e6 Add support for spoilers on images (MSC4193) (#2212)
* Add support for MSC4193: Spoilers on Media

* Clarify variable names and wording

* Restore list atom

* Improve spoilered image UX with autoload off

* Use `aria-pressed` to indicate attachment spoiler state

* Improve spoiler button tooltip wording, keep reveal button from conflicting with load errors
2025-02-22 14:25:13 +05:30
Ajay Bura
7c6ab366af Fix unknown rooms in space lobby (#2224)
* add hook to fetch one level of space hierarchy

* add enable param to level hierarchy hook

* improve HierarchyItem types

* fix type errors in lobby

* load space hierarachy per level

* fix menu item visibility

* fix unknown spaces over federation

* show inaccessible rooms only to admins

* fix unknown room renders loading content twice

* fix unknown room visible to normal user if space all room are unknown

* show no rooms card if space does not have any room
2025-02-22 19:24:33 +11:00
Lain Iwakura
f121cc0a24 fix space/tab inconsistency (#2180) 2025-02-21 19:22:48 +11:00
Ajay Bura
7456c152b7 Escape markdown sequences (#2208)
* escape inline markdown character

* fix typo

* improve document around custom markdown plugin and add escape sequence utils

* recover inline escape sequences on edit

* remove escape sequences from plain text body

* use `s` for strike-through instead of del

* escape block markdown sequences

* fix remove escape sequence was not removing all slashes from plain text

* recover block sequences on edit
2025-02-21 19:19:24 +11:00
Ajay Bura
b63868bbb5 scroll to bottom in unfocused window but stop sending read receipt (#2214)
* scroll to bottom in unfocused window but stop sending read receipt

* send read-receipt when new message are in view after regaining focus
2025-02-21 19:18:02 +11:00
Ajay Bura
59e8d66255 Add email notification toggle (#2223)
* refactor system notification to dedicated file

* add hook for email notification status

* add toogle for email notifications in settings
2025-02-21 19:15:47 +11:00
Ajay Bura
1b200eb676 Improve search result counts (#2221)
* remove limit from emoji autocomplete

* remove search limit from user mention

* remove limit from room mention autocomplete

* increase user search limit to 1000

* better search string selection for emoticons
2025-02-21 19:14:38 +11:00
Ajay Bura
3ada21a1df fix autocomplete menu flickering issue (#2220) 2025-02-20 18:32:44 +11:00
Ajay Bura
9fe67da98b sanitize string before used in regex to prevent crash (#2219) 2025-02-20 18:30:54 +11:00
Ajay Bura
d8d4bce287 add button to select all room pack as global pack (#2218) 2025-02-19 22:13:29 +11:00
Ajay Bura
b3979b31c7 fix room activity indicator appearing on self typing (#2217) 2025-02-19 22:08:58 +11:00
Ajay Bura
2e0c7c4406 Fix link visible inside spoiler (#2215)
* hide links in spoiler

* prevent link click inside spoiler
2025-02-19 22:07:33 +11:00
Ajay Bura
f73dc05e25 add order algorithm in search result 2025-02-19 11:23:32 +05:30
Krishan
5844209bee Release v4.3.2 (#2213) 2025-02-17 12:07:07 +11:00
dependabot[bot]
ff334c1c3d Bump dawidd6/action-download-artifact from 7 to 8 (#2184)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 7 to 8.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](80620a5d27...20319c5641)

---
updated-dependencies:
- dependency-name: dawidd6/action-download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 19:16:06 +11:00
dependabot[bot]
4374a89535 Bump docker/setup-buildx-action from 3.6.1 to 3.9.0 (#2196)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.6.1 to 3.9.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.6.1...v3.9.0)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 19:14:08 +11:00
dependabot[bot]
31dcad6220 Bump nginx from 1.27.0-alpine to 1.27.4-alpine (#2198)
Bumps nginx from 1.27.0-alpine to 1.27.4-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 19:13:11 +11:00
dependabot[bot]
a58cffe12b Bump docker/setup-qemu-action from 3.3.0 to 3.4.0 (#2197)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.3.0 to 3.4.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3.3.0...v3.4.0)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 19:10:00 +11:00
dependabot[bot]
de7724dfcf Bump actions/setup-node from 4.0.4 to 4.2.0 (#2185)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.0.4 to 4.2.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4.0.4...v4.2.0)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 19:03:27 +11:00
dependabot[bot]
4edc4bad02 Bump docker/build-push-action from 6.12.0 to 6.13.0 (#2183)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.12.0 to 6.13.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.12.0...v6.13.0)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-15 19:01:01 +11:00
Krishan
2444a60a3e Upgrade twemoji font to support twemoji v15.1.0 (#2202)
* Upgrade twemoji font to support twemoji v15.1.0

* bump emojibase deps to v15
2025-02-15 18:59:40 +11:00
Ajay Bura
ae88480d0a fix message does not appear after decryption complete (#2209)
* fix message does not appear after decryption complete

* update when event get decrypted before subscribing
2025-02-15 18:58:57 +11:00
Ajay Bura
2ed3f877c3 fix editor exit button appears on room switch (#2207) 2025-02-15 18:58:02 +11:00
Krishan
09d85d6c31 Release v4.3.0 (#2199) 2025-02-11 17:02:21 +11:00
Array in a Matrix
999bb7aca1 fix media autoload button function as per it's label (#2195)
* Corrected button title

Media would load automatically if the option is checked not the other way around.

* Update src/app/features/settings/general/General.tsx

* Update General.tsx

* Update General.tsx

---------

Co-authored-by: Krishan <33421343+kfiven@users.noreply.github.com>
2025-02-11 16:28:46 +11:00
Ajay Bura
b84f975f83 fix edits does not reflect in pinned messages (#2107)
* fix pinned message edits does not reflect in pinned messages

* fix all pinned message show edited

* remove console log
2025-02-10 21:16:01 +11:00
Ajay Bura
b12228ee95 fix notification crash on ios (#2192)
* fix notification crash for ios

* access notification from window variable

* fix Notification check

* catch notification variable error

* fix missing check for Notification
2025-02-10 21:02:33 +11:00
Ajay Bura
5460e4922b fix reply placeholder overflow (#2193) 2025-02-10 20:47:21 +11:00
Ajay Bura
7b4f684a25 fix left/banned member showing in autocomplete (#2194) 2025-02-10 20:36:49 +11:00
Ajay Bura
56b754153a redesigned app settings and switch to rust crypto (#1988)
* rework general settings

* account settings - WIP

* add missing key prop

* add object url hook

* extract wide modal styles

* profile settings and image editor - WIP

* add outline style to upload card

* remove file param from bind upload atom hook

* add compact variant to upload card

* add  compact upload card renderer

* add option to update profile avatar

* add option to change profile displayname

* allow displayname change based on capabilities check

* rearrange settings components into folders

* add system notification settings

* add initial page param in settings

* convert account data hook to typescript

* add push rule hook

* add notification mode hook

* add notification mode switcher component

* add all messages notification settings options

* add special messages notification settings

* add keyword notifications

* add ignored users section

* improve ignore user list strings

* add about settings

* add access token option in about settings

* add developer tools settings

* add expand button to account data dev tool option

* update folds

* fix editable active element textarea check

* do not close dialog when editable element in focus

* add text area plugins

* add text area intent handler hook

* add newline intent mod in text area

* add next line hotkey in text area intent hook

* add syntax error position dom utility function

* add account data editor

* add button to send new account data in dev tools

* improve custom emoji plugin

* add more custom emojis hooks

* add text util css

* add word break in setting tile title and description

* emojis and sticker user settings - WIP

* view image packs from settings

* emoji pack editing - WIP

* add option to edit pack meta

* change saved changes message

* add image edit and delete controls

* add option to upload pack images and apply changes

* fix state event type when updating image pack

* lazy load pack image tile img

* hide upload image button when user can not edit pack

* add option to add or remove global image packs

* upgrade to rust crypto (#2168)

* update matrix js sdk

* remove dead code

* use rust crypto

* update setPowerLevel usage

* fix types

* fix deprecated isRoomEncrypted method uses

* fix deprecated room.currentState uses

* fix deprecated import/export room keys func

* fix merge issues in image pack file

* fix remaining issues in image pack file

* start indexedDBStore

* update package lock and vite-plugin-top-level-await

* user session settings - WIP

* add useAsync hook

* add password stage uia

* add uia flow matrix error hook

* add UIA action component

* add options to delete sessions

* add sso uia stage

* fix SSO stage complete error

* encryption - WIP

* update user settings encryption terminology

* add default variant to password input

* use password input in uia password stage

* add options for local backup in user settings

* remove typo in import local backup password input label

* online backup - WIP

* fix uia sso action

* move access token settings from about to developer tools

* merge encryption tab into sessions and rename it to devices

* add device placeholder tile

* add logout dialog

* add logout button for current device

* move other devices in component

* render unverified device verification tile

* add learn more section for current device verification

* add device verification status badge

* add info card component

* add index file for password input component

* add types for secret storage

* add component to access secret storage key

* manual verification - WIP

* update matrix-js-sdk to v35

* add manual verification

* use react query for device list

* show unverified tab on sidebar

* fix device list updates

* add session key details to current device

* render restore encryption backup

* fix loading state of restore backup

* fix unverified tab settings closes after verification

* key backup tile - WIP

* fix unverified tab badge

* rename session key to device key in device tile

* improve backup restore functionality

* fix restore button enabled after layout reload during restoring backup

* update backup info on status change

* add backup disconnection failures

* add device verification using sas

* restore backup after verification

* show option to logout on startup error screen

* fix key backup hook update on decryption key cached

* add option to enable device verification

* add device verification reset dialog

* add logout button in settings drawer

* add encrypted message lost on logout

* fix backup restore never finish with 0 keys

* fix setup dialog hides when enabling device verification

* show backup details in menu

* update setup device verification body copy

* replace deprecated method

* fix displayname appear as mxid in settings

* remove old refactored codes

* fix types
2025-02-10 16:49:47 +11:00
Ajay Bura
f5d68fcc22 fix threaded reply not working in encrypted rooms (#2172) 2025-01-26 22:56:33 +11:00
Ajay Bura
42e6e6355d fix word overflow in text file viewer (#2179) 2025-01-26 22:55:09 +11:00
Ajay Bura
8e4475bb56 fix crash on membership change with invalid data (#2182)
* fix membership change with  invalid data crash

* add more checks around membership change

* fix displayname condition
2025-01-26 22:53:16 +11:00
Ajay Bura
d5766b58fe fix style issue of reply placeholder (#2181) 2025-01-26 22:52:10 +11:00
dependabot[bot]
b524778039 Bump softprops/action-gh-release from 2.0.8 to 2.2.1 (#2164)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.0.8 to 2.2.1.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](c062e08bd5...c95fe14893)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-18 15:52:42 +11:00
dependabot[bot]
45c2a22340 Bump actions/upload-artifact from 4.5.0 to 4.6.0 (#2163)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.5.0 to 4.6.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.5.0...v4.6.0)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-18 15:48:02 +11:00
dependabot[bot]
1b01f3e8ba Bump docker/setup-qemu-action from 3.2.0 to 3.3.0 (#2165)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.2.0 to 3.3.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3.2.0...v3.3.0)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-18 15:47:34 +11:00
dependabot[bot]
2760f4cab9 Bump docker/build-push-action from 6.10.0 to 6.12.0 (#2169)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.10.0 to 6.12.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.10.0...v6.12.0)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-18 15:46:58 +11:00
Krishan
f3e57715a3 Fix typo 2025-01-18 15:35:39 +11:00
Krishan
5cef90fad9 Enable actions and docker dependabot updates (#2167) 2025-01-17 14:23:49 +05:30
dependabot[bot]
0764143d2c Bump dawidd6/action-download-artifact from 6 to 7 (#2114)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 6 to 7.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](bf251b5aa9...80620a5d27)

---
updated-dependencies:
- dependency-name: dawidd6/action-download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-10 20:48:25 +11:00
Jan Jurzitza
e0e84f3756 Center image in URL preview card (#1556)
center is more likely to have relevant content than top left
2025-01-10 15:12:05 +05:30
Ajay Bura
69268187c2 Disable dependabot (#2140) 2025-01-08 22:35:46 +11:00
nexy7574
02437a6a20 Render captions to m.file, m.image, m.video, and m.audio (#2059)
* Add rendering image captions

* Handle sending captions for images

* Fix caption rendering on m.video and m.audio too

* Remove unused renderBody() parameter

* Fix m.file rendering body instead of filename where possible

* Add caption rendering for generic files

+ Fix video and audio not properly sending captions

* Use m.text for captions & render on demand

* Allow custom HTML in sending captions

* Don't *send* captions

* mvoe content const into renderCaption()

---------

Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
2025-01-06 12:44:22 +11:00
dependabot[bot]
3c5afaf33a Bump docker/metadata-action from 5.5.1 to 5.6.1 (#2113)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.5.1 to 5.6.1.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5.5.1...v5.6.1)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-30 20:02:34 +11:00
dependabot[bot]
396bf50239 Bump docker/build-push-action from 6.9.0 to 6.10.0 (#2112)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.9.0 to 6.10.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.9.0...v6.10.0)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-30 19:59:42 +11:00
dependabot[bot]
24d35a5817 Bump actions/upload-artifact from 4.3.6 to 4.5.0 (#2111)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.3.6 to 4.5.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.3.6...v4.5.0)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-30 19:54:19 +11:00
Marek Vospel
b8d9c4bdd6 Emoji ordering in emoji board (#2057)
* feat: sort emojis inside emoji picker

* feat: sort autocomplete emojis

Fixes #1632

* fix: sort function after concat

* fix: sort result instead of searchList
2024-12-22 16:11:02 +05:30
Ajay Bura
35f0e400ad Pinned Messages (#2081)
* add pinned room events hook

* room pinned message - WIP

* add room event hook

* fetch pinned messages before displaying

* use react-query in room event hook

* disable staleTime and gc to 1 hour in room event hook

* use room event hook in reply component

* render pinned messages

* add option to pin/unpin messages

* remove message base from message placeholders and add variant

* display message placeholder while loading pinned messages

* render pinned event error

* show no pinned message placeholder

* fix message placeholder flickering
2024-12-16 16:25:15 +05:30
Rein Fernhout
00d5553bcb add tableflip and unflip commands (#2075) 2024-12-13 10:02:25 +05:30
Krishan
a142630ff9 Release v4.2.3 (#2052) 2024-11-12 20:45:34 +11:00
renovate[bot]
492a149c7f fix(deps): update dependency matrix-js-sdk to v34.11.1 (#2053)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-12 20:43:50 +11:00
Krishan
c110e64341 Release v4.2.2 (#2012) 2024-10-16 21:29:30 +11:00
夜坂雅
0e51e19cab fix: register service worker immediately and cache media requests (#1977)
* Allow service worker to immediately claim pages
* Allow media requests to be cached by browser
2024-10-16 21:26:03 +11:00
renovate[bot]
35b0b1ea42 fix(deps): update dependency matrix-js-sdk to v34.8.0 (#2011)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-16 21:22:06 +11:00
dependabot[bot]
cca8b5f2b2 Bump actions/setup-node from 4.0.3 to 4.0.4 (#1969)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.0.3 to 4.0.4.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4.0.3...v4.0.4)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-03 17:03:33 +10:00
dependabot[bot]
48265c4227 Bump actions/checkout from 4.1.7 to 4.2.0 (#1985)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.1.7 to 4.2.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.1.7...v4.2.0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-03 17:00:32 +10:00
dependabot[bot]
c38efdfbce Bump docker/build-push-action from 6.7.0 to 6.9.0 (#1986)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.7.0 to 6.9.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.7.0...v6.9.0)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-03 16:59:55 +10:00
dependabot[bot]
d8833a310d Bump cla-assistant/github-action from 2.5.1 to 2.6.1 (#1987)
Bumps [cla-assistant/github-action](https://github.com/cla-assistant/github-action) from 2.5.1 to 2.6.1.
- [Release notes](https://github.com/cla-assistant/github-action/releases)
- [Commits](https://github.com/cla-assistant/github-action/compare/v2.5.1...v2.6.1)

---
updated-dependencies:
- dependency-name: cla-assistant/github-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-03 16:59:12 +10:00
Ajay Bura
5824d7c716 fix font-weight in dark theme with unsupported fonts (#1964) 2024-09-22 22:31:32 +10:00
Krishan
6e191d3c79 Fix matrix.to links opening in webview in cinny desktop (#1963) 2024-09-22 10:08:55 +05:30
Krishan
21164a9b61 Release v4.2.1 (#1953) 2024-09-14 23:24:34 +10:00
Krishan
4923b17ad6 Fix auth media check for dendrite (#1952) 2024-09-14 18:54:06 +05:30
297 changed files with 17808 additions and 9877 deletions

View File

@@ -2,14 +2,14 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: npm # - package-ecosystem: npm
directory: / # directory: /
schedule: # schedule:
interval: weekly # interval: weekly
day: "tuesday" # day: "tuesday"
time: "01:00" # time: "01:00"
timezone: "Asia/Kolkata" # timezone: "Asia/Kolkata"
open-pull-requests-limit: 15 # open-pull-requests-limit: 15
- package-ecosystem: github-actions - package-ecosystem: github-actions
directory: / directory: /

15
.github/renovate.json vendored
View File

@@ -1,15 +1,14 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [ "extends": ["config:recommended", ":dependencyDashboardApproval"],
"config:base", "labels": ["Dependencies"],
":dependencyDashboardApproval"
],
"labels": [ "Dependencies" ],
"packageRules": [ "packageRules": [
{ {
"matchUpdateTypes": [ "lockFileMaintenance" ] "matchUpdateTypes": ["lockFileMaintenance"]
} }
], ],
"lockFileMaintenance": { "enabled": true }, "lockFileMaintenance": {
"enabled": true
},
"dependencyDashboard": true "dependencyDashboard": true
} }

View File

@@ -12,9 +12,9 @@ jobs:
PR_NUMBER: ${{github.event.number}} PR_NUMBER: ${{github.event.number}}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.2.0
- name: Setup node - name: Setup node
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.2.0
with: with:
node-version: 20.12.2 node-version: 20.12.2
cache: 'npm' cache: 'npm'
@@ -25,7 +25,7 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=4096' NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build run: npm run build
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4.3.6 uses: actions/upload-artifact@v4.6.0
with: with:
name: preview name: preview
path: dist path: dist
@@ -33,7 +33,7 @@ jobs:
- name: Save pr number - name: Save pr number
run: echo ${PR_NUMBER} > ./pr.txt run: echo ${PR_NUMBER} > ./pr.txt
- name: Upload pr number - name: Upload pr number
uses: actions/upload-artifact@v4.3.6 uses: actions/upload-artifact@v4.6.0
with: with:
name: pr name: pr
path: ./pr.txt path: ./pr.txt

View File

@@ -12,7 +12,7 @@ jobs:
- name: 'CLA Assistant' - name: 'CLA Assistant'
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release # Beta Release
uses: cla-assistant/github-action@v2.5.1 uses: cla-assistant/github-action@v2.6.1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret # the below token should have repo scope and must be manually added by you in the repository's secret

View File

@@ -15,7 +15,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps: steps:
- name: Download pr number - name: Download pr number
uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11 uses: dawidd6/action-download-artifact@20319c5641d495c8a52e688b7dc5fada6c3a9fbc
with: with:
workflow: ${{ github.event.workflow.id }} workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }} run_id: ${{ github.event.workflow_run.id }}
@@ -24,7 +24,7 @@ jobs:
id: pr id: pr
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
- name: Download artifact - name: Download artifact
uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11 uses: dawidd6/action-download-artifact@20319c5641d495c8a52e688b7dc5fada6c3a9fbc
with: with:
workflow: ${{ github.event.workflow.id }} workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }} run_id: ${{ github.event.workflow_run.id }}

View File

@@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.2.0
- name: Build Docker image - name: Build Docker image
uses: docker/build-push-action@v6.7.0 uses: docker/build-push-action@v6.13.0
with: with:
context: . context: .
push: false push: false

View File

@@ -14,7 +14,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.2.0
- name: NPM Lockfile Changes - name: NPM Lockfile Changes
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891 uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891
with: with:

View File

@@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.2.0
- name: Setup node - name: Setup node
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.2.0
with: with:
node-version: 20.12.2 node-version: 20.12.2
cache: 'npm' cache: 'npm'

View File

@@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.2.0
- name: Setup node - name: Setup node
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.2.0
with: with:
node-version: 20.12.2 node-version: 20.12.2
cache: 'npm' cache: 'npm'
@@ -52,7 +52,7 @@ jobs:
gpg --export | xxd -p gpg --export | xxd -p
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
- name: Upload tagged release - name: Upload tagged release
uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191 uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda
with: with:
files: | files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz cinny-${{ steps.vars.outputs.tag }}.tar.gz
@@ -66,11 +66,11 @@ jobs:
packages: write packages: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.2.0
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0 uses: docker/setup-qemu-action@v3.4.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.6.1 uses: docker/setup-buildx-action@v3.9.0
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3.3.0 uses: docker/login-action@v3.3.0
with: with:
@@ -84,13 +84,13 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v5.5.1 uses: docker/metadata-action@v5.6.1
with: with:
images: | images: |
${{ secrets.DOCKER_USERNAME }}/cinny ${{ secrets.DOCKER_USERNAME }}/cinny
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6.7.0 uses: docker/build-push-action@v6.13.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View File

@@ -11,7 +11,7 @@ RUN npm run build
## App ## App
FROM nginx:1.27.0-alpine FROM nginx:1.27.4-alpine
COPY --from=builder /src/dist /app COPY --from=builder /src/dist /app
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -19,27 +19,22 @@ A Matrix client focusing primarily on simple, elegant and secure interface. The
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380"> <img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
## Getting started ## Getting started
* Web app is available at https://app.cinny.in and gets updated on each new release. The `dev` branch is continuously deployed at https://dev.cinny.in but keep in mind that it could have things broken. The web app is available at [app.cinny.in](https://app.cinny.in/) and gets updated on each new release. The `dev` branch is continuously deployed at [dev.cinny.in](https://dev.cinny.in) but keep in mind that it could have things broken.
* You can also download our desktop app from [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop). You can also download our desktop app from the [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop).
* To host Cinny on your own, download tarball of the app from [GitHub release](https://github.com/cinnyapp/cinny/releases/latest). ## Self-hosting
You can serve the application with a webserver of your choice by simply copying `dist/` directory to the webroot. To host Cinny on your own, simply download the tarball from [GitHub releases](https://github.com/cinnyapp/cinny/releases/latest), and serve the files from `dist/` using your preferred webserver. Alternatively, you can just pull the docker image from [DockerHub](https://hub.docker.com/r/ajbura/cinny) or [GitHub Container Registry](https://github.com/cinnyapp/cinny/pkgs/container/cinny).
To set default Homeserver on login, register and Explore Community page, place a customized [`config.json`](config.json) in webroot of your choice.
You will also need to setup redirects to serve the assests. An example setting of redirects for netlify is done in [`netlify.toml`](netlify.toml). You can also set `hashRouter.enabled = true` in [`config.json`](config.json) if you have trouble setting redirects.
To deploy on subdirectory, you need to rebuild the app youself after updating the `base` path in [`build.config.ts`](build.config.ts). For example, if you want to deploy on `https://cinny.in/app`, then change `base: '/app'`.
* Alternatively you can just pull the [DockerHub image](https://hub.docker.com/r/ajbura/cinny) by: * The default homeservers and explore pages are defined in [`config.json`](config.json).
```
docker pull ajbura/cinny
```
or [ghcr image](https://github.com/cinnyapp/cinny/pkgs/container/cinny) by:
```
docker pull ghcr.io/cinnyapp/cinny:latest
```
<details> * You need to set up redirects to serve the assests. Example configurations; [netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), [caddy](contrib/caddy/caddyfile).
<summary>PGP Public Key to verify tarball</summary> * If you have trouble configuring redirects you can [enable hash routing](config.json#L35) — the url in the browser will have a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/` instead of `app.cinny.in/home/`) but you won't have to configure your webserver.
* To deploy on subdirectory, you need to rebuild the app youself after updating the `base` path in [`build.config.ts`](build.config.ts).
* For example, if you want to deploy on `https://cinny.in/app`, then set `base: '/app'`.
<details><summary><b>PGP Public Key to verify tarball</b></summary>
``` ```
-----BEGIN PGP PUBLIC KEY BLOCK----- -----BEGIN PGP PUBLIC KEY BLOCK-----
@@ -87,8 +82,8 @@ mxFo+ioe/ABCufSmyqFye0psX3Sp
</details> </details>
## Local development ## Local development
> We recommend using a version manager as versions change very quickly. You will likely need to switch > [!TIP]
between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Iron LTS (v20). > We recommend using a version manager as versions change very quickly. You will likely need to switch between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Iron LTS (v20).
Execute the following commands to start a development server: Execute the following commands to start a development server:
```sh ```sh

6
contrib/caddy/caddyfile Normal file
View File

@@ -0,0 +1,6 @@
cinny.domain.tld {
@nativeRouter not file {path} /
rewrite @nativeRouter {http.matchers.file.relative}
root * /path/to/caddy/dist
file_server
}

View File

@@ -1,35 +1,35 @@
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name cinny.domain.tld; server_name cinny.domain.tld;
location / { location / {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {
alias /var/lib/letsencrypt/.well-known/acme-challenge/; alias /var/lib/letsencrypt/.well-known/acme-challenge/;
} }
} }
server { server {
listen 443 ssl http2; listen 443 ssl http2;
listen [::]:443 ssl; listen [::]:443 ssl;
server_name cinny.domain.tld; server_name cinny.domain.tld;
location / { location / {
root /opt/cinny/dist/; root /opt/cinny/dist/;
rewrite ^/config.json$ /config.json break; rewrite ^/config.json$ /config.json break;
rewrite ^/manifest.json$ /manifest.json break; rewrite ^/manifest.json$ /manifest.json break;
rewrite ^.*/olm.wasm$ /olm.wasm break; rewrite ^.*/olm.wasm$ /olm.wasm break;
rewrite ^/sw.js$ /sw.js break; rewrite ^/sw.js$ /sw.js break;
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break; rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
rewrite ^/public/(.*)$ /public/$1 break; rewrite ^/public/(.*)$ /public/$1 break;
rewrite ^/assets/(.*)$ /assets/$1 break; rewrite ^/assets/(.*)$ /assets/$1 break;
rewrite ^(.+)$ /index.html break; rewrite ^(.+)$ /index.html break;
} }
} }

7513
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "cinny", "name": "cinny",
"version": "4.2.0", "version": "4.5.0",
"description": "Yet another matrix client", "description": "Yet another matrix client",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -39,12 +39,12 @@
"dateformat": "5.0.3", "dateformat": "5.0.3",
"dayjs": "1.11.10", "dayjs": "1.11.10",
"domhandler": "5.0.3", "domhandler": "5.0.3",
"emojibase": "6.1.0", "emojibase": "15.3.1",
"emojibase-data": "7.0.1", "emojibase-data": "15.3.2",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"flux": "4.0.3", "flux": "4.0.3",
"focus-trap-react": "10.0.2", "focus-trap-react": "10.0.2",
"folds": "2.0.0", "folds": "2.1.0",
"formik": "2.4.6", "formik": "2.4.6",
"html-dom-parser": "4.0.0", "html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0", "html-react-parser": "4.2.0",
@@ -56,7 +56,7 @@
"jotai": "2.6.0", "jotai": "2.6.0",
"linkify-react": "4.1.3", "linkify-react": "4.1.3",
"linkifyjs": "4.1.3", "linkifyjs": "4.1.3",
"matrix-js-sdk": "34.5.0", "matrix-js-sdk": "35.0.0",
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "4.2.67", "pdfjs-dist": "4.2.67",
"prismjs": "1.29.0", "prismjs": "1.29.0",
@@ -73,9 +73,10 @@
"react-range": "1.8.14", "react-range": "1.8.14",
"react-router-dom": "6.20.0", "react-router-dom": "6.20.0",
"sanitize-html": "2.12.1", "sanitize-html": "2.12.1",
"slate": "0.94.1", "slate": "0.112.0",
"slate-history": "0.93.0", "slate-dom": "0.112.2",
"slate-react": "0.98.4", "slate-history": "0.110.3",
"slate-react": "0.112.1",
"tippy.js": "6.3.7", "tippy.js": "6.3.7",
"ua-parser-js": "1.0.35" "ua-parser-js": "1.0.35"
}, },
@@ -84,6 +85,7 @@
"@rollup/plugin-inject": "5.0.3", "@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1", "@rollup/plugin-wasm": "6.1.1",
"@types/file-saver": "2.0.5", "@types/file-saver": "2.0.5",
"@types/is-hotkey": "0.1.10",
"@types/node": "18.11.18", "@types/node": "18.11.18",
"@types/prismjs": "1.26.0", "@types/prismjs": "1.26.0",
"@types/react": "18.2.39", "@types/react": "18.2.39",
@@ -108,6 +110,6 @@
"vite": "5.0.13", "vite": "5.0.13",
"vite-plugin-pwa": "0.20.5", "vite-plugin-pwa": "0.20.5",
"vite-plugin-static-copy": "1.0.4", "vite-plugin-static-copy": "1.0.4",
"vite-plugin-top-level-await": "1.4.1" "vite-plugin-top-level-await": "1.4.4"
} }
} }

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,73 @@
import React, { ReactNode } from 'react';
import { AuthDict, AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
import { getUIAFlowForStages } from '../utils/matrix-uia';
import { useSupportedUIAFlows, useUIACompleted, useUIAFlow } from '../hooks/useUIAFlows';
import { UIAFlowOverlay } from './UIAFlowOverlay';
import { PasswordStage, SSOStage } from './uia-stages';
import { useMatrixClient } from '../hooks/useMatrixClient';
export const SUPPORTED_IN_APP_UIA_STAGES = [AuthType.Password, AuthType.Sso];
export const pickUIAFlow = (uiaFlows: UIAFlow[]): UIAFlow | undefined => {
const passwordFlow = getUIAFlowForStages(uiaFlows, [AuthType.Password]);
if (passwordFlow) return passwordFlow;
return getUIAFlowForStages(uiaFlows, [AuthType.Sso]);
};
type ActionUIAProps = {
authData: IAuthData;
ongoingFlow: UIAFlow;
action: (authDict: AuthDict) => void;
onCancel: () => void;
};
export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) {
const mx = useMatrixClient();
const completed = useUIACompleted(authData);
const { getStageToComplete } = useUIAFlow(authData, ongoingFlow);
const stageToComplete = getStageToComplete();
if (!stageToComplete) return null;
return (
<UIAFlowOverlay
currentStep={completed.length + 1}
stepCount={ongoingFlow.stages.length}
onCancel={onCancel}
>
{stageToComplete.type === AuthType.Password && (
<PasswordStage
userId={mx.getUserId()!}
stageData={stageToComplete}
onCancel={onCancel}
submitAuthDict={action}
/>
)}
{stageToComplete.type === AuthType.Sso && stageToComplete.session && (
<SSOStage
ssoRedirectURL={mx.getFallbackAuthUrl(AuthType.Sso, stageToComplete.session)}
stageData={stageToComplete}
onCancel={onCancel}
submitAuthDict={action}
/>
)}
</UIAFlowOverlay>
);
}
type ActionUIAFlowsLoaderProps = {
authData: IAuthData;
unsupported: () => ReactNode;
children: (ongoingFlow: UIAFlow) => ReactNode;
};
export function ActionUIAFlowsLoader({
authData,
unsupported,
children,
}: ActionUIAFlowsLoaderProps) {
const supportedFlows = useSupportedUIAFlows(authData.flows ?? [], SUPPORTED_IN_APP_UIA_STAGES);
const ongoingFlow = supportedFlows.length > 0 ? supportedFlows[0] : undefined;
if (!ongoingFlow) return unsupported();
return children(ongoingFlow);
}

View File

@@ -0,0 +1,281 @@
import React, { MouseEventHandler, useCallback, useState } from 'react';
import { useAtom } from 'jotai';
import { CryptoApi, KeyBackupInfo } from 'matrix-js-sdk/lib/crypto-api';
import {
Badge,
Box,
Button,
color,
config,
Icon,
IconButton,
Icons,
Menu,
percent,
PopOut,
ProgressBar,
RectCords,
Spinner,
Text,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { BackupProgressStatus, backupRestoreProgressAtom } from '../state/backupRestore';
import { InfoCard } from './info-card';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import {
useKeyBackupInfo,
useKeyBackupStatus,
useKeyBackupSync,
useKeyBackupTrust,
} from '../hooks/useKeyBackup';
import { stopPropagation } from '../utils/keyboard';
import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification';
type BackupStatusProps = {
enabled: boolean;
};
function BackupStatus({ enabled }: BackupStatusProps) {
return (
<Box as="span" gap="100" alignItems="Center">
<Badge variant={enabled ? 'Success' : 'Critical'} fill="Solid" size="200" radii="Pill" />
<Text
as="span"
size="L400"
style={{ color: enabled ? color.Success.Main : color.Critical.Main }}
>
{enabled ? 'Connected' : 'Disconnected'}
</Text>
</Box>
);
}
type BackupSyncingProps = {
count: number;
};
function BackupSyncing({ count }: BackupSyncingProps) {
return (
<Box as="span" gap="100" alignItems="Center">
<Spinner size="50" variant="Primary" fill="Soft" />
<Text as="span" size="L400" style={{ color: color.Primary.Main }}>
Syncing ({count})
</Text>
</Box>
);
}
function BackupProgressFetching() {
return (
<Box grow="Yes" gap="200" alignItems="Center">
<Badge variant="Secondary" fill="Solid" radii="300">
<Text size="L400">Restoring: 0%</Text>
</Badge>
<Box grow="Yes" direction="Column">
<ProgressBar variant="Secondary" size="300" min={0} max={1} value={0} />
</Box>
<Spinner size="50" variant="Secondary" fill="Soft" />
</Box>
);
}
type BackupProgressProps = {
total: number;
downloaded: number;
};
function BackupProgress({ total, downloaded }: BackupProgressProps) {
return (
<Box grow="Yes" gap="200" alignItems="Center">
<Badge variant="Secondary" fill="Solid" radii="300">
<Text size="L400">Restoring: {`${Math.round(percent(0, total, downloaded))}%`}</Text>
</Badge>
<Box grow="Yes" direction="Column">
<ProgressBar variant="Secondary" size="300" min={0} max={total} value={downloaded} />
</Box>
<Badge variant="Secondary" fill="Soft" radii="Pill">
<Text size="L400">
{downloaded} / {total}
</Text>
</Badge>
</Box>
);
}
type BackupTrustInfoProps = {
crypto: CryptoApi;
backupInfo: KeyBackupInfo;
};
function BackupTrustInfo({ crypto, backupInfo }: BackupTrustInfoProps) {
const trust = useKeyBackupTrust(crypto, backupInfo);
if (!trust) return null;
return (
<Box direction="Column">
{trust.matchesDecryptionKey ? (
<Text size="T200" style={{ color: color.Success.Main }}>
<b>Backup has trusted decryption key.</b>
</Text>
) : (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>Backup does not have trusted decryption key!</b>
</Text>
)}
{trust.trusted ? (
<Text size="T200" style={{ color: color.Success.Main }}>
<b>Backup has trusted by signature.</b>
</Text>
) : (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>Backup does not have trusted signature!</b>
</Text>
)}
</Box>
);
}
type BackupRestoreTileProps = {
crypto: CryptoApi;
};
export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
const [restoreProgress, setRestoreProgress] = useAtom(backupRestoreProgressAtom);
const restoring =
restoreProgress.status === BackupProgressStatus.Fetching ||
restoreProgress.status === BackupProgressStatus.Loading;
const backupEnabled = useKeyBackupStatus(crypto);
const backupInfo = useKeyBackupInfo(crypto);
const [remainingSession, syncFailure] = useKeyBackupSync();
const [menuCords, setMenuCords] = useState<RectCords>();
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const [restoreState, restoreBackup] = useAsyncCallback<void, Error, []>(
useCallback(async () => {
await crypto.restoreKeyBackup({
progressCallback(progress) {
setRestoreProgress(progress);
},
});
}, [crypto, setRestoreProgress])
);
const handleRestore = () => {
setMenuCords(undefined);
restoreBackup();
};
return (
<InfoCard
variant="Surface"
title="Encryption Backup"
after={
<Box alignItems="Center" gap="200">
{remainingSession === 0 ? (
<BackupStatus enabled={backupEnabled} />
) : (
<BackupSyncing count={remainingSession} />
)}
<IconButton
aria-pressed={!!menuCords}
size="300"
variant="Surface"
radii="300"
onClick={handleMenu}
>
<Icon size="100" src={Icons.VerticalDots} />
</IconButton>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu
style={{
padding: config.space.S100,
}}
>
<Box direction="Column" gap="100">
<Box direction="Column" gap="200">
<InfoCard
variant="SurfaceVariant"
title="Backup Details"
description={
<>
<span>Version: {backupInfo?.version ?? 'NIL'}</span>
<br />
<span>Keys: {backupInfo?.count ?? 'NIL'}</span>
</>
}
/>
</Box>
<Button
size="300"
variant="Success"
radii="300"
aria-disabled={restoreState.status === AsyncStatus.Loading || restoring}
onClick={
restoreState.status === AsyncStatus.Loading || restoring
? undefined
: handleRestore
}
before={<Icon size="100" src={Icons.Download} />}
>
<Text size="B300">Restore Backup</Text>
</Button>
</Box>
</Menu>
</FocusTrap>
}
/>
</Box>
}
>
{syncFailure && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{syncFailure}</b>
</Text>
)}
{!backupEnabled && backupInfo === null && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>No backup present on server!</b>
</Text>
)}
{!syncFailure && !backupEnabled && backupInfo && (
<BackupTrustInfo crypto={crypto} backupInfo={backupInfo} />
)}
{restoreState.status === AsyncStatus.Loading && !restoring && <BackupProgressFetching />}
{restoreProgress.status === BackupProgressStatus.Fetching && <BackupProgressFetching />}
{restoreProgress.status === BackupProgressStatus.Loading && (
<BackupProgress
total={restoreProgress.data.total}
downloaded={restoreProgress.data.downloaded}
/>
)}
{restoreState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{restoreState.error.message}</b>
</Text>
)}
</InfoCard>
);
}
export function AutoRestoreBackupOnVerification() {
useRestoreBackupOnVerification();
return null;
}

View File

@@ -19,7 +19,7 @@ export function CapabilitiesAndMediaConfigLoader({
[] []
>( >(
useCallback(async () => { useCallback(async () => {
const result = await Promise.allSettled([mx.getCapabilities(true), mx.getMediaConfig()]); const result = await Promise.allSettled([mx.getCapabilities(), mx.getMediaConfig()]);
const capabilities = promiseFulfilledResult(result[0]); const capabilities = promiseFulfilledResult(result[0]);
const mediaConfig = promiseFulfilledResult(result[1]); const mediaConfig = promiseFulfilledResult(result[1]);
return [capabilities, mediaConfig]; return [capabilities, mediaConfig];

View File

@@ -9,7 +9,7 @@ type CapabilitiesLoaderProps = {
export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) { export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(true), [mx])); const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(), [mx]));
useEffect(() => { useEffect(() => {
load(); load();

View File

@@ -0,0 +1,318 @@
import {
ShowSasCallbacks,
VerificationPhase,
VerificationRequest,
Verifier,
} from 'matrix-js-sdk/lib/crypto-api';
import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
import {
Box,
Button,
config,
Dialog,
Header,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import {
useVerificationRequestPhase,
useVerificationRequestReceived,
useVerifierCancel,
useVerifierShowSas,
} from '../hooks/useVerificationRequest';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ContainerColor } from '../styles/ContainerColor.css';
const DialogHeaderStyles: CSSProperties = {
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
};
type WaitingMessageProps = {
message: string;
};
function WaitingMessage({ message }: WaitingMessageProps) {
return (
<Box alignItems="Center" gap="200">
<Spinner variant="Secondary" size="200" />
<Text size="T300">{message}</Text>
</Box>
);
}
type VerificationUnexpectedProps = { message: string; onClose: () => void };
function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProps) {
return (
<Box direction="Column" gap="400">
<Text>{message}</Text>
<Button variant="Secondary" fill="Soft" onClick={onClose}>
<Text size="B400">Close</Text>
</Button>
</Box>
);
}
function VerificationWaitAccept() {
return (
<Box direction="Column" gap="400">
<Text>Please accept the request from other device.</Text>
<WaitingMessage message="Waiting for request to be accepted..." />
</Box>
);
}
type VerificationAcceptProps = {
onAccept: () => Promise<void>;
};
function VerificationAccept({ onAccept }: VerificationAcceptProps) {
const [acceptState, accept] = useAsyncCallback(onAccept);
const accepting = acceptState.status === AsyncStatus.Loading;
return (
<Box direction="Column" gap="400">
<Text>Click accept to start the verification process.</Text>
<Button
variant="Primary"
fill="Solid"
onClick={accept}
before={accepting && <Spinner size="100" variant="Primary" fill="Solid" />}
disabled={accepting}
>
<Text size="B400">Accept</Text>
</Button>
</Box>
);
}
function VerificationWaitStart() {
return (
<Box direction="Column" gap="400">
<Text>Verification request has been accepted.</Text>
<WaitingMessage message="Waiting for the response from other device..." />
</Box>
);
}
type VerificationStartProps = {
onStart: () => Promise<void>;
};
function AutoVerificationStart({ onStart }: VerificationStartProps) {
useEffect(() => {
onStart();
}, [onStart]);
return (
<Box direction="Column" gap="400">
<WaitingMessage message="Starting verification using emoji comparison..." />
</Box>
);
}
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
const confirming =
confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success;
return (
<Box direction="Column" gap="400">
<Text>Confirm the emoji below are displayed on both devices, in the same order:</Text>
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
borderRadius: config.radii.R400,
padding: config.space.S500,
}}
gap="700"
wrap="Wrap"
justifyContent="Center"
>
{sasData.sas.emoji?.map(([emoji, name], index) => (
<Box
// eslint-disable-next-line react/no-array-index-key
key={`${emoji}${name}${index}`}
direction="Column"
gap="100"
justifyContent="Center"
alignItems="Center"
>
<Text size="H1">{emoji}</Text>
<Text size="T200">{name}</Text>
</Box>
))}
</Box>
<Box direction="Column" gap="200">
<Button
variant="Primary"
fill="Soft"
onClick={confirm}
disabled={confirming}
before={confirming && <Spinner size="100" variant="Primary" />}
>
<Text size="B400">They Match</Text>
</Button>
<Button
variant="Primary"
fill="Soft"
onClick={() => sasData.mismatch()}
disabled={confirming}
>
<Text size="B400">Do not Match</Text>
</Button>
</Box>
</Box>
);
}
type SasVerificationProps = {
verifier: Verifier;
onCancel: () => void;
};
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
const [sasData, setSasData] = useState<ShowSasCallbacks>();
useVerifierShowSas(verifier, setSasData);
useVerifierCancel(verifier, onCancel);
useEffect(() => {
verifier.verify();
}, [verifier]);
if (sasData) {
return <CompareEmoji sasData={sasData} />;
}
return (
<Box direction="Column" gap="400">
<WaitingMessage message="Starting verification using emoji comparison..." />
</Box>
);
}
type VerificationDoneProps = {
onExit: () => void;
};
function VerificationDone({ onExit }: VerificationDoneProps) {
return (
<Box direction="Column" gap="400">
<div>
<Text>Your device is verified.</Text>
</div>
<Button variant="Primary" fill="Solid" onClick={onExit}>
<Text size="B400">Okay</Text>
</Button>
</Box>
);
}
type VerificationCanceledProps = {
onClose: () => void;
};
function VerificationCanceled({ onClose }: VerificationCanceledProps) {
return (
<Box direction="Column" gap="400">
<Text>Verification has been canceled.</Text>
<Button variant="Secondary" fill="Soft" onClick={onClose}>
<Text size="B400">Close</Text>
</Button>
</Box>
);
}
type DeviceVerificationProps = {
request: VerificationRequest;
onExit: () => void;
};
export function DeviceVerification({ request, onExit }: DeviceVerificationProps) {
const phase = useVerificationRequestPhase(request);
const handleCancel = useCallback(() => {
if (request.phase !== VerificationPhase.Done && request.phase !== VerificationPhase.Cancelled) {
request.cancel();
}
onExit();
}, [request, onExit]);
const handleAccept = useCallback(() => request.accept(), [request]);
const handleStart = useCallback(async () => {
await request.startVerification(VerificationMethod.Sas);
}, [request]);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: false,
escapeDeactivates: false,
}}
>
<Dialog variant="Surface">
<Header style={DialogHeaderStyles} variant="Surface" size="500">
<Box grow="Yes">
<Text size="H4">Device Verification</Text>
</Box>
<IconButton size="300" radii="300" onClick={handleCancel}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{phase === VerificationPhase.Requested &&
(request.initiatedByMe ? (
<VerificationWaitAccept />
) : (
<VerificationAccept onAccept={handleAccept} />
))}
{phase === VerificationPhase.Ready &&
(request.initiatedByMe ? (
<AutoVerificationStart onStart={handleStart} />
) : (
<VerificationWaitStart />
))}
{phase === VerificationPhase.Started &&
(request.verifier ? (
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
) : (
<VerificationUnexpected
message="Unexpected Error! Verification is started but verifier is missing."
onClose={handleCancel}
/>
))}
{phase === VerificationPhase.Done && <VerificationDone onExit={onExit} />}
{phase === VerificationPhase.Cancelled && (
<VerificationCanceled onClose={handleCancel} />
)}
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
export function ReceiveSelfDeviceVerification() {
const [request, setRequest] = useState<VerificationRequest>();
useVerificationRequestReceived(setRequest);
const handleExit = useCallback(() => {
setRequest(undefined);
}, []);
if (!request) return null;
if (!request.isSelfVerification) {
return null;
}
return <DeviceVerification request={request} onExit={handleExit} />;
}

View File

@@ -0,0 +1,375 @@
import React, { FormEventHandler, forwardRef, useCallback, useState } from 'react';
import {
Dialog,
Header,
Box,
Text,
IconButton,
Icon,
Icons,
config,
Button,
Chip,
color,
Spinner,
} from 'folds';
import FileSaver from 'file-saver';
import to from 'await-to-js';
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
import { PasswordInput } from './password-input';
import { ContainerColor } from '../styles/ContainerColor.css';
import { copyToClipboard } from '../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { clearSecretStorageKeys } from '../../client/state/secretStorageKeys';
import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAlive } from '../hooks/useAlive';
import { UseStateProvider } from './UseStateProvider';
type UIACallback<T> = (
authDict: AuthDict | null
) => Promise<[IAuthData, undefined] | [undefined, T]>;
type PerformAction<T> = (authDict: AuthDict | null) => Promise<T>;
type UIAAction<T> = {
authData: IAuthData;
callback: UIACallback<T>;
cancelCallback: () => void;
};
function makeUIAAction<T>(
authData: IAuthData,
performAction: PerformAction<T>,
resolve: (data: T) => void,
reject: (error?: any) => void
): UIAAction<T> {
const action: UIAAction<T> = {
authData,
callback: async (authDict) => {
const [error, data] = await to<T, MatrixError | Error>(performAction(authDict));
if (error instanceof MatrixError && error.httpStatus === 401) {
return [error.data as IAuthData, undefined];
}
if (error) {
reject(error);
throw error;
}
resolve(data);
return [undefined, data];
},
cancelCallback: reject,
};
return action;
}
type SetupVerificationProps = {
onComplete: (recoveryKey: string) => void;
};
function SetupVerification({ onComplete }: SetupVerificationProps) {
const mx = useMatrixClient();
const alive = useAlive();
const [uiaAction, setUIAAction] = useState<UIAAction<void>>();
const [nextAuthData, setNextAuthData] = useState<IAuthData | null>(); // null means no next action.
const handleAction = useCallback(
async (authDict: AuthDict) => {
if (!uiaAction) {
throw new Error('Unexpected Error! UIA action is perform without data.');
}
if (alive()) {
setNextAuthData(null);
}
const [authData] = await uiaAction.callback(authDict);
if (alive() && authData) {
setNextAuthData(authData);
}
},
[uiaAction, alive]
);
const resetUIA = useCallback(() => {
if (!alive()) return;
setUIAAction(undefined);
setNextAuthData(undefined);
}, [alive]);
const authUploadDeviceSigningKeys: UIAuthCallback<void> = useCallback(
(makeRequest) =>
new Promise<void>((resolve, reject) => {
makeRequest(null)
.then(() => {
resolve();
resetUIA();
})
.catch((error) => {
if (error instanceof MatrixError && error.httpStatus === 401) {
const authData = error.data as IAuthData;
const action = makeUIAAction(
authData,
makeRequest as PerformAction<void>,
resolve,
(err) => {
resetUIA();
reject(err);
}
);
if (alive()) {
setUIAAction(action);
} else {
reject(new Error('Authentication failed! Failed to setup device verification.'));
}
return;
}
reject(error);
});
}),
[alive, resetUIA]
);
const [setupState, setup] = useAsyncCallback<void, Error, [string | undefined]>(
useCallback(
async (passphrase) => {
const crypto = mx.getCrypto();
if (!crypto) throw new Error('Unexpected Error! Crypto module not found!');
const recoveryKeyData = await crypto.createRecoveryKeyFromPassphrase(passphrase);
if (!recoveryKeyData.encodedPrivateKey) {
throw new Error('Unexpected Error! Failed to create recovery key.');
}
clearSecretStorageKeys();
await crypto.bootstrapSecretStorage({
createSecretStorageKey: async () => recoveryKeyData,
setupNewSecretStorage: true,
});
await crypto.bootstrapCrossSigning({
authUploadDeviceSigningKeys,
setupNewCrossSigning: true,
});
await crypto.resetKeyBackup();
onComplete(recoveryKeyData.encodedPrivateKey);
},
[mx, onComplete, authUploadDeviceSigningKeys]
)
);
const loading = setupState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (loading) return;
const target = evt.target as HTMLFormElement | undefined;
const passphraseInput = target?.passphraseInput as HTMLInputElement | undefined;
let passphrase: string | undefined;
if (passphraseInput && passphraseInput.value.length > 0) {
passphrase = passphraseInput.value;
}
setup(passphrase);
};
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
<Text size="T300">
Generate a <b>Recovery Key</b> for verifying identity if you do not have access to other
devices. Additionally, setup a passphrase as a memorable alternative.
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Passphrase (Optional)</Text>
<PasswordInput name="passphraseInput" size="400" readOnly={loading} />
</Box>
<Button
type="submit"
disabled={loading}
before={loading && <Spinner size="200" variant="Primary" fill="Solid" />}
>
<Text size="B400">Continue</Text>
</Button>
{setupState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{setupState.error ? setupState.error.message : 'Unexpected Error!'}</b>
</Text>
)}
{nextAuthData !== null && uiaAction && (
<ActionUIAFlowsLoader
authData={nextAuthData ?? uiaAction.authData}
unsupported={() => (
<Text size="T200">
Authentication steps to perform this action are not supported by client.
</Text>
)}
>
{(ongoingFlow) => (
<ActionUIA
authData={nextAuthData ?? uiaAction.authData}
ongoingFlow={ongoingFlow}
action={handleAction}
onCancel={uiaAction.cancelCallback}
/>
)}
</ActionUIAFlowsLoader>
)}
</Box>
);
}
type RecoveryKeyDisplayProps = {
recoveryKey: string;
};
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const [show, setShow] = useState(false);
const handleCopy = () => {
copyToClipboard(recoveryKey);
};
const handleDownload = () => {
const blob = new Blob([recoveryKey], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'recovery-key.txt');
};
const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
return (
<Box direction="Column" gap="400">
<Text size="T300">
Store the Recovery Key in a safe place for future use, as you will need it to verify your
identity if you do not have access to other devices.
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Recovery Key</Text>
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
padding: config.space.S300,
borderRadius: config.radii.R400,
}}
alignItems="Center"
justifyContent="Center"
gap="400"
>
<Text style={{ fontFamily: 'monospace' }} size="T200" priority="300">
{safeToDisplayKey}
</Text>
<Chip onClick={() => setShow(!show)} variant="Secondary" radii="Pill">
<Text size="B300">{show ? 'Hide' : 'Show'}</Text>
</Chip>
</Box>
</Box>
<Box direction="Column" gap="200">
<Button onClick={handleCopy}>
<Text size="B400">Copy</Text>
</Button>
<Button onClick={handleDownload} fill="Soft">
<Text size="B400">Download</Text>
</Button>
</Box>
</Box>
);
}
type DeviceVerificationSetupProps = {
onCancel: () => void;
};
export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerificationSetupProps>(
({ onCancel }, ref) => {
const [recoveryKey, setRecoveryKey] = useState<string>();
return (
<Dialog ref={ref}>
<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">Setup Device Verification</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{recoveryKey ? (
<RecoveryKeyDisplay recoveryKey={recoveryKey} />
) : (
<SetupVerification onComplete={setRecoveryKey} />
)}
</Box>
</Dialog>
);
}
);
type DeviceVerificationResetProps = {
onCancel: () => void;
};
export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerificationResetProps>(
({ onCancel }, ref) => {
const [reset, setReset] = useState(false);
return (
<Dialog ref={ref}>
<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">Reset Device Verification</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
{reset ? (
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<UseStateProvider initial={undefined}>
{(recoveryKey: string | undefined, setRecoveryKey) =>
recoveryKey ? (
<RecoveryKeyDisplay recoveryKey={recoveryKey} />
) : (
<SetupVerification onComplete={setRecoveryKey} />
)
}
</UseStateProvider>
</Box>
) : (
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text size="H1">🧑🚒🤚</Text>
<Text size="T300">Resetting device verification is permanent.</Text>
<Text size="T300">
Anyone you have verified with will see security alerts and your encryption backup
will be lost. You almost certainly do not want to do this, unless you have lost{' '}
<b>Recovery Key</b> or <b>Recovery Passphrase</b> and every device you can verify
from.
</Text>
</Box>
<Button variant="Critical" onClick={() => setReset(true)}>
<Text size="B400">Reset</Text>
</Button>
</Box>
)}
</Dialog>
);
}
);

View File

@@ -0,0 +1,24 @@
import { ReactNode } from 'react';
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
import {
useDeviceVerificationStatus,
VerificationStatus,
} from '../hooks/useDeviceVerificationStatus';
type DeviceVerificationStatusProps = {
crypto?: CryptoApi;
userId: string;
deviceId: string;
children: (verificationStatus: VerificationStatus) => ReactNode;
};
export function DeviceVerificationStatus({
crypto,
userId,
deviceId,
children,
}: DeviceVerificationStatusProps) {
const status = useDeviceVerificationStatus(crypto, userId, deviceId);
return children(status);
}

View File

@@ -0,0 +1,89 @@
import React, { forwardRef, useCallback } from 'react';
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { logoutClient } from '../../client/initMatrix';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useCrossSigningActive } from '../hooks/useCrossSigning';
import { InfoCard } from './info-card';
import {
useDeviceVerificationStatus,
VerificationStatus,
} from '../hooks/useDeviceVerificationStatus';
type LogoutDialogProps = {
handleClose: () => void;
};
export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
({ handleClose }, ref) => {
const mx = useMatrixClient();
const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent());
const crossSigningActive = useCrossSigningActive();
const verificationStatus = useDeviceVerificationStatus(
mx.getCrypto(),
mx.getSafeUserId(),
mx.getDeviceId() ?? undefined
);
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
useCallback(async () => {
await logoutClient(mx);
}, [mx])
);
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
return (
<Dialog variant="Surface" ref={ref}>
<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">Logout</Text>
</Box>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{hasEncryptedRoom &&
(crossSigningActive ? (
verificationStatus === VerificationStatus.Unverified && (
<InfoCard
variant="Critical"
title="Unverified Device"
description="Verify your device before logging out to save your encrypted messages."
/>
)
) : (
<InfoCard
variant="Critical"
title="Alert"
description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages."
/>
))}
<Text priority="400">Youre about to log out. Are you sure?</Text>
{logoutState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to logout! {logoutState.error.message}
</Text>
)}
<Box direction="Column" gap="200">
<Button
variant="Critical"
onClick={logout}
disabled={ongoingLogout}
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
>
<Text size="B400">Logout</Text>
</Button>
<Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
<Text size="B400">Cancel</Text>
</Button>
</Box>
</Box>
</Dialog>
);
}
);

View File

@@ -0,0 +1,199 @@
import React, { MouseEventHandler, ReactNode, useCallback, useState } from 'react';
import {
Box,
Text,
Chip,
Icon,
Icons,
RectCords,
PopOut,
Menu,
config,
MenuItem,
color,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';
import { SettingTile } from './setting-tile';
import { SecretStorageKeyContent } from '../../types/matrix/accountData';
import { SecretStorageRecoveryKey, SecretStorageRecoveryPassphrase } from './SecretStorage';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { storePrivateKey } from '../../client/state/secretStorageKeys';
export enum ManualVerificationMethod {
RecoveryPassphrase = 'passphrase',
RecoveryKey = 'key',
}
type ManualVerificationMethodSwitcherProps = {
value: ManualVerificationMethod;
onChange: (value: ManualVerificationMethod) => void;
};
export function ManualVerificationMethodSwitcher({
value,
onChange,
}: ManualVerificationMethodSwitcherProps) {
const [menuCords, setMenuCords] = useState<RectCords>();
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (method: ManualVerificationMethod) => {
setMenuCords(undefined);
onChange(method);
};
return (
<>
<Chip
type="button"
variant="Secondary"
fill="Soft"
radii="Pill"
before={<Icon size="100" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text as="span" size="B300">
{value === ManualVerificationMethod.RecoveryPassphrase && 'Recovery Passphrase'}
{value === ManualVerificationMethod.RecoveryKey && 'Recovery Key'}
</Text>
</Chip>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
variant="Surface"
aria-selected={value === ManualVerificationMethod.RecoveryPassphrase}
radii="300"
onClick={() => handleSelect(ManualVerificationMethod.RecoveryPassphrase)}
>
<Box grow="Yes">
<Text size="T300">Recovery Passphrase</Text>
</Box>
</MenuItem>
<MenuItem
size="300"
variant="Surface"
aria-selected={value === ManualVerificationMethod.RecoveryKey}
radii="300"
onClick={() => handleSelect(ManualVerificationMethod.RecoveryKey)}
>
<Box grow="Yes">
<Text size="T300">Recovery Key</Text>
</Box>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
type ManualVerificationTileProps = {
secretStorageKeyId: string;
secretStorageKeyContent: SecretStorageKeyContent;
options?: ReactNode;
};
export function ManualVerificationTile({
secretStorageKeyId,
secretStorageKeyContent,
options,
}: ManualVerificationTileProps) {
const mx = useMatrixClient();
const hasPassphrase = !!secretStorageKeyContent.passphrase;
const [method, setMethod] = useState(
hasPassphrase
? ManualVerificationMethod.RecoveryPassphrase
: ManualVerificationMethod.RecoveryKey
);
const verifyAndRestoreBackup = useCallback(
async (recoveryKey: Uint8Array) => {
const crypto = mx.getCrypto();
if (!crypto) {
throw new Error('Unexpected Error! Crypto object not found.');
}
storePrivateKey(secretStorageKeyId, recoveryKey);
await crypto.bootstrapCrossSigning({});
await crypto.bootstrapSecretStorage({});
await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
},
[mx, secretStorageKeyId]
);
const [verifyState, handleDecodedRecoveryKey] = useAsyncCallback<void, Error, [Uint8Array]>(
verifyAndRestoreBackup
);
const verifying = verifyState.status === AsyncStatus.Loading;
return (
<Box direction="Column" gap="200">
<SettingTile
title="Verify Manually"
description={hasPassphrase ? 'Select a verification method.' : 'Provide recovery key.'}
after={
<Box alignItems="Center" gap="200">
{hasPassphrase && (
<ManualVerificationMethodSwitcher value={method} onChange={setMethod} />
)}
{options}
</Box>
}
/>
{verifyState.status === AsyncStatus.Success ? (
<Text size="T200" style={{ color: color.Success.Main }}>
<b>Device verified!</b>
</Text>
) : (
<Box direction="Column" gap="100">
{method === ManualVerificationMethod.RecoveryKey && (
<SecretStorageRecoveryKey
processing={verifying}
keyContent={secretStorageKeyContent}
onDecodedRecoveryKey={handleDecodedRecoveryKey}
/>
)}
{method === ManualVerificationMethod.RecoveryPassphrase &&
secretStorageKeyContent.passphrase && (
<SecretStorageRecoveryPassphrase
processing={verifying}
keyContent={secretStorageKeyContent}
passphraseContent={secretStorageKeyContent.passphrase}
onDecodedRecoveryKey={handleDecodedRecoveryKey}
/>
)}
{verifyState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{verifyState.error.message}</b>
</Text>
)}
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,29 @@
import React, { ReactNode } from 'react';
import FocusTrap from 'focus-trap-react';
import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
import { stopPropagation } from '../utils/keyboard';
type Modal500Props = {
requestClose: () => void;
children: ReactNode;
};
export function Modal500({ requestClose, children }: Modal500Props) {
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: requestClose,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" variant="Background">
{children}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View File

@@ -29,6 +29,7 @@ import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer'; import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer'; import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to'; import { testMatrixTo } from '../plugins/matrix-to';
import {IImageContent} from "../../types/matrix/common";
type RenderMessageContentProps = { type RenderMessageContentProps = {
displayName: string; displayName: string;
@@ -67,38 +68,63 @@ export function RenderMessageContent({
</UrlPreviewHolder> </UrlPreviewHolder>
); );
}; };
const renderCaption = () => {
const content: IImageContent = getContent();
if(content.filename && content.filename !== content.body) {
return (
<MText
edited={edited}
content={content}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/>
)
}
return null;
}
const renderFile = () => ( const renderFile = () => (
<MFile <>
content={getContent()} <MFile
renderFileContent={({ body, mimeType, info, encInfo, url }) => ( content={getContent()}
<FileContent renderFileContent={({ body, mimeType, info, encInfo, url }) => (
body={body} <FileContent
mimeType={mimeType}
renderAsPdfFile={() => (
<ReadPdfFile
body={body} body={body}
mimeType={mimeType} mimeType={mimeType}
url={url} renderAsPdfFile={() => (
encInfo={encInfo} <ReadPdfFile
renderViewer={(p) => <PdfViewer {...p} />} body={body}
/> mimeType={mimeType}
)} url={url}
renderAsTextFile={() => ( encInfo={encInfo}
<ReadTextFile renderViewer={(p) => <PdfViewer {...p} />}
body={body} />
mimeType={mimeType} )}
url={url} renderAsTextFile={() => (
encInfo={encInfo} <ReadTextFile
renderViewer={(p) => <TextViewer {...p} />} body={body}
/> mimeType={mimeType}
)} url={url}
> encInfo={encInfo}
<DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} /> renderViewer={(p) => <TextViewer {...p} />}
</FileContent> />
)} )}
outlined={outlineAttachment} >
/> <DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} />
</FileContent>
)}
outlined={outlineAttachment}
/>
{renderCaption()}
</>
); );
if (msgType === MsgType.Text) { if (msgType === MsgType.Text) {
@@ -158,36 +184,40 @@ export function RenderMessageContent({
if (msgType === MsgType.Image) { if (msgType === MsgType.Image) {
return ( return (
<MImage <>
content={getContent()} <MImage
renderImageContent={(props) => ( content={getContent()}
<ImageContent renderImageContent={(props) => (
{...props} <ImageContent
autoPlay={mediaAutoLoad} {...props}
renderImage={(p) => <Image {...p} loading="lazy" />} autoPlay={mediaAutoLoad}
renderViewer={(p) => <ImageViewer {...p} />} renderImage={(p) => <Image {...p} loading="lazy" />}
/> renderViewer={(p) => <ImageViewer {...p} />}
)} />
outlined={outlineAttachment} )}
/> outlined={outlineAttachment}
/>
{renderCaption()}
</>
); );
} }
if (msgType === MsgType.Video) { if (msgType === MsgType.Video) {
return ( return (
<MVideo <>
content={getContent()} <MVideo
renderAsFile={renderFile} content={getContent()}
renderVideoContent={({ body, info, mimeType, url, encInfo }) => ( renderAsFile={renderFile}
<VideoContent renderVideoContent={({ body, info, mimeType, url, encInfo }) => (
body={body} <VideoContent
info={info} body={body}
mimeType={mimeType} info={info}
url={url} mimeType={mimeType}
encInfo={encInfo} url={url}
renderThumbnail={ encInfo={encInfo}
mediaAutoLoad renderThumbnail={
? () => ( mediaAutoLoad
? () => (
<ThumbnailContent <ThumbnailContent
info={info} info={info}
renderImage={(src) => ( renderImage={(src) => (
@@ -195,26 +225,33 @@ export function RenderMessageContent({
)} )}
/> />
) )
: undefined : undefined
} }
renderVideo={(p) => <Video {...p} />} renderVideo={(p) => <Video {...p} />}
/> />
)} )}
outlined={outlineAttachment} outlined={outlineAttachment}
/> />
{renderCaption()}
</>
); );
} }
if (msgType === MsgType.Audio) { if (msgType === MsgType.Audio) {
return ( return (
<MAudio <>
content={getContent()} <MAudio
renderAsFile={renderFile} content={getContent()}
renderAudioContent={(props) => ( renderAsFile={renderFile}
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} /> renderAudioContent={(props) => (
)} <AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
outlined={outlineAttachment} )}
/> outlined={outlineAttachment}
/>
{renderCaption()}
</>
); );
} }

View File

@@ -0,0 +1,200 @@
import React, { FormEventHandler, useCallback } from 'react';
import { Box, Text, Button, Spinner, color } from 'folds';
import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto-api';
import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase';
import { PasswordInput } from './password-input';
import {
SecretStorageKeyContent,
SecretStoragePassphraseContent,
} from '../../types/matrix/accountData';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAlive } from '../hooks/useAlive';
type SecretStorageRecoveryPassphraseProps = {
processing?: boolean;
keyContent: SecretStorageKeyContent;
passphraseContent: SecretStoragePassphraseContent;
onDecodedRecoveryKey: (recoveryKey: Uint8Array) => void;
};
export function SecretStorageRecoveryPassphrase({
processing,
keyContent,
passphraseContent,
onDecodedRecoveryKey,
}: SecretStorageRecoveryPassphraseProps) {
const mx = useMatrixClient();
const alive = useAlive();
const [driveKeyState, submitPassphrase] = useAsyncCallback<
Uint8Array,
Error,
Parameters<typeof deriveKey>
>(
useCallback(
async (passphrase, salt, iterations, bits) => {
const decodedRecoveryKey = await deriveKey(passphrase, salt, iterations, bits);
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
if (!match) {
throw new Error('Invalid recovery passphrase.');
}
return decodedRecoveryKey;
},
[mx, keyContent]
)
);
const drivingKey = driveKeyState.status === AsyncStatus.Loading;
const loading = drivingKey || processing;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
if (loading) return;
evt.preventDefault();
const target = evt.target as HTMLFormElement | undefined;
const recoveryPassphraseInput = target?.recoveryPassphraseInput as HTMLInputElement | undefined;
if (!recoveryPassphraseInput) return;
const recoveryPassphrase = recoveryPassphraseInput.value.trim();
if (!recoveryPassphrase) return;
const { salt, iterations, bits } = passphraseContent;
submitPassphrase(recoveryPassphrase, salt, iterations, bits).then((decodedRecoveryKey) => {
if (alive()) {
recoveryPassphraseInput.value = '';
onDecodedRecoveryKey(decodedRecoveryKey);
}
});
};
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
<Box gap="200" alignItems="End">
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Recovery Passphrase</Text>
<PasswordInput
name="recoveryPassphraseInput"
size="400"
variant="Secondary"
radii="300"
autoFocus
required
outlined
readOnly={loading}
/>
</Box>
<Box shrink="No" gap="200">
<Button
type="submit"
variant="Success"
size="400"
radii="300"
disabled={loading}
before={loading && <Spinner size="200" variant="Success" fill="Solid" />}
>
<Text as="span" size="B400">
Verify
</Text>
</Button>
</Box>
</Box>
{driveKeyState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{driveKeyState.error.message}</b>
</Text>
)}
</Box>
);
}
type SecretStorageRecoveryKeyProps = {
processing?: boolean;
keyContent: SecretStorageKeyContent;
onDecodedRecoveryKey: (recoveryKey: Uint8Array) => void;
};
export function SecretStorageRecoveryKey({
processing,
keyContent,
onDecodedRecoveryKey,
}: SecretStorageRecoveryKeyProps) {
const mx = useMatrixClient();
const alive = useAlive();
const [driveKeyState, submitRecoveryKey] = useAsyncCallback<Uint8Array, Error, [string]>(
useCallback(
async (recoveryKey) => {
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
if (!match) {
throw new Error('Invalid recovery key.');
}
return decodedRecoveryKey;
},
[mx, keyContent]
)
);
const drivingKey = driveKeyState.status === AsyncStatus.Loading;
const loading = drivingKey || processing;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const target = evt.target as HTMLFormElement | undefined;
const recoveryKeyInput = target?.recoveryKeyInput as HTMLInputElement | undefined;
if (!recoveryKeyInput) return;
const recoveryKey = recoveryKeyInput.value.trim();
if (!recoveryKey) return;
submitRecoveryKey(recoveryKey).then((decodedRecoveryKey) => {
if (alive()) {
recoveryKeyInput.value = '';
onDecodedRecoveryKey(decodedRecoveryKey);
}
});
};
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
<Box gap="200" alignItems="End">
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Recovery Key</Text>
<PasswordInput
name="recoveryKeyInput"
size="400"
variant="Secondary"
radii="300"
autoFocus
required
outlined
readOnly={loading}
/>
</Box>
<Box shrink="No" gap="200">
<Button
type="submit"
variant="Success"
size="400"
radii="300"
disabled={loading}
before={loading && <Spinner size="200" variant="Success" fill="Solid" />}
>
<Text as="span" size="B400">
Verify
</Text>
</Button>
</Box>
</Box>
{driveKeyState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{driveKeyState.error.message}</b>
</Text>
)}
</Box>
);
}

View File

@@ -13,7 +13,6 @@ import {
IconButton, IconButton,
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';
export type UIAFlowOverlayProps = { export type UIAFlowOverlayProps = {
currentStep: number; currentStep: number;
@@ -29,7 +28,7 @@ export function UIAFlowOverlay({
}: UIAFlowOverlayProps) { }: UIAFlowOverlayProps) {
return ( return (
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={<OverlayBackdrop />}>
<FocusTrap focusTrapOptions={{ initialFocus: false, escapeDeactivates: stopPropagation }}> <FocusTrap focusTrapOptions={{ initialFocus: false, escapeDeactivates: false }}>
<Box style={{ height: '100%' }} direction="Column" grow="Yes" gap="400"> <Box style={{ height: '100%' }} direction="Column" grow="Yes" gap="400">
<Box grow="Yes" direction="Column" alignItems="Center" justifyContent="Center"> <Box grow="Yes" direction="Column" alignItems="Center" justifyContent="Center">
{children} {children}

View File

@@ -257,7 +257,9 @@ export function Toolbar() {
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl'; const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
const disableInline = isBlockActive(editor, BlockType.CodeBlock); const disableInline = isBlockActive(editor, BlockType.CodeBlock);
const canEscape = isAnyMarkActive(editor) || !isBlockActive(editor, BlockType.Paragraph); const canEscape = isBlockActive(editor, BlockType.Paragraph)
? isAnyMarkActive(editor)
: ReactEditor.isFocused(editor);
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
return ( return (

View File

@@ -5,6 +5,7 @@ import { Header, Menu, Scroll, config } from 'folds';
import * as css from './AutocompleteMenu.css'; import * as css from './AutocompleteMenu.css';
import { preventScrollWithArrowKey, stopPropagation } from '../../../utils/keyboard'; import { preventScrollWithArrowKey, stopPropagation } from '../../../utils/keyboard';
import { useAlive } from '../../../hooks/useAlive';
type AutocompleteMenuProps = { type AutocompleteMenuProps = {
requestClose: () => void; requestClose: () => void;
@@ -12,13 +13,22 @@ type AutocompleteMenuProps = {
children: ReactNode; children: ReactNode;
}; };
export function AutocompleteMenu({ headerContent, requestClose, children }: AutocompleteMenuProps) { export function AutocompleteMenu({ headerContent, requestClose, children }: AutocompleteMenuProps) {
const alive = useAlive();
const handleDeactivate = () => {
if (alive()) {
// The component is unmounted so we will not call for `requestClose`
requestClose();
}
};
return ( return (
<div className={css.AutocompleteMenuBase}> <div className={css.AutocompleteMenuBase}>
<div className={css.AutocompleteMenuContainer}> <div className={css.AutocompleteMenuContainer}>
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
onDeactivate: () => requestClose(), onPostDeactivate: handleDeactivate,
returnFocusOnDeactivate: false, returnFocusOnDeactivate: false,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
allowOutsideClick: true, allowOutsideClick: true,

View File

@@ -6,24 +6,21 @@ import { Room } from 'matrix-js-sdk';
import { AutocompleteQuery } from './autocompleteQuery'; import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu'; import { AutocompleteMenu } from './AutocompleteMenu';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
SearchItemStrGetter,
UseAsyncSearchOptions,
useAsyncSearch,
} from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard'; import { onTabPress } from '../../../utils/keyboard';
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils'; import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useRelevantImagePacks } from '../../../hooks/useImagePacks'; import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
import { IEmoji, emojis } from '../../../plugins/emoji'; import { IEmoji, emojis } from '../../../plugins/emoji';
import { ExtendedPackImage, PackUsage } from '../../../plugins/custom-emoji';
import { useKeyDown } from '../../../hooks/useKeyDown'; import { useKeyDown } from '../../../hooks/useKeyDown';
import { mxcUrlToHttp } from '../../../utils/matrix'; import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ImageUsage, PackImageReader } from '../../../plugins/custom-emoji';
import { getEmoticonSearchStr } from '../../../plugins/utils';
type EmoticonCompleteHandler = (key: string, shortcode: string) => void; type EmoticonCompleteHandler = (key: string, shortcode: string) => void;
type EmoticonSearchItem = ExtendedPackImage | IEmoji; type EmoticonSearchItem = PackImageReader | IEmoji;
type EmoticonAutocompleteProps = { type EmoticonAutocompleteProps = {
imagePackRooms: Room[]; imagePackRooms: Room[];
@@ -33,16 +30,11 @@ type EmoticonAutocompleteProps = {
}; };
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 20,
matchOptions: { matchOptions: {
contain: true, contain: true,
}, },
}; };
const getEmoticonStr: SearchItemStrGetter<EmoticonSearchItem> = (emoticon) => [
`:${emoticon.shortcode}:`,
];
export function EmoticonAutocomplete({ export function EmoticonAutocomplete({
imagePackRooms, imagePackRooms,
editor, editor,
@@ -52,19 +44,23 @@ export function EmoticonAutocomplete({
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const imagePacks = useRelevantImagePacks(mx, PackUsage.Emoticon, imagePackRooms); const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
const recentEmoji = useRecentEmoji(mx, 20); const recentEmoji = useRecentEmoji(mx, 20);
const searchList = useMemo(() => { const searchList = useMemo(() => {
const list: Array<EmoticonSearchItem> = []; const list: Array<EmoticonSearchItem> = [];
return list.concat( return list.concat(
imagePacks.flatMap((pack) => pack.getImagesFor(PackUsage.Emoticon)), imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)),
emojis emojis
); );
}, [imagePacks]); }, [imagePacks]);
const [result, search, resetSearch] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS); const [result, search, resetSearch] = useAsyncSearch(
const autoCompleteEmoticon = result ? result.items : recentEmoji; searchList,
getEmoticonSearchStr,
SEARCH_OPTIONS
);
const autoCompleteEmoticon = result ? result.items.slice(0, 20) : recentEmoji;
useEffect(() => { useEffect(() => {
if (query.text) search(query.text); if (query.text) search(query.text);

View File

@@ -65,7 +65,6 @@ type RoomMentionAutocompleteProps = {
}; };
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 20,
matchOptions: { matchOptions: {
contain: true, contain: true,
}, },
@@ -97,7 +96,7 @@ export function RoomMentionAutocomplete({
SEARCH_OPTIONS SEARCH_OPTIONS
); );
const autoCompleteRoomIds = result ? result.items : allRooms.slice(0, 20); const autoCompleteRoomIds = result ? result.items.slice(0, 20) : allRooms.slice(0, 20);
useEffect(() => { useEffect(() => {
if (query.text) search(query.text); if (query.text) search(query.text);

View File

@@ -19,6 +19,7 @@ import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matri
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room'; import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
import { UserAvatar } from '../../user-avatar'; import { UserAvatar } from '../../user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Membership } from '../../../../types/matrix/room';
type MentionAutoCompleteHandler = (userId: string, name: string) => void; type MentionAutoCompleteHandler = (userId: string, name: string) => void;
@@ -67,8 +68,13 @@ type UserMentionAutocompleteProps = {
requestClose: () => void; requestClose: () => void;
}; };
const withAllowedMembership = (member: RoomMember): boolean =>
member.membership === Membership.Join ||
member.membership === Membership.Invite ||
member.membership === Membership.Knock;
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 20, limit: 1000,
matchOptions: { matchOptions: {
contain: true, contain: true,
}, },
@@ -91,7 +97,9 @@ export function UserMentionAutocomplete({
const members = useRoomMembers(mx, roomId); const members = useRoomMembers(mx, roomId);
const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS); const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS);
const autoCompleteMembers = result ? result.items : members.slice(0, 20); const autoCompleteMembers = (result ? result.items.slice(0, 20) : members.slice(0, 20)).filter(
withAllowedMembership
);
useEffect(() => { useEffect(() => {
if (query.text) search(query.text); if (query.text) search(query.text);

View File

@@ -26,48 +26,75 @@ import {
testMatrixTo, testMatrixTo,
} from '../../plugins/matrix-to'; } from '../../plugins/matrix-to';
import { tryDecodeURIComponent } from '../../utils/dom'; import { tryDecodeURIComponent } from '../../utils/dom';
import {
escapeMarkdownInlineSequences,
escapeMarkdownBlockSequences,
} from '../../plugins/markdown';
const markNodeToType: Record<string, MarkType> = { type ProcessTextCallback = (text: string) => string;
b: MarkType.Bold,
strong: MarkType.Bold,
i: MarkType.Italic,
em: MarkType.Italic,
u: MarkType.Underline,
s: MarkType.StrikeThrough,
del: MarkType.StrikeThrough,
code: MarkType.Code,
span: MarkType.Spoiler,
};
const elementToTextMark = (node: Element): MarkType | undefined => { const getText = (node: ChildNode): string => {
const markType = markNodeToType[node.name];
if (!markType) return undefined;
if (markType === MarkType.Spoiler && node.attribs['data-mx-spoiler'] === undefined) {
return undefined;
}
if (
markType === MarkType.Code &&
node.parent &&
'name' in node.parent &&
node.parent.name === 'pre'
) {
return undefined;
}
return markType;
};
const parseNodeText = (node: ChildNode): string => {
if (isText(node)) { if (isText(node)) {
return node.data; return node.data;
} }
if (isTag(node)) { if (isTag(node)) {
return node.children.map((child) => parseNodeText(child)).join(''); return node.children.map((child) => getText(child)).join('');
} }
return ''; return '';
}; };
const elementToInlineNode = (node: Element): MentionElement | EmoticonElement | undefined => { const getInlineNodeMarkType = (node: Element): MarkType | undefined => {
if (node.name === 'b' || node.name === 'strong') {
return MarkType.Bold;
}
if (node.name === 'i' || node.name === 'em') {
return MarkType.Italic;
}
if (node.name === 'u') {
return MarkType.Underline;
}
if (node.name === 's' || node.name === 'del') {
return MarkType.StrikeThrough;
}
if (node.name === 'code') {
if (node.parent && 'name' in node.parent && node.parent.name === 'pre') {
return undefined; // Don't apply `Code` mark inside a <pre> tag
}
return MarkType.Code;
}
if (node.name === 'span' && node.attribs['data-mx-spoiler'] !== undefined) {
return MarkType.Spoiler;
}
return undefined;
};
const getInlineMarkElement = (
markType: MarkType,
node: Element,
getChild: (child: ChildNode) => InlineElement[]
): InlineElement[] => {
const children = node.children.flatMap(getChild);
const mdSequence = node.attribs['data-md'];
if (mdSequence !== undefined) {
children.unshift({ text: mdSequence });
children.push({ text: mdSequence });
return children;
}
children.forEach((child) => {
if (Text.isText(child)) {
child[markType] = true;
}
});
return children;
};
const getInlineNonMarkElement = (node: Element): MentionElement | EmoticonElement | undefined => {
if (node.name === 'img' && node.attribs['data-mx-emoticon'] !== undefined) { if (node.name === 'img' && node.attribs['data-mx-emoticon'] !== undefined) {
const { src, alt } = node.attribs; const { src, alt } = node.attribs;
if (!src) return undefined; if (!src) return undefined;
@@ -79,13 +106,13 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
if (testMatrixTo(href)) { if (testMatrixTo(href)) {
const userMention = parseMatrixToUser(href); const userMention = parseMatrixToUser(href);
if (userMention) { if (userMention) {
return createMentionElement(userMention, parseNodeText(node) || userMention, false); return createMentionElement(userMention, getText(node) || userMention, false);
} }
const roomMention = parseMatrixToRoom(href); const roomMention = parseMatrixToRoom(href);
if (roomMention) { if (roomMention) {
return createMentionElement( return createMentionElement(
roomMention.roomIdOrAlias, roomMention.roomIdOrAlias,
parseNodeText(node) || roomMention.roomIdOrAlias, getText(node) || roomMention.roomIdOrAlias,
false, false,
undefined, undefined,
roomMention.viaServers roomMention.viaServers
@@ -95,7 +122,7 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
if (eventMention) { if (eventMention) {
return createMentionElement( return createMentionElement(
eventMention.roomIdOrAlias, eventMention.roomIdOrAlias,
parseNodeText(node) || eventMention.roomIdOrAlias, getText(node) || eventMention.roomIdOrAlias,
false, false,
eventMention.eventId, eventMention.eventId,
eventMention.viaServers eventMention.viaServers
@@ -106,44 +133,40 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
return undefined; return undefined;
}; };
const parseInlineNodes = (node: ChildNode): InlineElement[] => { const getInlineElement = (node: ChildNode, processText: ProcessTextCallback): InlineElement[] => {
if (isText(node)) { if (isText(node)) {
return [{ text: node.data }]; return [{ text: processText(node.data) }];
} }
if (isTag(node)) { if (isTag(node)) {
const markType = elementToTextMark(node); const markType = getInlineNodeMarkType(node);
if (markType) { if (markType) {
const children = node.children.flatMap(parseInlineNodes); return getInlineMarkElement(markType, node, (child) => {
if (node.attribs['data-md'] !== undefined) { if (markType === MarkType.Code) return [{ text: getText(child) }];
children.unshift({ text: node.attribs['data-md'] }); return getInlineElement(child, processText);
children.push({ text: node.attribs['data-md'] }); });
} else {
children.forEach((child) => {
if (Text.isText(child)) {
child[markType] = true;
}
});
}
return children;
} }
const inlineNode = elementToInlineNode(node); const inlineNode = getInlineNonMarkElement(node);
if (inlineNode) return [inlineNode]; if (inlineNode) return [inlineNode];
if (node.name === 'a') { if (node.name === 'a') {
const children = node.childNodes.flatMap(parseInlineNodes); const children = node.childNodes.flatMap((child) => getInlineElement(child, processText));
children.unshift({ text: '[' }); children.unshift({ text: '[' });
children.push({ text: `](${node.attribs.href})` }); children.push({ text: `](${node.attribs.href})` });
return children; return children;
} }
return node.childNodes.flatMap(parseInlineNodes); return node.childNodes.flatMap((child) => getInlineElement(child, processText));
} }
return []; return [];
}; };
const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElement[] => { const parseBlockquoteNode = (
node: Element,
processText: ProcessTextCallback
): BlockQuoteElement[] | ParagraphElement[] => {
const quoteLines: Array<InlineElement[]> = []; const quoteLines: Array<InlineElement[]> = [];
let lineHolder: InlineElement[] = []; let lineHolder: InlineElement[] = [];
@@ -156,7 +179,7 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
node.children.forEach((child) => { node.children.forEach((child) => {
if (isText(child)) { if (isText(child)) {
lineHolder.push({ text: child.data }); lineHolder.push({ text: processText(child.data) });
return; return;
} }
if (isTag(child)) { if (isTag(child)) {
@@ -168,19 +191,20 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
if (child.name === 'p') { if (child.name === 'p') {
appendLine(); appendLine();
quoteLines.push(child.children.flatMap((c) => parseInlineNodes(c))); quoteLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
return; return;
} }
parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode)); lineHolder.push(...getInlineElement(child, processText));
} }
}); });
appendLine(); appendLine();
if (node.attribs['data-md'] !== undefined) { const mdSequence = node.attribs['data-md'];
if (mdSequence !== undefined) {
return quoteLines.map((lineChildren) => ({ return quoteLines.map((lineChildren) => ({
type: BlockType.Paragraph, type: BlockType.Paragraph,
children: [{ text: `${node.attribs['data-md']} ` }, ...lineChildren], children: [{ text: `${mdSequence} ` }, ...lineChildren],
})); }));
} }
@@ -195,22 +219,19 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
]; ];
}; };
const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElement[] => { const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElement[] => {
const codeLines = parseNodeText(node).trim().split('\n'); const codeLines = getText(node).trim().split('\n');
if (node.attribs['data-md'] !== undefined) { const mdSequence = node.attribs['data-md'];
const pLines = codeLines.map<ParagraphElement>((lineText) => ({ if (mdSequence !== undefined) {
const pLines = codeLines.map<ParagraphElement>((text) => ({
type: BlockType.Paragraph, type: BlockType.Paragraph,
children: [ children: [{ text }],
{
text: lineText,
},
],
})); }));
const childCode = node.children[0]; const childCode = node.children[0];
const className = const className =
isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : ''; isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : '';
const prefix = { text: `${node.attribs['data-md']}${className.replace('language-', '')}` }; const prefix = { text: `${mdSequence}${className.replace('language-', '')}` };
const suffix = { text: node.attribs['data-md'] }; const suffix = { text: mdSequence };
return [ return [
{ type: BlockType.Paragraph, children: [prefix] }, { type: BlockType.Paragraph, children: [prefix] },
...pLines, ...pLines,
@@ -221,19 +242,16 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
return [ return [
{ {
type: BlockType.CodeBlock, type: BlockType.CodeBlock,
children: codeLines.map<CodeLineElement>((lineTxt) => ({ children: codeLines.map<CodeLineElement>((text) => ({
type: BlockType.CodeLine, type: BlockType.CodeLine,
children: [ children: [{ text }],
{
text: lineTxt,
},
],
})), })),
}, },
]; ];
}; };
const parseListNode = ( const parseListNode = (
node: Element node: Element,
processText: ProcessTextCallback
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => { ): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
const listLines: Array<InlineElement[]> = []; const listLines: Array<InlineElement[]> = [];
let lineHolder: InlineElement[] = []; let lineHolder: InlineElement[] = [];
@@ -247,7 +265,7 @@ const parseListNode = (
node.children.forEach((child) => { node.children.forEach((child) => {
if (isText(child)) { if (isText(child)) {
lineHolder.push({ text: child.data }); lineHolder.push({ text: processText(child.data) });
return; return;
} }
if (isTag(child)) { if (isTag(child)) {
@@ -259,17 +277,18 @@ const parseListNode = (
if (child.name === 'li') { if (child.name === 'li') {
appendLine(); appendLine();
listLines.push(child.children.flatMap((c) => parseInlineNodes(c))); listLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
return; return;
} }
parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode)); lineHolder.push(...getInlineElement(child, processText));
} }
}); });
appendLine(); appendLine();
if (node.attribs['data-md'] !== undefined) { const mdSequence = node.attribs['data-md'];
const prefix = node.attribs['data-md'] || '-'; if (mdSequence !== undefined) {
const prefix = mdSequence || '-';
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? []; const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
return listLines.map((lineChildren) => ({ return listLines.map((lineChildren) => ({
type: BlockType.Paragraph, type: BlockType.Paragraph,
@@ -302,17 +321,21 @@ const parseListNode = (
}, },
]; ];
}; };
const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => { const parseHeadingNode = (
const children = node.children.flatMap((child) => parseInlineNodes(child)); node: Element,
processText: ProcessTextCallback
): HeadingElement | ParagraphElement => {
const children = node.children.flatMap((child) => getInlineElement(child, processText));
const headingMatch = node.name.match(/^h([123456])$/); const headingMatch = node.name.match(/^h([123456])$/);
const [, g1AsLevel] = headingMatch ?? ['h3', '3']; const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
const level = parseInt(g1AsLevel, 10); const level = parseInt(g1AsLevel, 10);
if (node.attribs['data-md'] !== undefined) { const mdSequence = node.attribs['data-md'];
if (mdSequence !== undefined) {
return { return {
type: BlockType.Paragraph, type: BlockType.Paragraph,
children: [{ text: `${node.attribs['data-md']} ` }, ...children], children: [{ text: `${mdSequence} ` }, ...children],
}; };
} }
@@ -323,7 +346,11 @@ const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => {
}; };
}; };
export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => { export const domToEditorInput = (
domNodes: ChildNode[],
processText: ProcessTextCallback,
processLineStartText: ProcessTextCallback
): Descendant[] => {
const children: Descendant[] = []; const children: Descendant[] = [];
let lineHolder: InlineElement[] = []; let lineHolder: InlineElement[] = [];
@@ -340,7 +367,14 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
domNodes.forEach((node) => { domNodes.forEach((node) => {
if (isText(node)) { if (isText(node)) {
lineHolder.push({ text: node.data }); if (lineHolder.length === 0) {
// we are inserting first part of line
// it may contain block markdown starting data
// that we may need to escape.
lineHolder.push({ text: processLineStartText(node.data) });
return;
}
lineHolder.push({ text: processText(node.data) });
return; return;
} }
if (isTag(node)) { if (isTag(node)) {
@@ -354,14 +388,14 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
appendLine(); appendLine();
children.push({ children.push({
type: BlockType.Paragraph, type: BlockType.Paragraph,
children: node.children.flatMap((child) => parseInlineNodes(child)), children: node.children.flatMap((child) => getInlineElement(child, processText)),
}); });
return; return;
} }
if (node.name === 'blockquote') { if (node.name === 'blockquote') {
appendLine(); appendLine();
children.push(...parseBlockquoteNode(node)); children.push(...parseBlockquoteNode(node, processText));
return; return;
} }
if (node.name === 'pre') { if (node.name === 'pre') {
@@ -371,17 +405,17 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
} }
if (node.name === 'ol' || node.name === 'ul') { if (node.name === 'ol' || node.name === 'ul') {
appendLine(); appendLine();
children.push(...parseListNode(node)); children.push(...parseListNode(node, processText));
return; return;
} }
if (node.name.match(/^h[123456]$/)) { if (node.name.match(/^h[123456]$/)) {
appendLine(); appendLine();
children.push(parseHeadingNode(node)); children.push(parseHeadingNode(node, processText));
return; return;
} }
parseInlineNodes(node).forEach((inlineNode) => lineHolder.push(inlineNode)); lineHolder.push(...getInlineElement(node, processText));
} }
}); });
appendLine(); appendLine();
@@ -389,21 +423,31 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
return children; return children;
}; };
export const htmlToEditorInput = (unsafeHtml: string): Descendant[] => { export const htmlToEditorInput = (unsafeHtml: string, markdown?: boolean): Descendant[] => {
const sanitizedHtml = sanitizeCustomHtml(unsafeHtml); const sanitizedHtml = sanitizeCustomHtml(unsafeHtml);
const processText = (partText: string) => {
if (!markdown) return partText;
return escapeMarkdownInlineSequences(partText);
};
const domNodes = parse(sanitizedHtml); const domNodes = parse(sanitizedHtml);
const editorNodes = domToEditorInput(domNodes); const editorNodes = domToEditorInput(domNodes, processText, (lineStartText: string) => {
if (!markdown) return lineStartText;
return escapeMarkdownBlockSequences(lineStartText, processText);
});
return editorNodes; return editorNodes;
}; };
export const plainToEditorInput = (text: string): Descendant[] => { export const plainToEditorInput = (text: string, markdown?: boolean): Descendant[] => {
const editorNodes: Descendant[] = text.split('\n').map((lineText) => { const editorNodes: Descendant[] = text.split('\n').map((lineText) => {
const paragraphNode: ParagraphElement = { const paragraphNode: ParagraphElement = {
type: BlockType.Paragraph, type: BlockType.Paragraph,
children: [ children: [
{ {
text: lineText, text: markdown
? escapeMarkdownBlockSequences(lineText, escapeMarkdownInlineSequences)
: lineText,
}, },
], ],
}; };

View File

@@ -1,10 +1,17 @@
import { Descendant, Text } from 'slate'; import { Descendant, Editor, Text } from 'slate';
import { MatrixClient } from 'matrix-js-sdk';
import { sanitizeText } from '../../utils/sanitize'; import { sanitizeText } from '../../utils/sanitize';
import { BlockType } from './types'; import { BlockType } from './types';
import { CustomElement } from './slate'; import { CustomElement } from './slate';
import { parseBlockMD, parseInlineMD } from '../../plugins/markdown'; import {
parseBlockMD,
parseInlineMD,
unescapeMarkdownBlockSequences,
unescapeMarkdownInlineSequences,
} from '../../plugins/markdown';
import { findAndReplace } from '../../utils/findAndReplace'; import { findAndReplace } from '../../utils/findAndReplace';
import { sanitizeForRegex } from '../../utils/regex';
import { getCanonicalAliasOrRoomId, isUserId } from '../../utils/matrix';
export type OutputOptions = { export type OutputOptions = {
allowTextFormatting?: boolean; allowTextFormatting?: boolean;
@@ -18,7 +25,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
if (node.bold) string = `<strong>${string}</strong>`; if (node.bold) string = `<strong>${string}</strong>`;
if (node.italic) string = `<i>${string}</i>`; if (node.italic) string = `<i>${string}</i>`;
if (node.underline) string = `<u>${string}</u>`; if (node.underline) string = `<u>${string}</u>`;
if (node.strikeThrough) string = `<del>${string}</del>`; if (node.strikeThrough) string = `<s>${string}</s>`;
if (node.code) string = `<code>${string}</code>`; if (node.code) string = `<code>${string}</code>`;
if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`; if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
} }
@@ -101,7 +108,8 @@ export const toMatrixCustomHTML = (
allowBlockMarkdown: false, allowBlockMarkdown: false,
}) })
.replace(/<br\/>$/, '\n') .replace(/<br\/>$/, '\n')
.replace(/^&gt;/, '>'); .replace(/^(\\*)&gt;/, '$1>');
markdownLines += line; markdownLines += line;
if (index === targetNodes.length - 1) { if (index === targetNodes.length - 1) {
return parseBlockMD(markdownLines, ignoreHTMLParseInlineMD); return parseBlockMD(markdownLines, ignoreHTMLParseInlineMD);
@@ -156,11 +164,14 @@ const elementToPlainText = (node: CustomElement, children: string): string => {
} }
}; };
export const toPlainText = (node: Descendant | Descendant[]): string => { export const toPlainText = (node: Descendant | Descendant[], isMarkdown: boolean): string => {
if (Array.isArray(node)) return node.map((n) => toPlainText(n)).join(''); if (Array.isArray(node)) return node.map((n) => toPlainText(n, isMarkdown)).join('');
if (Text.isText(node)) return node.text; if (Text.isText(node))
return isMarkdown
? unescapeMarkdownBlockSequences(node.text, unescapeMarkdownInlineSequences)
: node.text;
const children = node.children.map((n) => toPlainText(n)).join(''); const children = node.children.map((n) => toPlainText(n, isMarkdown)).join('');
return elementToPlainText(node, children); return elementToPlainText(node, children);
}; };
@@ -179,9 +190,42 @@ export const customHtmlEqualsPlainText = (customHtml: string, plain: string): bo
export const trimCustomHtml = (customHtml: string) => customHtml.replace(/<br\/>$/g, '').trim(); export const trimCustomHtml = (customHtml: string) => customHtml.replace(/<br\/>$/g, '').trim();
export const trimCommand = (cmdName: string, str: string) => { export const trimCommand = (cmdName: string, str: string) => {
const cmdRegX = new RegExp(`^(\\s+)?(\\/${cmdName})([^\\S\n]+)?`); const cmdRegX = new RegExp(`^(\\s+)?(\\/${sanitizeForRegex(cmdName)})([^\\S\n]+)?`);
const match = str.match(cmdRegX); const match = str.match(cmdRegX);
if (!match) return str; if (!match) return str;
return str.slice(match[0].length); return str.slice(match[0].length);
}; };
export type MentionsData = {
room: boolean;
users: Set<string>;
};
export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): MentionsData => {
const mentionData: MentionsData = {
room: false,
users: new Set(),
};
const parseMentions = (node: Descendant): void => {
if (Text.isText(node)) return;
if (node.type === BlockType.CodeBlock) return;
if (node.type === BlockType.Mention) {
if (node.id === getCanonicalAliasOrRoomId(mx, roomId)) {
mentionData.room = true;
}
if (isUserId(node.id) && node.id !== mx.getUserId()) {
mentionData.users.add(node.id);
}
return;
}
node.children.forEach(parseMentions);
};
editor.children.forEach(parseMentions);
return mentionData;
};

View File

@@ -41,7 +41,6 @@ import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard
import { useRelevantImagePacks } from '../../hooks/useImagePacks'; import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../hooks/useRecentEmoji';
import { ExtendedPackImage, ImagePack, PackUsage } from '../../plugins/custom-emoji';
import { isUserId, mxcUrlToHttp } from '../../utils/matrix'; import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
import { editableActiveElement, isIntersectingScrollView, targetFromEvent } from '../../utils/dom'; import { editableActiveElement, isIntersectingScrollView, targetFromEvent } from '../../utils/dom';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
@@ -50,6 +49,8 @@ import { useThrottle } from '../../hooks/useThrottle';
import { addRecentEmoji } from '../../plugins/recent-emoji'; import { addRecentEmoji } from '../../plugins/recent-emoji';
import { mobileOrTablet } from '../../utils/user-agent'; import { mobileOrTablet } from '../../utils/user-agent';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
import { getEmoticonSearchStr } from '../../plugins/utils';
const RECENT_GROUP_ID = 'recent_group'; const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_group'; const SEARCH_GROUP_ID = 'search_group';
@@ -359,16 +360,16 @@ function ImagePackSidebarStack({
}: { }: {
mx: MatrixClient; mx: MatrixClient;
packs: ImagePack[]; packs: ImagePack[];
usage: PackUsage; usage: ImageUsage;
onItemClick: (id: string) => void; onItemClick: (id: string) => void;
useAuthentication?: boolean; useAuthentication?: boolean;
}) { }) {
const activeGroupId = useAtomValue(activeGroupIdAtom); const activeGroupId = useAtomValue(activeGroupIdAtom);
return ( return (
<SidebarStack> <SidebarStack>
{usage === PackUsage.Emoticon && <SidebarDivider />} {usage === ImageUsage.Emoticon && <SidebarDivider />}
{packs.map((pack) => { {packs.map((pack) => {
let label = pack.displayName; let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
return ( return (
<SidebarBtn <SidebarBtn
@@ -384,7 +385,10 @@ function ImagePackSidebarStack({
height: toRem(24), height: toRem(24),
objectFit: 'contain', objectFit: 'contain',
}} }}
src={mxcUrlToHttp(mx, pack.getPackAvatarUrl(usage) ?? '', useAuthentication) || pack.avatarUrl} src={
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ||
pack.meta.avatar
}
alt={label || 'Unknown Pack'} alt={label || 'Unknown Pack'}
/> />
</SidebarBtn> </SidebarBtn>
@@ -462,130 +466,154 @@ export function SearchEmojiGroup({
tab: EmojiBoardTab; tab: EmojiBoardTab;
label: string; label: string;
id: string; id: string;
emojis: Array<ExtendedPackImage | IEmoji>; emojis: Array<PackImageReader | IEmoji>;
useAuthentication?: boolean; useAuthentication?: boolean;
}) { }) {
return ( return (
<EmojiGroup key={id} id={id} label={label}> <EmojiGroup key={id} id={id} label={label}>
{tab === EmojiBoardTab.Emoji {tab === EmojiBoardTab.Emoji
? searchResult.map((emoji) => ? searchResult.map((emoji) =>
'unicode' in emoji ? ( 'unicode' in emoji ? (
<EmojiItem <EmojiItem
key={emoji.unicode} key={emoji.unicode}
label={emoji.label} label={emoji.label}
type={EmojiType.Emoji} type={EmojiType.Emoji}
data={emoji.unicode} data={emoji.unicode}
shortcode={emoji.shortcode} shortcode={emoji.shortcode}
> >
{emoji.unicode} {emoji.unicode}
</EmojiItem> </EmojiItem>
) : ( ) : (
<EmojiItem <EmojiItem
key={emoji.shortcode} key={emoji.shortcode}
label={emoji.body || emoji.shortcode} label={emoji.body || emoji.shortcode}
type={EmojiType.CustomEmoji} type={EmojiType.CustomEmoji}
data={emoji.url} data={emoji.url}
shortcode={emoji.shortcode} shortcode={emoji.shortcode}
> >
<img <img
loading="lazy" loading="lazy"
className={css.CustomEmojiImg} className={css.CustomEmojiImg}
alt={emoji.body || emoji.shortcode} alt={emoji.body || emoji.shortcode}
src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url} src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url}
/> />
</EmojiItem> </EmojiItem>
)
) )
)
: searchResult.map((emoji) => : searchResult.map((emoji) =>
'unicode' in emoji ? null : ( 'unicode' in emoji ? null : (
<StickerItem <StickerItem
key={emoji.shortcode} key={emoji.shortcode}
label={emoji.body || emoji.shortcode} label={emoji.body || emoji.shortcode}
type={EmojiType.Sticker} type={EmojiType.Sticker}
data={emoji.url} data={emoji.url}
shortcode={emoji.shortcode} shortcode={emoji.shortcode}
> >
<img <img
loading="lazy" loading="lazy"
className={css.StickerImg} className={css.StickerImg}
alt={emoji.body || emoji.shortcode} alt={emoji.body || emoji.shortcode}
src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url} src={mxcUrlToHttp(mx, emoji.url, useAuthentication) ?? emoji.url}
/> />
</StickerItem> </StickerItem>
) )
)} )}
</EmojiGroup> </EmojiGroup>
); );
} }
export const CustomEmojiGroups = memo( export const CustomEmojiGroups = memo(
({ mx, groups, useAuthentication }: { mx: MatrixClient; groups: ImagePack[]; useAuthentication?: boolean }) => ( ({
mx,
groups,
useAuthentication,
}: {
mx: MatrixClient;
groups: ImagePack[];
useAuthentication?: boolean;
}) => (
<> <>
{groups.map((pack) => ( {groups.map((pack) => (
<EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}> <EmojiGroup key={pack.id} id={pack.id} label={pack.meta.name || 'Unknown'}>
{pack.getEmojis().map((image) => ( {pack
<EmojiItem .getImages(ImageUsage.Emoticon)
key={image.shortcode} .sort((a, b) => a.shortcode.localeCompare(b.shortcode))
label={image.body || image.shortcode} .map((image) => (
type={EmojiType.CustomEmoji} <EmojiItem
data={image.url} key={image.shortcode}
shortcode={image.shortcode} label={image.body || image.shortcode}
> type={EmojiType.CustomEmoji}
<img data={image.url}
loading="lazy" shortcode={image.shortcode}
className={css.CustomEmojiImg} >
alt={image.body || image.shortcode} <img
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url} loading="lazy"
/> className={css.CustomEmojiImg}
</EmojiItem> alt={image.body || image.shortcode}
))} src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
/>
</EmojiItem>
))}
</EmojiGroup> </EmojiGroup>
))} ))}
</> </>
) )
); );
export const StickerGroups = memo(({ mx, groups, useAuthentication }: { mx: MatrixClient; groups: ImagePack[]; useAuthentication?: boolean }) => ( export const StickerGroups = memo(
<> ({
{groups.length === 0 && ( mx,
<Box groups,
style={{ padding: `${toRem(60)} ${config.space.S500}` }} useAuthentication,
alignItems="Center" }: {
justifyContent="Center" mx: MatrixClient;
direction="Column" groups: ImagePack[];
gap="300" useAuthentication?: boolean;
> }) => (
<Icon size="600" src={Icons.Sticker} /> <>
<Box direction="Inherit"> {groups.length === 0 && (
<Text align="Center">No Sticker Packs!</Text> <Box
<Text priority="300" align="Center" size="T200"> style={{ padding: `${toRem(60)} ${config.space.S500}` }}
Add stickers from user, room or space settings. alignItems="Center"
</Text> justifyContent="Center"
direction="Column"
gap="300"
>
<Icon size="600" src={Icons.Sticker} />
<Box direction="Inherit">
<Text align="Center">No Sticker Packs!</Text>
<Text priority="300" align="Center" size="T200">
Add stickers from user, room or space settings.
</Text>
</Box>
</Box> </Box>
</Box> )}
)} {groups.map((pack) => (
{groups.map((pack) => ( <EmojiGroup key={pack.id} id={pack.id} label={pack.meta.name || 'Unknown'}>
<EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}> {pack
{pack.getStickers().map((image) => ( .getImages(ImageUsage.Sticker)
<StickerItem .sort((a, b) => a.shortcode.localeCompare(b.shortcode))
key={image.shortcode} .map((image) => (
label={image.body || image.shortcode} <StickerItem
type={EmojiType.Sticker} key={image.shortcode}
data={image.url} label={image.body || image.shortcode}
shortcode={image.shortcode} type={EmojiType.Sticker}
> data={image.url}
<img shortcode={image.shortcode}
loading="lazy" >
className={css.StickerImg} <img
alt={image.body || image.shortcode} loading="lazy"
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url} className={css.StickerImg}
/> alt={image.body || image.shortcode}
</StickerItem> src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url}
))} />
</EmojiGroup> </StickerItem>
))} ))}
</> </EmojiGroup>
)); ))}
</>
)
);
export const NativeEmojiGroups = memo( export const NativeEmojiGroups = memo(
({ groups, labels }: { groups: IEmojiGroup[]; labels: IEmojiGroupLabels }) => ( ({ groups, labels }: { groups: IEmojiGroup[]; labels: IEmojiGroupLabels }) => (
@@ -609,15 +637,8 @@ export const NativeEmojiGroups = memo(
) )
); );
const getSearchListItemStr = (item: ExtendedPackImage | IEmoji) => {
const shortcode = `:${item.shortcode}:`;
if ('body' in item) {
return [shortcode, item.body ?? ''];
}
return shortcode;
};
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 26, limit: 1000,
matchOptions: { matchOptions: {
contain: true, contain: true,
}, },
@@ -646,14 +667,14 @@ export function EmojiBoard({
}) { }) {
const emojiTab = tab === EmojiBoardTab.Emoji; const emojiTab = tab === EmojiBoardTab.Emoji;
const stickerTab = tab === EmojiBoardTab.Sticker; const stickerTab = tab === EmojiBoardTab.Sticker;
const usage = emojiTab ? PackUsage.Emoticon : PackUsage.Sticker; const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
const setActiveGroupId = useSetAtom(activeGroupIdAtom); const setActiveGroupId = useSetAtom(activeGroupIdAtom);
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const emojiGroupLabels = useEmojiGroupLabels(); const emojiGroupLabels = useEmojiGroupLabels();
const emojiGroupIcons = useEmojiGroupIcons(); const emojiGroupIcons = useEmojiGroupIcons();
const imagePacks = useRelevantImagePacks(mx, usage, imagePackRooms); const imagePacks = useRelevantImagePacks(usage, imagePackRooms);
const recentEmojis = useRecentEmoji(mx, 21); const recentEmojis = useRecentEmoji(mx, 21);
const contentScrollRef = useRef<HTMLDivElement>(null); const contentScrollRef = useRef<HTMLDivElement>(null);
@@ -661,18 +682,20 @@ export function EmojiBoard({
const emojiPreviewTextRef = useRef<HTMLParagraphElement>(null); const emojiPreviewTextRef = useRef<HTMLParagraphElement>(null);
const searchList = useMemo(() => { const searchList = useMemo(() => {
let list: Array<ExtendedPackImage | IEmoji> = []; let list: Array<PackImageReader | IEmoji> = [];
list = list.concat(imagePacks.flatMap((pack) => pack.getImagesFor(usage))); list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
if (emojiTab) list = list.concat(emojis); if (emojiTab) list = list.concat(emojis);
return list; return list;
}, [emojiTab, usage, imagePacks]); }, [emojiTab, usage, imagePacks]);
const [result, search, resetSearch] = useAsyncSearch( const [result, search, resetSearch] = useAsyncSearch(
searchList, searchList,
getSearchListItemStr, getEmoticonSearchStr,
SEARCH_OPTIONS SEARCH_OPTIONS
); );
const searchedItems = result?.items.slice(0, 100);
const handleOnChange: ChangeEventHandler<HTMLInputElement> = useDebounce( const handleOnChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
useCallback( useCallback(
(evt) => { (evt) => {
@@ -688,7 +711,7 @@ export function EmojiBoard({
const syncActiveGroupId = useCallback(() => { const syncActiveGroupId = useCallback(() => {
const targetEl = contentScrollRef.current; const targetEl = contentScrollRef.current;
if (!targetEl) return; if (!targetEl) return;
const groupEls = [...targetEl.querySelectorAll('div[data-group-id]')] as HTMLElement[]; const groupEls = Array.from(targetEl.querySelectorAll('div[data-group-id]')) as HTMLElement[];
const groupEl = groupEls.find((el) => isIntersectingScrollView(targetEl, el)); const groupEl = groupEls.find((el) => isIntersectingScrollView(targetEl, el));
const groupId = groupEl?.getAttribute('data-group-id') ?? undefined; const groupId = groupEl?.getAttribute('data-group-id') ?? undefined;
setActiveGroupId(groupId); setActiveGroupId(groupId);
@@ -735,7 +758,10 @@ export function EmojiBoard({
} else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) { } else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) {
const img = document.createElement('img'); const img = document.createElement('img');
img.className = css.CustomEmojiImg; img.className = css.CustomEmojiImg;
img.setAttribute('src', mxcUrlToHttp(mx, emojiInfo.data, useAuthentication) || emojiInfo.data); img.setAttribute(
'src',
mxcUrlToHttp(mx, emojiInfo.data, useAuthentication) || emojiInfo.data
);
img.setAttribute('alt', emojiInfo.shortcode); img.setAttribute('alt', emojiInfo.shortcode);
emojiPreviewRef.current.textContent = ''; emojiPreviewRef.current.textContent = '';
emojiPreviewRef.current.appendChild(img); emojiPreviewRef.current.appendChild(img);
@@ -890,21 +916,29 @@ export function EmojiBoard({
direction="Column" direction="Column"
gap="200" gap="200"
> >
{result && ( {searchedItems && (
<SearchEmojiGroup <SearchEmojiGroup
mx={mx} mx={mx}
tab={tab} tab={tab}
id={SEARCH_GROUP_ID} id={SEARCH_GROUP_ID}
label={result.items.length ? 'Search Results' : 'No Results found'} label={searchedItems.length ? 'Search Results' : 'No Results found'}
emojis={result.items} emojis={searchedItems}
useAuthentication={useAuthentication} useAuthentication={useAuthentication}
/> />
)} )}
{emojiTab && recentEmojis.length > 0 && ( {emojiTab && recentEmojis.length > 0 && (
<RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} /> <RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} />
)} )}
{emojiTab && <CustomEmojiGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />} {emojiTab && (
{stickerTab && <StickerGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />} <CustomEmojiGroups
mx={mx}
groups={imagePacks}
useAuthentication={useAuthentication}
/>
)}
{stickerTab && (
<StickerGroups mx={mx} groups={imagePacks} useAuthentication={useAuthentication} />
)}
{emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />} {emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />}
</Box> </Box>
</Scroll> </Scroll>

View File

@@ -0,0 +1,35 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config } from 'folds';
export const ImageEditor = style([
DefaultReset,
{
height: '100%',
},
]);
export const ImageEditorHeader = style([
DefaultReset,
{
paddingLeft: config.space.S200,
paddingRight: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
flexShrink: 0,
gap: config.space.S200,
},
]);
export const ImageEditorContent = style([
DefaultReset,
{
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
overflow: 'hidden',
},
]);
export const Image = style({
width: '100%',
height: '100%',
objectFit: 'contain',
});

View File

@@ -0,0 +1,51 @@
import React from 'react';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import * as css from './ImageEditor.css';
export type ImageEditorProps = {
name: string;
url: string;
requestClose: () => void;
};
export const ImageEditor = as<'div', ImageEditorProps>(
({ className, name, url, requestClose, ...props }, ref) => {
const handleApply = () => {
//
};
return (
<Box
className={classNames(css.ImageEditor, className)}
direction="Column"
{...props}
ref={ref}
>
<Header className={css.ImageEditorHeader} 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>
Image Editor
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<Chip variant="Primary" radii="300" onClick={handleApply}>
<Text size="B300">Save</Text>
</Chip>
</Box>
</Header>
<Box
grow="Yes"
className={css.ImageEditorContent}
justifyContent="Center"
alignItems="Center"
>
<img className={css.Image} src={url} alt={name} />
</Box>
</Box>
);
}
);

View File

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

View File

@@ -0,0 +1,388 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { as, Box, Text, config, Button, Menu, Spinner } from 'folds';
import {
ImagePack,
ImageUsage,
PackContent,
PackImage,
PackImageReader,
packMetaEqual,
PackMetaReader,
} from '../../plugins/custom-emoji';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { SequenceCard } from '../sequence-card';
import { ImageTile, ImageTileEdit, ImageTileUpload } from './ImageTile';
import { SettingTile } from '../setting-tile';
import { UsageSwitcher } from './UsageSwitcher';
import { ImagePackProfile, ImagePackProfileEdit } from './PackMeta';
import * as css from './style.css';
import { useFilePicker } from '../../hooks/useFilePicker';
import { CompactUploadCardRenderer } from '../upload-card';
import { UploadSuccess } from '../../state/upload';
import { getImageInfo, TUploadContent } from '../../utils/matrix';
import { getImageFileUrl, loadImageElement, renameFile } from '../../utils/dom';
import { replaceSpaceWithDash, suffixRename } from '../../utils/common';
import { getFileNameWithoutExt } from '../../utils/mimeTypes';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
export type ImagePackContentProps = {
imagePack: ImagePack;
canEdit?: boolean;
onUpdate?: (packContent: PackContent) => Promise<void>;
};
export const ImagePackContent = as<'div', ImagePackContentProps>(
({ imagePack, canEdit, onUpdate, ...props }, ref) => {
const useAuthentication = useMediaAuthentication();
const [metaEditing, setMetaEditing] = useState(false);
const [savedMeta, setSavedMeta] = useState<PackMetaReader>();
const currentMeta = savedMeta ?? imagePack.meta;
const images = useMemo(() => Array.from(imagePack.images.collection.values()), [imagePack]);
const [files, setFiles] = useState<File[]>([]);
const [uploadedImages, setUploadedImages] = useState<PackImageReader[]>([]);
const [imagesEditing, setImagesEditing] = useState<Set<string>>(new Set());
const [savedImages, setSavedImages] = useState<Map<string, PackImageReader>>(new Map());
const [deleteImages, setDeleteImages] = useState<Set<string>>(new Set());
const hasImageWithShortcode = useCallback(
(shortcode: string): boolean => {
const hasInPack = imagePack.images.collection.has(shortcode);
if (hasInPack) return true;
const hasInUploaded =
uploadedImages.find((img) => img.shortcode === shortcode) !== undefined;
if (hasInUploaded) return true;
const hasInSaved =
Array.from(savedImages).find(([, img]) => img.shortcode === shortcode) !== undefined;
return hasInSaved;
},
[imagePack, savedImages, uploadedImages]
);
const pickFiles = useFilePicker(
useCallback(
(pickedFiles: File[]) => {
const uniqueFiles = pickedFiles.map((file) => {
const fileName = replaceSpaceWithDash(file.name);
if (hasImageWithShortcode(fileName)) {
const uniqueName = suffixRename(fileName, hasImageWithShortcode);
return renameFile(file, uniqueName);
}
return fileName !== file.name ? renameFile(file, fileName) : file;
});
setFiles((f) => [...f, ...uniqueFiles]);
},
[hasImageWithShortcode]
),
true
);
const handleMetaSave = useCallback(
(editedMeta: PackMetaReader) => {
setMetaEditing(false);
setSavedMeta(
(m) =>
new PackMetaReader({
...imagePack.meta.content,
...m?.content,
...editedMeta.content,
})
);
},
[imagePack.meta]
);
const handleMetaCancel = () => setMetaEditing(false);
const handlePackUsageChange = useCallback(
(usg: ImageUsage[]) => {
setSavedMeta(
(m) =>
new PackMetaReader({
...imagePack.meta.content,
...m?.content,
usage: usg,
})
);
},
[imagePack.meta]
);
const handleUploadRemove = useCallback((file: TUploadContent) => {
setFiles((fs) => fs.filter((f) => f !== file));
}, []);
const handleUploadComplete = useCallback(
async (data: UploadSuccess) => {
const imgEl = await loadImageElement(getImageFileUrl(data.file));
const packImage: PackImage = {
url: data.mxc,
info: getImageInfo(imgEl, data.file),
};
const image = PackImageReader.fromPackImage(
getFileNameWithoutExt(data.file.name),
packImage
);
if (!image) return;
handleUploadRemove(data.file);
setUploadedImages((imgs) => [image, ...imgs]);
},
[handleUploadRemove]
);
const handleImageEdit = (shortcode: string) => {
setImagesEditing((shortcodes) => {
const shortcodeSet = new Set(shortcodes);
shortcodeSet.add(shortcode);
return shortcodeSet;
});
};
const handleDeleteToggle = (shortcode: string) => {
setDeleteImages((shortcodes) => {
const shortcodeSet = new Set(shortcodes);
if (shortcodeSet.has(shortcode)) shortcodeSet.delete(shortcode);
else shortcodeSet.add(shortcode);
return shortcodeSet;
});
};
const handleImageEditCancel = (shortcode: string) => {
setImagesEditing((shortcodes) => {
const shortcodeSet = new Set(shortcodes);
shortcodeSet.delete(shortcode);
return shortcodeSet;
});
};
const handleImageEditSave = (shortcode: string, image: PackImageReader) => {
handleImageEditCancel(shortcode);
const saveImage =
shortcode !== image.shortcode && hasImageWithShortcode(image.shortcode)
? new PackImageReader(
suffixRename(image.shortcode, hasImageWithShortcode),
image.url,
image.content
)
: image;
setSavedImages((sImgs) => {
const imgs = new Map(sImgs);
imgs.set(shortcode, saveImage);
return imgs;
});
};
const handleResetSavedChanges = () => {
setSavedMeta(undefined);
setFiles([]);
setUploadedImages([]);
setSavedImages(new Map());
setDeleteImages(new Set());
};
const [applyState, applyChanges] = useAsyncCallback(
useCallback(async () => {
const pack: PackContent = {
pack: savedMeta?.content ?? imagePack.meta.content,
images: {},
};
const pushImage = (img: PackImageReader) => {
if (deleteImages.has(img.shortcode)) return;
if (!pack.images) return;
const imgToPush = savedImages.get(img.shortcode) ?? img;
pack.images[imgToPush.shortcode] = imgToPush.content;
};
uploadedImages.forEach((img) => pushImage(img));
images.forEach((img) => pushImage(img));
return onUpdate?.(pack);
}, [imagePack, images, savedMeta, uploadedImages, savedImages, deleteImages, onUpdate])
);
useEffect(() => {
if (applyState.status === AsyncStatus.Success) {
handleResetSavedChanges();
}
}, [applyState]);
const savedChanges =
(savedMeta && !packMetaEqual(imagePack.meta, savedMeta)) ||
uploadedImages.length > 0 ||
savedImages.size > 0 ||
deleteImages.size > 0;
const canApplyChanges = !metaEditing && imagesEditing.size === 0 && files.length === 0;
const applying = applyState.status === AsyncStatus.Loading;
const renderImage = (image: PackImageReader) => (
<SequenceCard
key={image.shortcode}
style={{ padding: config.space.S300 }}
variant={deleteImages.has(image.shortcode) ? 'Critical' : 'SurfaceVariant'}
direction="Column"
gap="400"
>
{imagesEditing.has(image.shortcode) ? (
<ImageTileEdit
defaultShortcode={image.shortcode}
image={savedImages.get(image.shortcode) ?? image}
packUsage={currentMeta.usage}
useAuthentication={useAuthentication}
onCancel={handleImageEditCancel}
onSave={handleImageEditSave}
/>
) : (
<ImageTile
defaultShortcode={image.shortcode}
image={savedImages.get(image.shortcode) ?? image}
packUsage={currentMeta.usage}
useAuthentication={useAuthentication}
canEdit={canEdit}
onEdit={handleImageEdit}
deleted={deleteImages.has(image.shortcode)}
onDeleteToggle={handleDeleteToggle}
/>
)}
</SequenceCard>
);
return (
<Box grow="Yes" direction="Column" gap="700" {...props} ref={ref}>
{savedChanges && (
<Menu className={css.UnsavedMenu} variant="Success">
<Box alignItems="Center" gap="400">
<Box grow="Yes" direction="Column">
{applyState.status === AsyncStatus.Error ? (
<Text size="T200">
<b>Failed to apply changes! Please try again.</b>
</Text>
) : (
<Text size="T200">
<b>Changes saved! Apply when ready.</b>
</Text>
)}
</Box>
<Box shrink="No" gap="200">
<Button
size="300"
variant="Success"
fill="None"
radii="300"
disabled={!canApplyChanges || applying}
onClick={handleResetSavedChanges}
>
<Text size="B300">Reset</Text>
</Button>
<Button
size="300"
variant="Success"
radii="300"
disabled={!canApplyChanges || applying}
before={applying && <Spinner variant="Success" fill="Solid" size="100" />}
onClick={applyChanges}
>
<Text size="B300">Apply Changes</Text>
</Button>
</Box>
</Box>
</Menu>
)}
<Box direction="Column" gap="100">
<Text size="L400">Pack</Text>
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
{metaEditing ? (
<ImagePackProfileEdit
meta={currentMeta}
onCancel={handleMetaCancel}
onSave={handleMetaSave}
/>
) : (
<ImagePackProfile
meta={currentMeta}
canEdit={canEdit}
onEdit={() => setMetaEditing(true)}
/>
)}
</SequenceCard>
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Images Usage"
description="Select how the images are being used: as emojis, as stickers, or as both."
after={
<UsageSwitcher
usage={currentMeta.usage}
canEdit={canEdit}
onChange={handlePackUsageChange}
/>
}
/>
</SequenceCard>
</Box>
{images.length === 0 && !canEdit ? null : (
<Box direction="Column" gap="100">
<Text size="L400">Images</Text>
{canEdit && (
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Upload Images"
description="Select images from your storage to upload them in pack."
after={
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
type="button"
outlined
onClick={() => pickFiles('image/*')}
>
<Text size="B300">Select</Text>
</Button>
}
/>
</SequenceCard>
)}
{files.map((file) => (
<SequenceCard
key={file.name}
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<ImageTileUpload file={file}>
{(uploadAtom) => (
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleUploadRemove}
onComplete={handleUploadComplete}
/>
)}
</ImageTileUpload>
</SequenceCard>
))}
{uploadedImages.map(renderImage)}
{images.map(renderImage)}
</Box>
)}
</Box>
);
}
);

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds';
import { PackAddress } from '../../plugins/custom-emoji';
import { Page, PageHeader, PageContent } from '../page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomImagePack } from './RoomImagePack';
import { UserImagePack } from './UserImagePack';
type ImagePackViewProps = {
address: PackAddress | undefined;
requestClose: () => void;
};
export function ImagePackView({ address, requestClose }: ImagePackViewProps) {
const mx = useMatrixClient();
const room = address && mx.getRoom(address.roomId);
return (
<Page>
<PageHeader outlined={false} balance>
<Box alignItems="Center" grow="Yes" gap="200">
<Box alignItems="Inherit" grow="Yes" gap="200">
<Chip
size="500"
radii="Pill"
onClick={requestClose}
before={<Icon size="100" src={Icons.ArrowLeft} />}
>
<Text size="T300">Emojis & Stickers</Text>
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
{room && address ? (
<RoomImagePack room={room} stateKey={address.stateKey} />
) : (
<UserImagePack />
)}
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View File

@@ -0,0 +1,214 @@
import React, { FormEventHandler, ReactNode, useMemo, useState } from 'react';
import { Badge, Box, Button, Chip, Icon, Icons, Input, Text } from 'folds';
import { UsageSwitcher, useUsageStr } from './UsageSwitcher';
import { mxcUrlToHttp } from '../../utils/matrix';
import * as css from './style.css';
import { ImageUsage, imageUsageEqual, PackImageReader } from '../../plugins/custom-emoji';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { SettingTile } from '../setting-tile';
import { useObjectURL } from '../../hooks/useObjectURL';
import { createUploadAtom, TUploadAtom } from '../../state/upload';
import { replaceSpaceWithDash } from '../../utils/common';
type ImageTileProps = {
defaultShortcode: string;
useAuthentication: boolean;
packUsage: ImageUsage[];
image: PackImageReader;
canEdit?: boolean;
onEdit?: (defaultShortcode: string, image: PackImageReader) => void;
deleted?: boolean;
onDeleteToggle?: (defaultShortcode: string) => void;
};
export function ImageTile({
defaultShortcode,
image,
packUsage,
useAuthentication,
canEdit,
onEdit,
onDeleteToggle,
deleted,
}: ImageTileProps) {
const mx = useMatrixClient();
const getUsageStr = useUsageStr();
return (
<SettingTile
before={
<img
className={css.ImagePackImage}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''}
alt={image.shortcode}
loading="lazy"
/>
}
title={
deleted ? (
<span className={css.DeleteImageShortcode}>{image.shortcode}</span>
) : (
image.shortcode
)
}
description={
<Box as="span" gap="200">
{image.usage && getUsageStr(image.usage) !== getUsageStr(packUsage) && (
<Badge as="span" variant="Secondary" size="400" radii="300" outlined>
<Text as="span" size="L400">
{getUsageStr(image.usage)}
</Text>
</Badge>
)}
{image.body}
</Box>
}
after={
canEdit ? (
<Box shrink="No" alignItems="Center" gap="200">
<Chip
variant={deleted ? 'Critical' : 'Secondary'}
fill="None"
radii="Pill"
onClick={() => onDeleteToggle?.(defaultShortcode)}
>
{deleted ? <Text size="B300">Undo</Text> : <Icon size="50" src={Icons.Delete} />}
</Chip>
{!deleted && (
<Chip
variant="Secondary"
radii="Pill"
onClick={() => onEdit?.(defaultShortcode, image)}
>
<Text size="B300">Edit</Text>
</Chip>
)}
</Box>
) : undefined
}
/>
);
}
type ImageTileUploadProps = {
file: File;
children: (uploadAtom: TUploadAtom) => ReactNode;
};
export function ImageTileUpload({ file, children }: ImageTileUploadProps) {
const url = useObjectURL(file);
const uploadAtom = useMemo(() => createUploadAtom(file), [file]);
return (
<SettingTile before={<img className={css.ImagePackImage} src={url} alt={file.name} />}>
{children(uploadAtom)}
</SettingTile>
);
}
type ImageTileEditProps = {
defaultShortcode: string;
useAuthentication: boolean;
packUsage: ImageUsage[];
image: PackImageReader;
onCancel: (shortcode: string) => void;
onSave: (shortcode: string, image: PackImageReader) => void;
};
export function ImageTileEdit({
defaultShortcode,
useAuthentication,
packUsage,
image,
onCancel,
onSave,
}: ImageTileEditProps) {
const mx = useMatrixClient();
const defaultUsage = image.usage ?? packUsage;
const [unsavedUsage, setUnsavedUsages] = useState(defaultUsage);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const target = evt.target as HTMLFormElement | undefined;
const shortcodeInput = target?.shortcodeInput as HTMLInputElement | undefined;
const bodyInput = target?.bodyInput as HTMLTextAreaElement | undefined;
if (!shortcodeInput || !bodyInput) return;
const shortcode = replaceSpaceWithDash(shortcodeInput.value.trim());
const body = bodyInput.value.trim() || undefined;
const usage = unsavedUsage;
if (!shortcode) return;
if (
shortcode === image.shortcode &&
body === image.body &&
imageUsageEqual(usage, defaultUsage)
) {
onCancel(defaultShortcode);
return;
}
const imageReader = new PackImageReader(shortcode, image.url, {
info: image.info,
body,
usage: imageUsageEqual(usage, packUsage) ? undefined : usage,
});
onSave(defaultShortcode, imageReader);
};
return (
<SettingTile
before={
<img
className={css.ImagePackImage}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''}
alt={image.shortcode}
loading="lazy"
/>
}
>
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="200">
<Box direction="Column" className={css.ImagePackImageInputs}>
<Input
before={<Text size="L400">Shortcode:</Text>}
defaultValue={image.shortcode}
name="shortcodeInput"
variant="Secondary"
size="300"
radii="0"
required
autoFocus
/>
<Input
before={<Text size="L400">Body:</Text>}
defaultValue={image.body}
name="bodyInput"
variant="Secondary"
size="300"
radii="0"
/>
</Box>
<Box gap="200">
<Box shrink="No" direction="Column">
<UsageSwitcher usage={unsavedUsage} onChange={setUnsavedUsages} canEdit />
</Box>
<Box grow="Yes" />
<Button type="submit" variant="Success" size="300" radii="300">
<Text size="B300">Save</Text>
</Button>
<Button
type="reset"
variant="Secondary"
fill="Soft"
size="300"
radii="300"
onClick={() => onCancel(defaultShortcode)}
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
</SettingTile>
);
}

View File

@@ -0,0 +1,232 @@
import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
import {
Box,
Text,
Avatar,
AvatarImage,
AvatarFallback,
Button,
Icon,
Icons,
Input,
TextArea,
Chip,
} from 'folds';
import Linkify from 'linkify-react';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { nameInitials } from '../../utils/common';
import { BreakWord } from '../../styles/Text.css';
import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { useFilePicker } from '../../hooks/useFilePicker';
import { useObjectURL } from '../../hooks/useObjectURL';
import { createUploadAtom, UploadSuccess } from '../../state/upload';
import { CompactUploadCardRenderer } from '../upload-card';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { PackMetaReader } from '../../plugins/custom-emoji';
type ImagePackAvatarProps = {
url?: string;
name?: string;
};
function ImagePackAvatar({ url, name }: ImagePackAvatarProps) {
return (
<Avatar size="500" className={ContainerColor({ variant: 'Secondary' })}>
{url ? (
<AvatarImage src={url} alt={name ?? 'Unknown'} />
) : (
<AvatarFallback>
<Text size="H2">{nameInitials(name ?? 'Unknown')}</Text>
</AvatarFallback>
)}
</Avatar>
);
}
type ImagePackProfileProps = {
meta: PackMetaReader;
canEdit?: boolean;
onEdit?: () => void;
};
export function ImagePackProfile({ meta, canEdit, onEdit }: ImagePackProfileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const avatarUrl = meta.avatar
? mxcUrlToHttp(mx, meta.avatar, useAuthentication) ?? undefined
: undefined;
return (
<Box gap="400">
<Box grow="Yes" direction="Column" gap="300">
<Box direction="Column" gap="100">
<Text className={BreakWord} size="H5">
{meta.name ?? 'Unknown'}
</Text>
{meta.attribution && (
<Text className={BreakWord} size="T200">
<Linkify options={LINKIFY_OPTS}>{meta.attribution}</Linkify>
</Text>
)}
</Box>
{canEdit && (
<Box gap="200">
<Chip
variant="Secondary"
fill="Soft"
radii="300"
before={<Icon size="50" src={Icons.Pencil} />}
onClick={onEdit}
outlined
>
<Text size="B300">Edit</Text>
</Chip>
</Box>
)}
</Box>
<Box shrink="No">
<ImagePackAvatar url={avatarUrl} name={meta.name} />
</Box>
</Box>
);
}
type ImagePackProfileEditProps = {
meta: PackMetaReader;
onCancel: () => void;
onSave: (meta: PackMetaReader) => void;
};
export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfileEditProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [avatar, setAvatar] = useState(meta.avatar);
const avatarUrl = avatar ? mxcUrlToHttp(mx, avatar, useAuthentication) ?? undefined : undefined;
const [imageFile, setImageFile] = useState<File>();
const avatarFileUrl = useObjectURL(imageFile);
const uploadingAvatar = avatarFileUrl ? avatar === meta.avatar : false;
const uploadAtom = useMemo(() => {
if (imageFile) return createUploadAtom(imageFile);
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
setAvatar(meta.avatar);
}, [meta.avatar]);
const handleUploaded = useCallback((upload: UploadSuccess) => {
setAvatar(upload.mxc);
}, []);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (uploadingAvatar) return;
const target = evt.target as HTMLFormElement | undefined;
const nameInput = target?.nameInput as HTMLInputElement | undefined;
const attributionTextArea = target?.attributionTextArea as HTMLTextAreaElement | undefined;
if (!nameInput || !attributionTextArea) return;
const name = nameInput.value.trim();
const attribution = attributionTextArea.value.trim();
if (!name) return;
const metaReader = new PackMetaReader({
avatar_url: avatar,
display_name: name,
attribution,
});
onSave(metaReader);
};
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
<Box gap="400">
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Pack Avatar</Text>
{uploadAtom ? (
<Box gap="200" direction="Column">
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded}
/>
</Box>
) : (
<Box gap="200">
<Button
type="button"
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => pickFile('image/*')}
>
<Text size="B300">Upload</Text>
</Button>
{!avatar && meta.avatar && (
<Button
type="button"
size="300"
variant="Success"
fill="None"
radii="300"
onClick={() => setAvatar(meta.avatar)}
>
<Text size="B300">Reset</Text>
</Button>
)}
{avatar && (
<Button
type="button"
size="300"
variant="Critical"
fill="None"
radii="300"
onClick={() => setAvatar(undefined)}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
)}
</Box>
<Box shrink="No">
<ImagePackAvatar url={avatarFileUrl ?? avatarUrl} name={meta.name} />
</Box>
</Box>
<Box direction="Inherit" gap="100">
<Text size="L400">Name</Text>
<Input name="nameInput" defaultValue={meta.name} variant="Secondary" radii="300" required />
</Box>
<Box direction="Inherit" gap="100">
<Text size="L400">Attribution</Text>
<TextArea
name="attributionTextArea"
defaultValue={meta.attribution}
variant="Secondary"
radii="300"
/>
</Box>
<Box gap="300">
<Button type="submit" variant="Success" size="300" radii="300" disabled={uploadingAvatar}>
<Text size="B300">Save</Text>
</Button>
<Button
type="reset"
onClick={onCancel}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,55 @@
import React, { useCallback, useMemo } from 'react';
import { Room } from 'matrix-js-sdk';
import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { ImagePackContent } from './ImagePackContent';
import { ImagePack, PackContent } from '../../plugins/custom-emoji';
import { StateEvent } from '../../../types/matrix/room';
import { useRoomImagePack } from '../../hooks/useImagePacks';
import { randomStr } from '../../utils/common';
type RoomImagePackProps = {
room: Room;
stateKey: string;
};
export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const powerLevels = usePowerLevels(room);
const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
const canEditImagePack = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(userId));
const fallbackPack = useMemo(() => {
const fakePackId = randomStr(4);
return new ImagePack(
fakePackId,
{},
{
roomId: room.roomId,
stateKey,
}
);
}, [room.roomId, stateKey]);
const imagePack = useRoomImagePack(room, stateKey) ?? fallbackPack;
const handleUpdate = useCallback(
async (packContent: PackContent) => {
const { address } = imagePack;
if (!address) return;
await mx.sendStateEvent(
address.roomId,
StateEvent.PoniesRoomEmotes,
packContent,
address.stateKey
);
},
[mx, imagePack]
);
return (
<ImagePackContent imagePack={imagePack} canEdit={canEditImagePack} onUpdate={handleUpdate} />
);
}

View File

@@ -0,0 +1,116 @@
import React, { MouseEventHandler, useMemo, useState } from 'react';
import { Box, Button, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import FocusTrap from 'focus-trap-react';
import { ImageUsage } from '../../plugins/custom-emoji';
import { stopPropagation } from '../../utils/keyboard';
export const useUsageStr = (): ((usage: ImageUsage[]) => string) => {
const getUsageStr = (usage: ImageUsage[]): string => {
const sticker = usage.includes(ImageUsage.Sticker);
const emoticon = usage.includes(ImageUsage.Emoticon);
if (sticker && emoticon) return 'Both';
if (sticker) return 'Sticker';
if (emoticon) return 'Emoji';
return 'Both';
};
return getUsageStr;
};
type UsageSelectorProps = {
selected: ImageUsage[];
onChange: (usage: ImageUsage[]) => void;
};
export function UsageSelector({ selected, onChange }: UsageSelectorProps) {
const getUsageStr = useUsageStr();
const selectedUsageStr = getUsageStr(selected);
const isSelected = (usage: ImageUsage[]) => getUsageStr(usage) === selectedUsageStr;
const allUsages: ImageUsage[][] = useMemo(
() => [[ImageUsage.Emoticon], [ImageUsage.Sticker], [ImageUsage.Sticker, ImageUsage.Emoticon]],
[]
);
return (
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{allUsages.map((usage) => (
<MenuItem
key={getUsageStr(usage)}
size="300"
variant={isSelected(usage) ? 'SurfaceVariant' : 'Surface'}
aria-selected={isSelected(usage)}
radii="300"
onClick={() => onChange(usage)}
>
<Box grow="Yes">
<Text size="T300">{getUsageStr(usage)}</Text>
</Box>
</MenuItem>
))}
</Box>
);
}
type UsageSwitcherProps = {
usage: ImageUsage[];
canEdit?: boolean;
onChange: (usage: ImageUsage[]) => void;
};
export function UsageSwitcher({ usage, onChange, canEdit }: UsageSwitcherProps) {
const getUsageStr = useUsageStr();
const [menuCords, setMenuCords] = useState<RectCords>();
const handleSelectUsage: MouseEventHandler<HTMLButtonElement> = (event) => {
setMenuCords(event.currentTarget.getBoundingClientRect());
};
return (
<>
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
type="button"
outlined
aria-disabled={!canEdit}
after={canEdit && <Icon src={Icons.ChevronBottom} size="100" />}
onClick={canEdit ? handleSelectUsage : undefined}
>
<Text size="B300">{getUsageStr(usage)}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<UsageSelector
selected={usage}
onChange={(usg) => {
setMenuCords(undefined);
onChange(usg);
}}
/>
</Menu>
</FocusTrap>
}
/>
</>
);
}

View File

@@ -0,0 +1,22 @@
import React, { useCallback, useMemo } from 'react';
import { ImagePackContent } from './ImagePackContent';
import { ImagePack, PackContent } from '../../plugins/custom-emoji';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { useUserImagePack } from '../../hooks/useImagePacks';
export function UserImagePack() {
const mx = useMatrixClient();
const defaultPack = useMemo(() => new ImagePack(mx.getUserId() ?? '', {}, undefined), [mx]);
const imagePack = useUserImagePack();
const handleUpdate = useCallback(
async (packContent: PackContent) => {
await mx.setAccountData(AccountDataEvent.PoniesUserEmotes, packContent);
},
[mx]
);
return <ImagePackContent imagePack={imagePack ?? defaultPack} canEdit onUpdate={handleUpdate} />;
}

View File

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

View File

@@ -0,0 +1,37 @@
import { style } from '@vanilla-extract/css';
import { color, config, DefaultReset, toRem } from 'folds';
export const ImagePackImage = style([
DefaultReset,
{
width: toRem(36),
height: toRem(36),
objectFit: 'contain',
},
]);
export const DeleteImageShortcode = style([
DefaultReset,
{
color: color.Critical.Main,
textDecoration: 'line-through',
},
]);
export const ImagePackImageInputs = style([
DefaultReset,
{
overflow: 'hidden',
borderRadius: config.radii.R300,
},
]);
export const UnsavedMenu = style({
position: 'sticky',
padding: config.space.S200,
paddingLeft: config.space.S400,
top: config.space.S400,
left: config.space.S400,
right: 0,
zIndex: 1,
});

View File

@@ -0,0 +1,53 @@
import { Box, ContainerColor, Text } from 'folds';
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import { BreakWord } from '../../styles/Text.css';
import { ContainerColor as ContainerClr } from '../../styles/ContainerColor.css';
import * as css from './styles.css';
type InfoCardProps = {
variant?: ContainerColor;
title?: ReactNode;
description?: ReactNode;
before?: ReactNode;
after?: ReactNode;
children?: ReactNode;
};
export function InfoCard({
variant = 'Primary',
title,
description,
before,
after,
children,
}: InfoCardProps) {
return (
<Box
direction="Column"
className={classNames(css.InfoCard, ContainerClr({ variant }))}
gap="300"
>
<Box gap="200" alignItems="Center">
{before && (
<Box shrink="No" alignSelf="Start">
{before}
</Box>
)}
<Box grow="Yes" direction="Column" gap="100">
{title && (
<Text size="L400" className={BreakWord}>
{title}
</Text>
)}
{description && (
<Text size="T200" className={BreakWord}>
{description}
</Text>
)}
</Box>
{after && <Box shrink="No">{after}</Box>}
</Box>
{children}
</Box>
);
}

View File

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

View File

@@ -0,0 +1,10 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
export const InfoCard = style([
{
padding: config.space.S200,
borderRadius: config.radii.R300,
borderWidth: config.borderWidth.B300,
},
]);

View File

@@ -22,6 +22,8 @@ import {
IThumbnailContent, IThumbnailContent,
IVideoContent, IVideoContent,
IVideoInfo, IVideoInfo,
MATRIX_SPOILER_PROPERTY_NAME,
MATRIX_SPOILER_REASON_PROPERTY_NAME,
} from '../../../types/matrix/common'; } from '../../../types/matrix/common';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes'; import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common'; import { parseGeoUri, scaleYDimension } from '../../utils/common';
@@ -172,10 +174,13 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
type RenderImageContentProps = { type RenderImageContentProps = {
body: string; body: string;
filename?: string;
info?: IImageInfo & IThumbnailContent; info?: IImageInfo & IThumbnailContent;
mimeType?: string; mimeType?: string;
url: string; url: string;
encInfo?: IEncryptedFile; encInfo?: IEncryptedFile;
markedAsSpoiler?: boolean;
spoilerReason?: string;
}; };
type MImageProps = { type MImageProps = {
content: IImageContent; content: IImageContent;
@@ -203,6 +208,8 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
mimeType: imgInfo?.mimetype, mimeType: imgInfo?.mimetype,
url: mxcUrl, url: mxcUrl,
encInfo: content.file, encInfo: content.file,
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})} })}
</AttachmentBox> </AttachmentBox>
</Attachment> </Attachment>
@@ -282,7 +289,7 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
return ( return (
<Attachment outlined={outlined}> <Attachment outlined={outlined}>
<AttachmentHeader> <AttachmentHeader>
<FileHeader body={content.body ?? 'Audio'} mimeType={safeMimeType} /> <FileHeader body={content.filename ?? content.body ?? 'Audio'} mimeType={safeMimeType} />
</AttachmentHeader> </AttachmentHeader>
<AttachmentBox> <AttachmentBox>
<AttachmentContent> <AttachmentContent>
@@ -322,14 +329,14 @@ export function MFile({ content, renderFileContent, outlined }: MFileProps) {
<Attachment outlined={outlined}> <Attachment outlined={outlined}>
<AttachmentHeader> <AttachmentHeader>
<FileHeader <FileHeader
body={content.body ?? 'Unnamed File'} body={content.filename ?? content.body ?? 'Unnamed File'}
mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE} mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
/> />
</AttachmentHeader> </AttachmentHeader>
<AttachmentBox> <AttachmentBox>
<AttachmentContent> <AttachmentContent>
{renderFileContent({ {renderFileContent({
body: content.body ?? 'File', body: content.filename ?? content.body ?? 'File',
info: fileInfo ?? {}, info: fileInfo ?? {},
mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE, mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE,
url: mxcUrl, url: mxcUrl,

View File

@@ -1,8 +1,6 @@
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds'; import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk'; import { EventTimelineSet, Room } from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
import React, { MouseEventHandler, ReactNode, useEffect, useMemo, useState } from 'react';
import to from 'await-to-js';
import classNames from 'classnames'; import classNames from 'classnames';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room'; import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
@@ -12,6 +10,7 @@ import { randomNumberBetween } from '../../utils/common';
import * as css from './Reply.css'; import * as css from './Reply.css';
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content'; import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { useRoomEvent } from '../../hooks/useRoomEvent';
type ReplyLayoutProps = { type ReplyLayoutProps = {
userColor?: string; userColor?: string;
@@ -22,7 +21,6 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
<Box <Box
className={classNames(css.Reply, className)} className={classNames(css.Reply, className)}
alignItems="Center" alignItems="Center"
alignSelf="Start"
gap="100" gap="100"
{...props} {...props}
ref={ref} ref={ref}
@@ -39,14 +37,13 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
); );
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => ( export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
<Box className={css.ThreadIndicator} alignItems="Center" alignSelf="Start" {...props} ref={ref}> <Box className={css.ThreadIndicator} alignItems="Center" {...props} ref={ref}>
<Icon className={css.ThreadIndicatorIcon} src={Icons.Message} /> <Icon className={css.ThreadIndicatorIcon} src={Icons.Message} />
<Text size="T200">Threaded reply</Text> <Text size="T200">Threaded reply</Text>
</Box> </Box>
)); ));
type ReplyProps = { type ReplyProps = {
mx: MatrixClient;
room: Room; room: Room;
timelineSet?: EventTimelineSet | undefined; timelineSet?: EventTimelineSet | undefined;
replyEventId: string; replyEventId: string;
@@ -54,78 +51,60 @@ type ReplyProps = {
onClick?: MouseEventHandler | undefined; onClick?: MouseEventHandler | undefined;
}; };
export const Reply = as<'div', ReplyProps>((_, ref) => { export const Reply = as<'div', ReplyProps>(
const { mx, room, timelineSet, replyEventId, threadRootId, onClick, ...props } = _; ({ room, timelineSet, replyEventId, threadRootId, onClick, ...props }, ref) => {
const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>( const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
timelineSet?.findEventById(replyEventId) const getFromLocalTimeline = useCallback(
); () => timelineSet?.findEventById(replyEventId),
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []); [timelineSet, replyEventId]
);
const replyEvent = useRoomEvent(room, replyEventId, getFromLocalTimeline);
const { body } = replyEvent?.getContent() ?? {}; const { body } = replyEvent?.getContent() ?? {};
const sender = replyEvent?.getSender(); const sender = replyEvent?.getSender();
const fallbackBody = replyEvent?.isRedacted() ? ( const fallbackBody = replyEvent?.isRedacted() ? (
<MessageDeletedContent /> <MessageDeletedContent />
) : ( ) : (
<MessageFailedContent /> <MessageFailedContent />
); );
useEffect(() => { const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
let disposed = false; const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
const loadEvent = async () => {
const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, replyEventId));
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, replyEventId]);
const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted'; return (
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody; <Box direction="Column" alignItems="Start" {...props} ref={ref}>
{threadRootId && (
return ( <ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
<Box direction="Column" {...props} ref={ref}>
{threadRootId && (
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
)}
<ReplyLayout
as="button"
userColor={sender ? colorMXID(sender) : undefined}
username={
sender && (
<Text size="T300" truncate>
<b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b>
</Text>
)
}
data-event-id={replyEventId}
onClick={onClick}
>
{replyEvent !== undefined ? (
<Text size="T300" truncate>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
) : (
<LinePlaceholder
style={{
backgroundColor: color.SurfaceVariant.ContainerActive,
maxWidth: toRem(placeholderWidth),
width: '100%',
}}
/>
)} )}
</ReplyLayout> <ReplyLayout
</Box> as="button"
); userColor={sender ? colorMXID(sender) : undefined}
}); username={
sender && (
<Text size="T300" truncate>
<b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b>
</Text>
)
}
data-event-id={replyEventId}
onClick={onClick}
>
{replyEvent !== undefined ? (
<Text size="T300" truncate>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
) : (
<LinePlaceholder
style={{
backgroundColor: color.SurfaceVariant.ContainerActive,
width: toRem(placeholderWidth),
maxWidth: '100%',
}}
/>
)}
</ReplyLayout>
</Box>
);
}
);

View File

@@ -1,6 +1,7 @@
import { Box, Icon, IconSrc } from 'folds'; import { Box, Icon, IconSrc } from 'folds';
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { CompactLayout, ModernLayout } from '..'; import { CompactLayout, ModernLayout } from '..';
import { MessageLayout } from '../../../state/settings';
export type EventContentProps = { export type EventContentProps = {
messageLayout: number; messageLayout: number;
@@ -11,9 +12,9 @@ export type EventContentProps = {
export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) { export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
const beforeJSX = ( const beforeJSX = (
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes"> <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
{messageLayout === 1 && time} {messageLayout === MessageLayout.Compact && time}
<Box <Box
grow={messageLayout === 1 ? undefined : 'Yes'} grow={messageLayout === MessageLayout.Compact ? undefined : 'Yes'}
alignItems="Center" alignItems="Center"
justifyContent="Center" justifyContent="Center"
> >
@@ -25,11 +26,11 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
const msgContentJSX = ( const msgContentJSX = (
<Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200"> <Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200">
{content} {content}
{messageLayout !== 1 && time} {messageLayout !== MessageLayout.Compact && time}
</Box> </Box>
); );
return messageLayout === 1 ? ( return messageLayout === MessageLayout.Compact ? (
<CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout> <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
) : ( ) : (
<ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout> <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>

View File

@@ -27,7 +27,6 @@ import {
getFileNameExt, getFileNameExt,
mimeTypeToExt, mimeTypeToExt,
} from '../../../utils/mimeTypes'; } from '../../../utils/mimeTypes';
import * as css from './style.css';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { import {
decryptFile, decryptFile,
@@ -36,6 +35,7 @@ import {
mxcUrlToHttp, mxcUrlToHttp,
} from '../../../utils/matrix'; } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
const renderErrorButton = (retry: () => void, text: string) => ( const renderErrorButton = (retry: () => void, text: string) => (
<TooltipProvider <TooltipProvider
@@ -111,7 +111,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
}} }}
> >
<Modal <Modal
className={css.ModalWide} className={ModalWide}
size="500" size="500"
onContextMenu={(evt: any) => evt.stopPropagation()} onContextMenu={(evt: any) => evt.stopPropagation()}
> >
@@ -199,7 +199,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
}} }}
> >
<Modal <Modal
className={css.ModalWide} className={ModalWide}
size="500" size="500"
onContextMenu={(evt: any) => evt.stopPropagation()} onContextMenu={(evt: any) => evt.stopPropagation()}
> >

View File

@@ -3,6 +3,7 @@ import {
Badge, Badge,
Box, Box,
Button, Button,
Chip,
Icon, Icon,
Icons, Icons,
Modal, Modal,
@@ -28,6 +29,7 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix'; import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
type RenderViewerProps = { type RenderViewerProps = {
src: string; src: string;
@@ -50,6 +52,8 @@ export type ImageContentProps = {
info?: IImageInfo; info?: IImageInfo;
encInfo?: EncryptedAttachmentInfo; encInfo?: EncryptedAttachmentInfo;
autoPlay?: boolean; autoPlay?: boolean;
markedAsSpoiler?: boolean;
spoilerReason?: string;
renderViewer: (props: RenderViewerProps) => ReactNode; renderViewer: (props: RenderViewerProps) => ReactNode;
renderImage: (props: RenderImageProps) => ReactNode; renderImage: (props: RenderImageProps) => ReactNode;
}; };
@@ -63,6 +67,8 @@ export const ImageContent = as<'div', ImageContentProps>(
info, info,
encInfo, encInfo,
autoPlay, autoPlay,
markedAsSpoiler,
spoilerReason,
renderViewer, renderViewer,
renderImage, renderImage,
...props ...props
@@ -76,6 +82,7 @@ export const ImageContent = as<'div', ImageContentProps>(
const [load, setLoad] = useState(false); const [load, setLoad] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [viewer, setViewer] = useState(false); const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
@@ -121,7 +128,7 @@ export const ImageContent = as<'div', ImageContentProps>(
}} }}
> >
<Modal <Modal
className={css.ModalWide} className={ModalWide}
size="500" size="500"
onContextMenu={(evt: any) => evt.stopPropagation()} onContextMenu={(evt: any) => evt.stopPropagation()}
> >
@@ -144,7 +151,7 @@ export const ImageContent = as<'div', ImageContentProps>(
punch={1} punch={1}
/> />
)} )}
{!autoPlay && srcState.status === AsyncStatus.Idle && ( {!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center"> <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Button <Button
variant="Secondary" variant="Secondary"
@@ -159,7 +166,7 @@ export const ImageContent = as<'div', ImageContentProps>(
</Box> </Box>
)} )}
{srcState.status === AsyncStatus.Success && ( {srcState.status === AsyncStatus.Success && (
<Box className={css.AbsoluteContainer}> <Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
{renderImage({ {renderImage({
alt: body, alt: body,
title: body, title: body,
@@ -171,8 +178,42 @@ export const ImageContent = as<'div', ImageContentProps>(
})} })}
</Box> </Box>
)} )}
{blurred && !error && srcState.status !== AsyncStatus.Error && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<TooltipProvider
tooltip={
typeof spoilerReason === 'string' && (
<Tooltip variant="Secondary">
<Text>{spoilerReason}</Text>
</Tooltip>
)
}
position="Top"
align="Center"
>
{(triggerRef) => (
<Chip
ref={triggerRef}
variant="Secondary"
radii="Pill"
size="500"
outlined
onClick={() => {
setBlurred(false);
if (srcState.status === AsyncStatus.Idle) {
loadSrc();
}
}}
>
<Text size="B300">Spoiler</Text>
</Chip>
)}
</TooltipProvider>
</Box>
)}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) && {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load && ( !load &&
!markedAsSpoiler && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center"> <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" /> <Spinner variant="Secondary" />
</Box> </Box>

View File

@@ -31,7 +31,9 @@ export const AbsoluteFooter = style([
}, },
]); ]);
export const ModalWide = style({ export const Blur = style([
minWidth: '85vw', DefaultReset,
minHeight: '90vh', {
}); filter: 'blur(44px)',
},
]);

View File

@@ -1,22 +1,27 @@
import React from 'react'; import React, { useMemo } from 'react';
import { as, toRem } from 'folds'; import { as, ContainerColor, toRem } from 'folds';
import { randomNumberBetween } from '../../../utils/common'; import { randomNumberBetween } from '../../../utils/common';
import { LinePlaceholder } from './LinePlaceholder'; import { LinePlaceholder } from './LinePlaceholder';
import { CompactLayout, MessageBase } from '../layout'; import { CompactLayout } from '../layout';
export const CompactPlaceholder = as<'div'>(({ ...props }, ref) => ( export const CompactPlaceholder = as<'div', { variant?: ContainerColor }>(
<MessageBase> ({ variant, ...props }, ref) => {
<CompactLayout const nameSize = useMemo(() => randomNumberBetween(40, 100), []);
{...props} const msgSize = useMemo(() => randomNumberBetween(120, 500), []);
ref={ref}
before={ return (
<> <CompactLayout
<LinePlaceholder style={{ maxWidth: toRem(50) }} /> {...props}
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(40, 100)) }} /> ref={ref}
</> before={
} <>
> <LinePlaceholder variant={variant} style={{ maxWidth: toRem(50) }} />
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(120, 500)) }} /> <LinePlaceholder variant={variant} style={{ maxWidth: toRem(nameSize) }} />
</CompactLayout> </>
</MessageBase> }
)); >
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(msgSize) }} />
</CompactLayout>
);
}
);

View File

@@ -1,25 +1,39 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties, useMemo } from 'react';
import { Avatar, Box, as, color, toRem } from 'folds'; import { Avatar, Box, ContainerColor, as, color, toRem } from 'folds';
import { randomNumberBetween } from '../../../utils/common'; import { randomNumberBetween } from '../../../utils/common';
import { LinePlaceholder } from './LinePlaceholder'; import { LinePlaceholder } from './LinePlaceholder';
import { MessageBase, ModernLayout } from '../layout'; import { ModernLayout } from '../layout';
const contentMargin: CSSProperties = { marginTop: toRem(3) }; const contentMargin: CSSProperties = { marginTop: toRem(3) };
const avatarBg: CSSProperties = { backgroundColor: color.SurfaceVariant.Container };
export const DefaultPlaceholder = as<'div'>(({ ...props }, ref) => ( export const DefaultPlaceholder = as<'div', { variant?: ContainerColor }>(
<MessageBase> ({ variant, ...props }, ref) => {
<ModernLayout {...props} ref={ref} before={<Avatar style={avatarBg} size="300" />}> const nameSize = useMemo(() => randomNumberBetween(40, 100), []);
<Box style={contentMargin} grow="Yes" direction="Column" gap="200"> const msgSize = useMemo(() => randomNumberBetween(80, 200), []);
<Box grow="Yes" gap="200" alignItems="Center" justifyContent="SpaceBetween"> const msg2Size = useMemo(() => randomNumberBetween(80, 200), []);
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(40, 100)) }} />
<LinePlaceholder style={{ maxWidth: toRem(50) }} /> return (
<ModernLayout
{...props}
ref={ref}
before={
<Avatar
style={{ backgroundColor: color[variant ?? 'SurfaceVariant'].Container }}
size="300"
/>
}
>
<Box style={contentMargin} grow="Yes" direction="Column" gap="200">
<Box grow="Yes" gap="200" alignItems="Center" justifyContent="SpaceBetween">
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(nameSize) }} />
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(50) }} />
</Box>
<Box grow="Yes" gap="200" wrap="Wrap">
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(msgSize) }} />
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(msg2Size) }} />
</Box>
</Box> </Box>
<Box grow="Yes" gap="200" wrap="Wrap"> </ModernLayout>
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(80, 200)) }} /> );
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(80, 200)) }} /> }
</Box> );
</Box>
</ModernLayout>
</MessageBase>
));

View File

@@ -1,12 +1,35 @@
import { style } from '@vanilla-extract/css'; import { ComplexStyleRule } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds'; import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { ContainerColor, DefaultReset, color, config, toRem } from 'folds';
export const LinePlaceholder = style([ const getVariant = (variant: ContainerColor): ComplexStyleRule => ({
DefaultReset, backgroundColor: color[variant].Container,
{ });
width: '100%',
height: toRem(16), export const LinePlaceholder = recipe({
borderRadius: config.radii.R300, base: [
backgroundColor: color.SurfaceVariant.Container, DefaultReset,
{
width: '100%',
height: toRem(16),
borderRadius: config.radii.R300,
},
],
variants: {
variant: {
Background: getVariant('Background'),
Surface: getVariant('Surface'),
SurfaceVariant: getVariant('SurfaceVariant'),
Primary: getVariant('Primary'),
Secondary: getVariant('Secondary'),
Success: getVariant('Success'),
Warning: getVariant('Warning'),
Critical: getVariant('Critical'),
},
}, },
]); defaultVariants: {
variant: 'SurfaceVariant',
},
});
export type LinePlaceholderVariants = RecipeVariants<typeof LinePlaceholder>;

View File

@@ -3,6 +3,13 @@ import { Box, as } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import * as css from './LinePlaceholder.css'; import * as css from './LinePlaceholder.css';
export const LinePlaceholder = as<'div'>(({ className, ...props }, ref) => ( export const LinePlaceholder = as<'div', css.LinePlaceholderVariants>(
<Box className={classNames(css.LinePlaceholder, className)} shrink="No" {...props} ref={ref} /> ({ className, variant, ...props }, ref) => (
)); <Box
className={classNames(css.LinePlaceholder({ variant }), className)}
shrink="No"
{...props}
ref={ref}
/>
)
);

View File

@@ -27,14 +27,14 @@ export function PageRoot({ nav, children }: PageRootProps) {
type ClientDrawerLayoutProps = { type ClientDrawerLayoutProps = {
children: ReactNode; children: ReactNode;
}; };
export function PageNav({ children }: ClientDrawerLayoutProps) { export function PageNav({ size, children }: ClientDrawerLayoutProps & css.PageNavVariants) {
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile; const isMobile = screenSize === ScreenSize.Mobile;
return ( return (
<Box <Box
grow={isMobile ? 'Yes' : undefined} grow={isMobile ? 'Yes' : undefined}
className={css.PageNav} className={css.PageNav({ size })}
shrink={isMobile ? 'Yes' : 'No'} shrink={isMobile ? 'Yes' : 'No'}
> >
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
@@ -44,15 +44,17 @@ export function PageNav({ children }: ClientDrawerLayoutProps) {
); );
} }
export const PageNavHeader = as<'header'>(({ className, ...props }, ref) => ( export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
<Header ({ className, outlined, ...props }, ref) => (
className={classNames(css.PageNavHeader, className)} <Header
variant="Background" className={classNames(css.PageNavHeader({ outlined }), className)}
size="600" variant="Background"
{...props} size="600"
ref={ref} {...props}
/> ref={ref}
)); />
)
);
export function PageNavContent({ export function PageNavContent({
scrollRef, scrollRef,
@@ -88,11 +90,11 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
)); ));
export const PageHeader = as<'div', css.PageHeaderVariants>( export const PageHeader = as<'div', css.PageHeaderVariants>(
({ className, balance, ...props }, ref) => ( ({ className, outlined, balance, ...props }, ref) => (
<Header <Header
as="header" as="header"
size="600" size="600"
className={classNames(css.PageHeader({ balance }), className)} className={classNames(css.PageHeader({ balance, outlined }), className)}
{...props} {...props}
ref={ref} ref={ref}
/> />

View File

@@ -2,30 +2,55 @@ import { style } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds'; import { DefaultReset, color, config, toRem } from 'folds';
export const PageNav = style({ export const PageNav = recipe({
width: toRem(256), variants: {
}); size: {
'400': {
export const PageNavHeader = style({ width: toRem(256),
padding: `0 ${config.space.S200} 0 ${config.space.S300}`, },
flexShrink: 0, '300': {
borderBottomWidth: 1, width: toRem(222),
},
selectors: {
'button&': {
cursor: 'pointer',
},
'button&[aria-pressed=true]': {
backgroundColor: color.Background.ContainerActive,
},
'button&:hover, button&:focus-visible': {
backgroundColor: color.Background.ContainerHover,
},
'button&:active': {
backgroundColor: color.Background.ContainerActive,
}, },
}, },
defaultVariants: {
size: '400',
},
}); });
export type PageNavVariants = RecipeVariants<typeof PageNav>;
export const PageNavHeader = recipe({
base: {
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
flexShrink: 0,
selectors: {
'button&': {
cursor: 'pointer',
},
'button&[aria-pressed=true]': {
backgroundColor: color.Background.ContainerActive,
},
'button&:hover, button&:focus-visible': {
backgroundColor: color.Background.ContainerHover,
},
'button&:active': {
backgroundColor: color.Background.ContainerActive,
},
},
},
variants: {
outlined: {
true: {
borderBottomWidth: 1,
},
},
},
defaultVariants: {
outlined: true,
},
});
export type PageNavHeaderVariants = RecipeVariants<typeof PageNavHeader>;
export const PageNavContent = style({ export const PageNavContent = style({
minHeight: '100%', minHeight: '100%',
@@ -38,7 +63,6 @@ export const PageHeader = recipe({
base: { base: {
paddingLeft: config.space.S400, paddingLeft: config.space.S400,
paddingRight: config.space.S200, paddingRight: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
}, },
variants: { variants: {
balance: { balance: {
@@ -46,6 +70,14 @@ export const PageHeader = recipe({
paddingLeft: config.space.S200, paddingLeft: config.space.S200,
}, },
}, },
outlined: {
true: {
borderBottomWidth: config.borderWidth.B300,
},
},
},
defaultVariants: {
outlined: true,
}, },
}); });
export type PageHeaderVariants = RecipeVariants<typeof PageHeader>; export type PageHeaderVariants = RecipeVariants<typeof PageHeader>;

View File

@@ -6,7 +6,7 @@ type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> &
size: '400' | '500'; size: '400' | '500';
}; };
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>( export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
({ variant, size, style, after, ...props }, ref) => { ({ variant = 'Background', size, style, after, ...props }, ref) => {
const paddingRight: string = size === '500' ? config.space.S300 : config.space.S200; const paddingRight: string = size === '500' ? config.space.S300 : config.space.S200;
return ( return (

View File

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

View File

@@ -0,0 +1,32 @@
import React, { ReactNode } from 'react';
import { Box, Text } from 'folds';
import { BreakWord } from '../../styles/Text.css';
type SettingTileProps = {
title?: ReactNode;
description?: ReactNode;
before?: ReactNode;
after?: ReactNode;
children?: ReactNode;
};
export function SettingTile({ title, description, before, after, children }: SettingTileProps) {
return (
<Box alignItems="Center" gap="300">
{before && <Box shrink="No">{before}</Box>}
<Box grow="Yes" direction="Column" gap="100">
{title && (
<Text className={BreakWord} size="T300">
{title}
</Text>
)}
{description && (
<Text className={BreakWord} size="T200" priority="300">
{description}
</Text>
)}
{children}
</Box>
{after && <Box shrink="No">{after}</Box>}
</Box>
);
}

View File

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

View File

@@ -31,7 +31,11 @@ export const TextViewerContent = style([
export const TextViewerPre = style([ export const TextViewerPre = style([
DefaultReset, DefaultReset,
{ {
padding: config.space.S600,
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}, },
]); ]);
export const TextViewerPrePadding = style({
padding: config.space.S600,
});

View File

@@ -1,5 +1,5 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React, { Suspense, lazy } from 'react'; import React, { ComponentProps, HTMLAttributes, Suspense, forwardRef, lazy } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'folds'; import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'folds';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
@@ -8,6 +8,29 @@ import { copyToClipboard } from '../../utils/dom';
const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism')); const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
type TextViewerContentProps = {
text: string;
langName: string;
size?: ComponentProps<typeof Text>['size'];
} & HTMLAttributes<HTMLPreElement>;
export const TextViewerContent = forwardRef<HTMLPreElement, TextViewerContentProps>(
({ text, langName, size, className, ...props }, ref) => (
<Text
as="pre"
size={size}
className={classNames(css.TextViewerPre, `language-${langName}`, className)}
{...props}
ref={ref}
>
<ErrorBoundary fallback={<code>{text}</code>}>
<Suspense fallback={<code>{text}</code>}>
<ReactPrism>{(codeRef) => <code ref={codeRef}>{text}</code>}</ReactPrism>
</Suspense>
</ErrorBoundary>
</Text>
)
);
export type TextViewerProps = { export type TextViewerProps = {
name: string; name: string;
text: string; text: string;
@@ -43,6 +66,7 @@ export const TextViewer = as<'div', TextViewerProps>(
</Chip> </Chip>
</Box> </Box>
</Header> </Header>
<Box <Box
grow="Yes" grow="Yes"
className={css.TextViewerContent} className={css.TextViewerContent}
@@ -50,13 +74,11 @@ export const TextViewer = as<'div', TextViewerProps>(
alignItems="Center" alignItems="Center"
> >
<Scroll hideTrack variant="Background" visibility="Hover"> <Scroll hideTrack variant="Background" visibility="Hover">
<Text as="pre" className={classNames(css.TextViewerPre, `language-${langName}`)}> <TextViewerContent
<ErrorBoundary fallback={<code>{text}</code>}> className={css.TextViewerPrePadding}
<Suspense fallback={<code>{text}</code>}> text={text}
<ReactPrism>{(codeRef) => <code ref={codeRef}>{text}</code>}</ReactPrism> langName={langName}
</Suspense> />
</ErrorBoundary>
</Text>
</Scroll> </Scroll>
</Box> </Box>
</Box> </Box>

View File

@@ -0,0 +1,89 @@
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
import React, { FormEventHandler } from 'react';
import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types';
import { ErrorCode } from '../../cs-errorcode';
import { PasswordInput } from '../password-input';
export function PasswordStage({
stageData,
submitAuthDict,
onCancel,
userId,
}: StageComponentProps & {
userId: string;
}) {
const { errorCode, error, session } = stageData;
const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const { passwordInput } = evt.target as HTMLFormElement & {
passwordInput: HTMLInputElement;
};
const password = passwordInput.value;
if (!password) return;
submitAuthDict({
type: AuthType.Password,
identifier: {
type: 'm.id.user',
user: userId,
},
password,
session,
});
};
return (
<Dialog>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Account Password</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
as="form"
onSubmit={handleFormSubmit}
style={{ padding: `0 ${config.space.S400} ${config.space.S400}` }}
direction="Column"
gap="400"
>
<Box direction="Column" gap="400">
<Text size="T200">
To perform this action you need to authenticate yourself by entering you account
password.
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Password</Text>
<PasswordInput size="400" name="passwordInput" outlined autoFocus required />
{errorCode && (
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
<Icon size="50" src={Icons.Warning} filled />
<Text size="T200">
<b>
{errorCode === ErrorCode.M_FORBIDDEN
? 'Invalid Password!'
: `${errorCode}: ${error}`}
</b>
</Text>
</Box>
)}
</Box>
</Box>
<Button variant="Primary" type="submit">
<Text as="span" size="B400">
Continue
</Text>
</Button>
</Box>
</Dialog>
);
}

View File

@@ -0,0 +1,91 @@
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
import React, { useCallback, useEffect, useState } from 'react';
import { StageComponentProps } from './types';
export function SSOStage({
ssoRedirectURL,
stageData,
submitAuthDict,
onCancel,
}: StageComponentProps & {
ssoRedirectURL: string;
}) {
const { errorCode, error, session } = stageData;
const [ssoWindow, setSSOWindow] = useState<Window>();
const handleSubmit = useCallback(() => {
submitAuthDict({
session,
});
}, [submitAuthDict, session]);
const handleContinue = () => {
const w = window.open(ssoRedirectURL, '_blank');
setSSOWindow(w ?? undefined);
};
useEffect(() => {
const handleMessage = (evt: MessageEvent) => {
if (ssoWindow && evt.data === 'authDone' && evt.source === ssoWindow) {
ssoWindow.close();
setSSOWindow(undefined);
handleSubmit();
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [ssoWindow, handleSubmit]);
return (
<Dialog>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">SSO Login</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
style={{ padding: `0 ${config.space.S400} ${config.space.S400}` }}
direction="Column"
gap="400"
>
<Text size="T200">
To perform this action you need to authenticate yourself by SSO login.
</Text>
{errorCode && (
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
<Icon size="50" src={Icons.Warning} filled />
<Text size="T200">
<b>{`${errorCode}: ${error}`}</b>
</Text>
</Box>
)}
{ssoWindow ? (
<Button variant="Primary" onClick={handleSubmit}>
<Text as="span" size="B400">
Continue
</Text>
</Button>
) : (
<Button variant="Primary" onClick={handleContinue}>
<Text as="span" size="B400">
Continue with SSO
</Text>
</Button>
)}
</Box>
</Dialog>
);
}

View File

@@ -1,6 +1,8 @@
export * from './types'; export * from './types';
export * from './DummyStage'; export * from './DummyStage';
export * from './EmailStage'; export * from './EmailStage';
export * from './PasswordStage';
export * from './ReCaptchaStage'; export * from './ReCaptchaStage';
export * from './RegistrationTokenStage'; export * from './RegistrationTokenStage';
export * from './SSOStage';
export * from './TermsStage'; export * from './TermsStage';

View File

@@ -0,0 +1,94 @@
import React, { useEffect } from 'react';
import { Chip, Icon, IconButton, Icons, Text, color } from 'folds';
import { UploadCard, UploadCardError, CompactUploadCardProgress } from './UploadCard';
import { TUploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { TUploadContent } from '../../utils/matrix';
import { getFileTypeIcon } from '../../utils/common';
type CompactUploadCardRendererProps = {
isEncrypted?: boolean;
uploadAtom: TUploadAtom;
onRemove: (file: TUploadContent) => void;
onComplete?: (upload: UploadSuccess) => void;
};
export function CompactUploadCardRenderer({
isEncrypted,
uploadAtom,
onRemove,
onComplete,
}: CompactUploadCardRendererProps) {
const mx = useMatrixClient();
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
const { file } = upload;
if (upload.status === UploadStatus.Idle) startUpload();
const removeUpload = () => {
cancelUpload();
onRemove(file);
};
useEffect(() => {
if (upload.status === UploadStatus.Success) {
onComplete?.(upload);
}
}, [upload, onComplete]);
return (
<UploadCard
compact
outlined
radii="300"
before={<Icon src={getFileTypeIcon(Icons, file.type)} />}
after={
<>
{upload.status === UploadStatus.Error && (
<Chip
as="button"
onClick={startUpload}
aria-label="Retry Upload"
variant="Critical"
radii="Pill"
outlined
>
<Text size="B300">Retry</Text>
</Chip>
)}
<IconButton
onClick={removeUpload}
aria-label="Cancel Upload"
variant="SurfaceVariant"
radii="Pill"
size="300"
>
<Icon src={Icons.Cross} size="200" />
</IconButton>
</>
}
>
{upload.status === UploadStatus.Success ? (
<>
<Text size="H6" truncate>
{file.name}
</Text>
<Icon style={{ color: color.Success.Main }} src={Icons.Check} size="100" />
</>
) : (
<>
{upload.status === UploadStatus.Idle && (
<CompactUploadCardProgress sentBytes={0} totalBytes={file.size} />
)}
{upload.status === UploadStatus.Loading && (
<CompactUploadCardProgress sentBytes={upload.progress.loaded} totalBytes={file.size} />
)}
{upload.status === UploadStatus.Error && (
<UploadCardError>
<Text size="T200">{upload.error.message}</Text>
</UploadCardError>
)}
</>
)}
</UploadCard>
);
}

View File

@@ -7,9 +7,21 @@ export const UploadCard = recipe({
padding: config.space.S300, padding: config.space.S300,
backgroundColor: color.SurfaceVariant.Container, backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer, color: color.SurfaceVariant.OnContainer,
borderColor: color.SurfaceVariant.ContainerLine,
}, },
variants: { variants: {
radii: RadiiVariant, radii: RadiiVariant,
outlined: {
true: {
borderStyle: 'solid',
borderWidth: config.borderWidth.B300,
},
},
compact: {
true: {
padding: config.space.S100,
},
},
}, },
defaultVariants: { defaultVariants: {
radii: '400', radii: '400',

View File

@@ -12,8 +12,13 @@ type UploadCardProps = {
}; };
export const UploadCard = forwardRef<HTMLDivElement, UploadCardProps & css.UploadCardVariant>( export const UploadCard = forwardRef<HTMLDivElement, UploadCardProps & css.UploadCardVariant>(
({ before, after, children, bottom, radii }, ref) => ( ({ before, after, children, bottom, radii, outlined, compact }, ref) => (
<Box className={css.UploadCard({ radii })} direction="Column" gap="200" ref={ref}> <Box
className={css.UploadCard({ radii, outlined, compact })}
direction="Column"
gap="200"
ref={ref}
>
<Box alignItems="Center" gap="200"> <Box alignItems="Center" gap="200">
{before} {before}
<Box alignItems="Center" grow="Yes" gap="200"> <Box alignItems="Center" grow="Yes" gap="200">
@@ -33,7 +38,7 @@ type UploadCardProgressProps = {
export function UploadCardProgress({ sentBytes, totalBytes }: UploadCardProgressProps) { export function UploadCardProgress({ sentBytes, totalBytes }: UploadCardProgressProps) {
return ( return (
<Box direction="Column" gap="200"> <Box grow="Yes" direction="Column" gap="200">
<ProgressBar variant="Secondary" size="300" min={0} max={totalBytes} value={sentBytes} /> <ProgressBar variant="Secondary" size="300" min={0} max={totalBytes} value={sentBytes} />
<Box alignItems="Center" justifyContent="SpaceBetween"> <Box alignItems="Center" justifyContent="SpaceBetween">
<Badge variant="Secondary" fill="Solid" radii="Pill"> <Badge variant="Secondary" fill="Solid" radii="Pill">
@@ -49,6 +54,24 @@ export function UploadCardProgress({ sentBytes, totalBytes }: UploadCardProgress
); );
} }
export function CompactUploadCardProgress({ sentBytes, totalBytes }: UploadCardProgressProps) {
return (
<Box grow="Yes" gap="200" alignItems="Center">
<Badge variant="Secondary" fill="Solid" radii="Pill">
<Text size="L400">{`${Math.round(percent(0, totalBytes, sentBytes))}%`}</Text>
</Badge>
<Box grow="Yes" direction="Column">
<ProgressBar variant="Secondary" size="300" min={0} max={totalBytes} value={sentBytes} />
</Box>
<Badge variant="Secondary" fill="Soft" radii="Pill">
<Text size="L400">
{bytesToSize(sentBytes)} / {bytesToSize(totalBytes)}
</Text>
</Badge>
</Box>
);
}
type UploadCardErrorProps = { type UploadCardErrorProps = {
children: ReactNode; children: ReactNode;
}; };

View File

@@ -1,38 +1,102 @@
import React from 'react'; import React, { useEffect } from 'react';
import { Chip, Icon, IconButton, Icons, Text, color } from 'folds'; import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard'; import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
import { TUploadAtom, UploadStatus, useBindUploadAtom } from '../../state/upload'; import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { TUploadContent } from '../../utils/matrix'; import { TUploadContent } from '../../utils/matrix';
import { getFileTypeIcon } from '../../utils/common'; import { getFileTypeIcon } from '../../utils/common';
import {
roomUploadAtomFamily,
TUploadItem,
TUploadMetadata,
} from '../../state/room/roomInputDrafts';
import { useObjectURL } from '../../hooks/useObjectURL';
type ImagePreviewProps = { fileItem: TUploadItem; onSpoiler: (marked: boolean) => void };
function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
const { originalFile, metadata } = fileItem;
const fileUrl = useObjectURL(originalFile);
return fileUrl ? (
<Box
style={{
borderRadius: config.radii.R300,
overflow: 'hidden',
backgroundColor: 'black',
position: 'relative',
}}
>
<img
style={{
objectFit: 'contain',
width: '100%',
height: toRem(152),
filter: fileItem.metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
}}
src={fileUrl}
alt={originalFile.name}
/>
<Box
justifyContent="End"
style={{
position: 'absolute',
bottom: config.space.S100,
left: config.space.S100,
right: config.space.S100,
}}
>
<Chip
variant={metadata.markedAsSpoiler ? 'Warning' : 'Secondary'}
fill="Soft"
radii="Pill"
aria-pressed={metadata.markedAsSpoiler}
before={<Icon src={Icons.EyeBlind} size="50" />}
onClick={() => onSpoiler(!metadata.markedAsSpoiler)}
>
<Text size="B300">Spoiler</Text>
</Chip>
</Box>
</Box>
) : null;
}
type UploadCardRendererProps = { type UploadCardRendererProps = {
file: TUploadContent;
isEncrypted?: boolean; isEncrypted?: boolean;
uploadAtom: TUploadAtom; fileItem: TUploadItem;
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
onRemove: (file: TUploadContent) => void; onRemove: (file: TUploadContent) => void;
onComplete?: (upload: UploadSuccess) => void;
}; };
export function UploadCardRenderer({ export function UploadCardRenderer({
file,
isEncrypted, isEncrypted,
uploadAtom, fileItem,
setMetadata,
onRemove, onRemove,
onComplete,
}: UploadCardRendererProps) { }: UploadCardRendererProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const { upload, startUpload, cancelUpload } = useBindUploadAtom( const uploadAtom = roomUploadAtomFamily(fileItem.file);
mx, const { metadata } = fileItem;
file, const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
uploadAtom, const { file } = upload;
isEncrypted
);
if (upload.status === UploadStatus.Idle) startUpload(); if (upload.status === UploadStatus.Idle) startUpload();
const handleSpoiler = (marked: boolean) => {
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
};
const removeUpload = () => { const removeUpload = () => {
cancelUpload(); cancelUpload();
onRemove(file); onRemove(file);
}; };
useEffect(() => {
if (upload.status === UploadStatus.Success) {
onComplete?.(upload);
}
}, [upload, onComplete]);
return ( return (
<UploadCard <UploadCard
radii="300" radii="300"
@@ -64,6 +128,9 @@ export function UploadCardRenderer({
} }
bottom={ bottom={
<> <>
{fileItem.originalFile.type.startsWith('image') && (
<ImagePreview fileItem={fileItem} onSpoiler={handleSpoiler} />
)}
{upload.status === UploadStatus.Idle && ( {upload.status === UploadStatus.Idle && (
<UploadCardProgress sentBytes={0} totalBytes={file.size} /> <UploadCardProgress sentBytes={0} totalBytes={file.size} />
)} )}

View File

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

View File

@@ -20,8 +20,7 @@ export const UrlPreviewImg = style([
width: toRem(100), width: toRem(100),
height: toRem(100), height: toRem(100),
objectFit: 'cover', objectFit: 'cover',
objectPosition: 'left', objectPosition: 'center',
backgroundPosition: 'start',
flexShrink: 0, flexShrink: 0,
overflow: 'hidden', overflow: 'hidden',
}, },

View File

@@ -155,7 +155,7 @@ function SettingsMenuItem({
disabled?: boolean; disabled?: boolean;
}) { }) {
const handleSettings = () => { const handleSettings = () => {
if (item.space) { if ('space' in item) {
openSpaceSettings(item.roomId); openSpaceSettings(item.roomId);
} else { } else {
toggleRoomSettings(item.roomId); toggleRoomSettings(item.roomId);
@@ -271,7 +271,7 @@ export function HierarchyItemMenu({
</Text> </Text>
</MenuItem> </MenuItem>
{promptLeave && {promptLeave &&
(item.space ? ( ('space' in item ? (
<LeaveSpacePrompt <LeaveSpacePrompt
roomId={item.roomId} roomId={item.roomId}
onDone={handleRequestClose} onDone={handleRequestClose}

View File

@@ -3,10 +3,17 @@ import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { IJoinRuleEventContent, JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk'; import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import produce from 'immer';
import { useSpace } from '../../hooks/useSpace'; import { useSpace } from '../../hooks/useSpace';
import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page'; import { Page, PageContent, PageContentCenter, PageHeroSection } from '../../components/page';
import { HierarchyItem, useSpaceHierarchy } from '../../hooks/useSpaceHierarchy'; import {
HierarchyItem,
HierarchyItemSpace,
useSpaceHierarchy,
} from '../../hooks/useSpaceHierarchy';
import { VirtualTile } from '../../components/virtualizer'; import { VirtualTile } from '../../components/virtualizer';
import { spaceRoomsAtom } from '../../state/spaceRooms'; import { spaceRoomsAtom } from '../../state/spaceRooms';
import { MembersDrawer } from '../room/MembersDrawer'; import { MembersDrawer } from '../room/MembersDrawer';
@@ -24,18 +31,15 @@ import {
usePowerLevels, usePowerLevels,
useRoomsPowerLevels, useRoomsPowerLevels,
} from '../../hooks/usePowerLevels'; } from '../../hooks/usePowerLevels';
import { RoomItemCard } from './RoomItem';
import { mDirectAtom } from '../../state/mDirectList'; import { mDirectAtom } from '../../state/mDirectList';
import { SpaceItemCard } from './SpaceItem';
import { makeLobbyCategoryId } from '../../state/closedLobbyCategories'; import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
import { useCategoryHandler } from '../../hooks/useCategoryHandler'; import { useCategoryHandler } from '../../hooks/useCategoryHandler';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList'; import { allRoomsAtom } from '../../state/room-list/roomList';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { getSpaceRoomPath } from '../../pages/pathUtils'; import { getSpaceRoomPath } from '../../pages/pathUtils';
import { HierarchyItemMenu } from './HierarchyItemMenu';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { AfterItemDropTarget, CanDropCallback, useDnDMonitor } from './DnD'; import { CanDropCallback, useDnDMonitor } from './DnD';
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable'; import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
import { getStateEvent } from '../../utils/room'; import { getStateEvent } from '../../utils/room';
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories'; import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
@@ -48,6 +52,7 @@ import { useOrphanSpaces } from '../../state/hooks/roomList';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { AccountDataEvent } from '../../../types/matrix/accountData'; import { AccountDataEvent } from '../../../types/matrix/accountData';
import { useRoomMembers } from '../../hooks/useRoomMembers'; import { useRoomMembers } from '../../hooks/useRoomMembers';
import { SpaceHierarchy } from './SpaceHierarchy';
export function Lobby() { export function Lobby() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -80,6 +85,8 @@ export function Lobby() {
return new Set(sideSpaces); return new Set(sideSpaces);
}, [sidebarItems]); }, [sidebarItems]);
const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => new Map());
useElementSizeObserver( useElementSizeObserver(
useCallback(() => heroSectionRef.current, []), useCallback(() => heroSectionRef.current, []),
useCallback((w, height) => setHeroSectionHeight(height), []) useCallback((w, height) => setHeroSectionHeight(height), [])
@@ -106,19 +113,20 @@ export function Lobby() {
); );
const [draggingItem, setDraggingItem] = useState<HierarchyItem>(); const [draggingItem, setDraggingItem] = useState<HierarchyItem>();
const flattenHierarchy = useSpaceHierarchy( const hierarchy = useSpaceHierarchy(
space.roomId, space.roomId,
spaceRooms, spaceRooms,
getRoom, getRoom,
useCallback( useCallback(
(childId) => (childId) =>
closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || !!draggingItem?.space, closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) ||
(draggingItem ? 'space' in draggingItem : false),
[closedCategories, space.roomId, draggingItem] [closedCategories, space.roomId, draggingItem]
) )
); );
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: flattenHierarchy.length, count: hierarchy.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
estimateSize: () => 1, estimateSize: () => 1,
overscan: 2, overscan: 2,
@@ -128,8 +136,17 @@ export function Lobby() {
const roomsPowerLevels = useRoomsPowerLevels( const roomsPowerLevels = useRoomsPowerLevels(
useMemo( useMemo(
() => flattenHierarchy.map((i) => mx.getRoom(i.roomId)).filter((r) => !!r) as Room[], () =>
[mx, flattenHierarchy] hierarchy
.flatMap((i) => {
const childRooms = Array.isArray(i.rooms)
? i.rooms.map((r) => mx.getRoom(r.roomId))
: [];
return [mx.getRoom(i.space.roomId), ...childRooms];
})
.filter((r) => !!r) as Room[],
[mx, hierarchy]
) )
); );
@@ -141,8 +158,8 @@ export function Lobby() {
return false; return false;
} }
if (item.space) { if ('space' in item) {
if (!container.item.space) return false; if (!('space' in container.item)) return false;
const containerSpaceId = space.roomId; const containerSpaceId = space.roomId;
if ( if (
@@ -155,9 +172,8 @@ export function Lobby() {
return true; return true;
} }
const containerSpaceId = container.item.space const containerSpaceId =
? container.item.roomId 'space' in container.item ? container.item.roomId : container.item.parentId;
: container.item.parentId;
const dropOutsideSpace = item.parentId !== containerSpaceId; const dropOutsideSpace = item.parentId !== containerSpaceId;
@@ -191,22 +207,22 @@ export function Lobby() {
); );
const reorderSpace = useCallback( const reorderSpace = useCallback(
(item: HierarchyItem, containerItem: HierarchyItem) => { (item: HierarchyItemSpace, containerItem: HierarchyItem) => {
if (!item.parentId) return; if (!item.parentId) return;
const childItems = flattenHierarchy const itemSpaces: HierarchyItemSpace[] = hierarchy
.filter((i) => i.parentId && i.space) .map((i) => i.space)
.filter((i) => i.roomId !== item.roomId); .filter((i) => i.roomId !== item.roomId);
const beforeIndex = childItems.findIndex((i) => i.roomId === containerItem.roomId); const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId);
const insertIndex = beforeIndex + 1; const insertIndex = beforeIndex + 1;
childItems.splice(insertIndex, 0, { itemSpaces.splice(insertIndex, 0, {
...item, ...item,
content: { ...item.content, order: undefined }, content: { ...item.content, order: undefined },
}); });
const currentOrders = childItems.map((i) => { const currentOrders = itemSpaces.map((i) => {
if (typeof i.content.order === 'string' && lex.has(i.content.order)) { if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
return i.content.order; return i.content.order;
} }
@@ -216,21 +232,21 @@ export function Lobby() {
const newOrders = orderKeys(lex, currentOrders); const newOrders = orderKeys(lex, currentOrders);
newOrders?.forEach((orderKey, index) => { newOrders?.forEach((orderKey, index) => {
const itm = childItems[index]; const itm = itemSpaces[index];
if (!itm || !itm.parentId) return; if (!itm || !itm.parentId) return;
const parentPL = roomsPowerLevels.get(itm.parentId); const parentPL = roomsPowerLevels.get(itm.parentId);
const canEdit = parentPL && canEditSpaceChild(parentPL); const canEdit = parentPL && canEditSpaceChild(parentPL);
if (canEdit && orderKey !== currentOrders[index]) { if (canEdit && orderKey !== currentOrders[index]) {
mx.sendStateEvent( mx.sendStateEvent(
itm.parentId, itm.parentId,
StateEvent.SpaceChild, StateEvent.SpaceChild as any,
{ ...itm.content, order: orderKey }, { ...itm.content, order: orderKey },
itm.roomId itm.roomId
); );
} }
}); });
}, },
[mx, flattenHierarchy, lex, roomsPowerLevels, canEditSpaceChild] [mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
); );
const reorderRoom = useCallback( const reorderRoom = useCallback(
@@ -239,13 +255,12 @@ export function Lobby() {
if (!item.parentId) { if (!item.parentId) {
return; return;
} }
const containerParentId: string = containerItem.space const containerParentId: string =
? containerItem.roomId 'space' in containerItem ? containerItem.roomId : containerItem.parentId;
: containerItem.parentId;
const itemContent = item.content; const itemContent = item.content;
if (item.parentId !== containerParentId) { if (item.parentId !== containerParentId) {
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild, {}, item.roomId); mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
} }
if ( if (
@@ -258,34 +273,35 @@ export function Lobby() {
const joinRuleContent = getStateEvent( const joinRuleContent = getStateEvent(
itemRoom, itemRoom,
StateEvent.RoomJoinRules StateEvent.RoomJoinRules
)?.getContent<IJoinRuleEventContent>(); )?.getContent<RoomJoinRulesEventContent>();
if (joinRuleContent) { if (joinRuleContent) {
const allow = const allow =
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? []; joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? [];
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId }); allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules, { mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
...joinRuleContent, ...joinRuleContent,
allow, allow,
}); });
} }
} }
const childItems = flattenHierarchy const itemSpaces = Array.from(
.filter((i) => i.parentId === containerParentId && !i.space) hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? []
.filter((i) => i.roomId !== item.roomId); );
const beforeItem: HierarchyItem | undefined = containerItem.space ? undefined : containerItem; const beforeItem: HierarchyItem | undefined =
const beforeIndex = childItems.findIndex((i) => i.roomId === beforeItem?.roomId); 'space' in containerItem ? undefined : containerItem;
const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId);
const insertIndex = beforeIndex + 1; const insertIndex = beforeIndex + 1;
childItems.splice(insertIndex, 0, { itemSpaces.splice(insertIndex, 0, {
...item, ...item,
parentId: containerParentId, parentId: containerParentId,
content: { ...itemContent, order: undefined }, content: { ...itemContent, order: undefined },
}); });
const currentOrders = childItems.map((i) => { const currentOrders = itemSpaces.map((i) => {
if (typeof i.content.order === 'string' && lex.has(i.content.order)) { if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
return i.content.order; return i.content.order;
} }
@@ -295,18 +311,18 @@ export function Lobby() {
const newOrders = orderKeys(lex, currentOrders); const newOrders = orderKeys(lex, currentOrders);
newOrders?.forEach((orderKey, index) => { newOrders?.forEach((orderKey, index) => {
const itm = childItems[index]; const itm = itemSpaces[index];
if (itm && orderKey !== currentOrders[index]) { if (itm && orderKey !== currentOrders[index]) {
mx.sendStateEvent( mx.sendStateEvent(
containerParentId, containerParentId,
StateEvent.SpaceChild, StateEvent.SpaceChild as any,
{ ...itm.content, order: orderKey }, { ...itm.content, order: orderKey },
itm.roomId itm.roomId
); );
} }
}); });
}, },
[mx, flattenHierarchy, lex] [mx, hierarchy, lex]
); );
useDnDMonitor( useDnDMonitor(
@@ -317,7 +333,7 @@ export function Lobby() {
if (!canDrop(item, container)) { if (!canDrop(item, container)) {
return; return;
} }
if (item.space) { if ('space' in item) {
reorderSpace(item, container.item); reorderSpace(item, container.item);
} else { } else {
reorderRoom(item, container.item); reorderRoom(item, container.item);
@@ -327,8 +343,16 @@ export function Lobby() {
) )
); );
const addSpaceRoom = useCallback( const handleSpacesFound = useCallback(
(roomId: string) => setSpaceRooms({ type: 'PUT', roomId }), (sItems: IHierarchyRoom[]) => {
setSpaceRooms({ type: 'PUT', roomIds: sItems.map((i) => i.room_id) });
setSpacesItem((current) => {
const newItems = produce(current, (draft) => {
sItems.forEach((item) => draft.set(item.room_id, item));
});
return current.size === newItems.size ? current : newItems;
});
},
[setSpaceRooms] [setSpaceRooms]
); );
@@ -393,121 +417,44 @@ export function Lobby() {
<LobbyHero /> <LobbyHero />
</PageHeroSection> </PageHeroSection>
{vItems.map((vItem) => { {vItems.map((vItem) => {
const item = flattenHierarchy[vItem.index]; const item = hierarchy[vItem.index];
if (!item) return null; if (!item) return null;
const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {}; const nextSpaceId = hierarchy[vItem.index + 1]?.space.roomId;
const userPLInItem = powerLevelAPI.getPowerLevel(
itemPowerLevel,
mx.getUserId() ?? undefined
);
const canInvite = powerLevelAPI.canDoAction(
itemPowerLevel,
'invite',
userPLInItem
);
const isJoined = allJoinedRooms.has(item.roomId);
const nextRoomId: string | undefined = const categoryId = makeLobbyCategoryId(space.roomId, item.space.roomId);
flattenHierarchy[vItem.index + 1]?.roomId;
const dragging =
draggingItem?.roomId === item.roomId &&
draggingItem.parentId === item.parentId;
if (item.space) {
const categoryId = makeLobbyCategoryId(space.roomId, item.roomId);
const { parentId } = item;
const parentPowerLevels = parentId
? roomsPowerLevels.get(parentId) ?? {}
: undefined;
return (
<VirtualTile
virtualItem={vItem}
style={{
paddingTop: vItem.index === 0 ? 0 : config.space.S500,
}}
ref={virtualizer.measureElement}
key={vItem.index}
>
<SpaceItemCard
item={item}
joined={allJoinedRooms.has(item.roomId)}
categoryId={categoryId}
closed={closedCategories.has(categoryId) || !!draggingItem?.space}
handleClose={handleCategoryClick}
getRoom={getRoom}
canEditChild={canEditSpaceChild(
roomsPowerLevels.get(item.roomId) ?? {}
)}
canReorder={
parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false
}
options={
parentId &&
parentPowerLevels && (
<HierarchyItemMenu
item={{ ...item, parentId }}
canInvite={canInvite}
joined={isJoined}
canEditChild={canEditSpaceChild(parentPowerLevels)}
pinned={sidebarSpaces.has(item.roomId)}
onTogglePin={togglePinToSidebar}
/>
)
}
before={item.parentId ? undefined : undefined}
after={
<AfterItemDropTarget
item={item}
nextRoomId={nextRoomId}
afterSpace
canDrop={canDrop}
/>
}
onDragging={setDraggingItem}
data-dragging={dragging}
/>
</VirtualTile>
);
}
const parentPowerLevels = roomsPowerLevels.get(item.parentId) ?? {};
const prevItem: HierarchyItem | undefined = flattenHierarchy[vItem.index - 1];
const nextItem: HierarchyItem | undefined = flattenHierarchy[vItem.index + 1];
return ( return (
<VirtualTile <VirtualTile
virtualItem={vItem} virtualItem={vItem}
style={{ paddingTop: config.space.S100 }} style={{
paddingTop: vItem.index === 0 ? 0 : config.space.S500,
}}
ref={virtualizer.measureElement} ref={virtualizer.measureElement}
key={vItem.index} key={vItem.index}
> >
<RoomItemCard <SpaceHierarchy
item={item} spaceItem={item.space}
onSpaceFound={addSpaceRoom} summary={spacesItems.get(item.space.roomId)}
dm={mDirects.has(item.roomId)} roomItems={item.rooms}
firstChild={!prevItem || prevItem.space === true} allJoinedRooms={allJoinedRooms}
lastChild={!nextItem || nextItem.space === true} mDirects={mDirects}
onOpen={handleOpenRoom} roomsPowerLevels={roomsPowerLevels}
getRoom={getRoom} canEditSpaceChild={canEditSpaceChild}
canReorder={canEditSpaceChild(parentPowerLevels)} categoryId={categoryId}
options={ closed={
<HierarchyItemMenu closedCategories.has(categoryId) ||
item={item} (draggingItem ? 'space' in draggingItem : false)
canInvite={canInvite}
joined={isJoined}
canEditChild={canEditSpaceChild(parentPowerLevels)}
/>
} }
after={ handleClose={handleCategoryClick}
<AfterItemDropTarget draggingItem={draggingItem}
item={item}
nextRoomId={nextRoomId}
canDrop={canDrop}
/>
}
data-dragging={dragging}
onDragging={setDraggingItem} onDragging={setDraggingItem}
canDrop={canDrop}
nextSpaceId={nextSpaceId}
getRoom={getRoom}
pinned={sidebarSpaces.has(item.space.roomId)}
togglePinToSidebar={togglePinToSidebar}
onSpacesFound={handleSpacesFound}
onOpenRoom={handleOpenRoom}
/> />
</VirtualTile> </VirtualTile>
); );

View File

@@ -1,4 +1,4 @@
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useRef } from 'react'; import React, { MouseEventHandler, ReactNode, useCallback, useRef } from 'react';
import { import {
Avatar, Avatar,
Badge, Badge,
@@ -20,23 +20,20 @@ import {
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk'; import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { millify } from '../../plugins/millify'; import { millify } from '../../plugins/millify';
import { import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader';
HierarchyRoomSummaryLoader,
LocalRoomSummaryLoader,
} from '../../components/RoomSummaryLoader';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer'; import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard'; import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard';
import { Membership, RoomType } from '../../../types/matrix/room'; import { Membership } from '../../../types/matrix/room';
import * as css from './RoomItem.css'; import * as css from './RoomItem.css';
import * as styleCss from './style.css'; import * as styleCss from './style.css';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ErrorCode } from '../../cs-errorcode';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
import { ItemDraggableTarget, useDraggableItem } from './DnD'; import { ItemDraggableTarget, useDraggableItem } from './DnD';
import { mxcUrlToHttp } from '../../utils/matrix'; import { mxcUrlToHttp } from '../../utils/matrix';
@@ -125,13 +122,11 @@ function RoomProfileLoading() {
type RoomProfileErrorProps = { type RoomProfileErrorProps = {
roomId: string; roomId: string;
error: Error; inaccessibleRoom: boolean;
suggested?: boolean; suggested?: boolean;
via?: string[]; via?: string[];
}; };
function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorProps) { function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProfileErrorProps) {
const privateRoom = error.name === ErrorCode.M_FORBIDDEN;
return ( return (
<Box grow="Yes" gap="300"> <Box grow="Yes" gap="300">
<Avatar> <Avatar>
@@ -142,7 +137,7 @@ function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorPro
renderFallback={() => ( renderFallback={() => (
<RoomIcon <RoomIcon
size="300" size="300"
joinRule={privateRoom ? JoinRule.Invite : JoinRule.Restricted} joinRule={inaccessibleRoom ? JoinRule.Invite : JoinRule.Restricted}
filled filled
/> />
)} )}
@@ -162,25 +157,18 @@ function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorPro
)} )}
</Box> </Box>
<Box gap="200" alignItems="Center"> <Box gap="200" alignItems="Center">
{privateRoom && ( {inaccessibleRoom ? (
<> <Badge variant="Secondary" fill="Soft" radii="300" size="500">
<Badge variant="Secondary" fill="Soft" radii="Pill" outlined> <Text size="L400">Inaccessible</Text>
<Text size="L400">Private Room</Text> </Badge>
</Badge> ) : (
<Line <Text size="T200" truncate>
variant="SurfaceVariant" {roomId}
style={{ height: toRem(12) }} </Text>
direction="Vertical"
size="400"
/>
</>
)} )}
<Text size="T200" truncate>
{roomId}
</Text>
</Box> </Box>
</Box> </Box>
{!privateRoom && <RoomJoinButton roomId={roomId} via={via} />} {!inaccessibleRoom && <RoomJoinButton roomId={roomId} via={via} />}
</Box> </Box>
); );
} }
@@ -288,23 +276,11 @@ function RoomProfile({
); );
} }
function CallbackOnFoundSpace({
roomId,
onSpaceFound,
}: {
roomId: string;
onSpaceFound: (roomId: string) => void;
}) {
useEffect(() => {
onSpaceFound(roomId);
}, [roomId, onSpaceFound]);
return null;
}
type RoomItemCardProps = { type RoomItemCardProps = {
item: HierarchyItem; item: HierarchyItem;
onSpaceFound: (roomId: string) => void; loading: boolean;
error: Error | null;
summary: IHierarchyRoom | undefined;
dm?: boolean; dm?: boolean;
firstChild?: boolean; firstChild?: boolean;
lastChild?: boolean; lastChild?: boolean;
@@ -320,10 +296,10 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
( (
{ {
item, item,
onSpaceFound, loading,
error,
summary,
dm, dm,
firstChild,
lastChild,
onOpen, onOpen,
options, options,
before, before,
@@ -348,8 +324,6 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
return ( return (
<SequenceCard <SequenceCard
className={css.RoomItemCard} className={css.RoomItemCard}
firstChild={firstChild}
lastChild={lastChild}
variant="SurfaceVariant" variant="SurfaceVariant"
gap="300" gap="300"
alignItems="Center" alignItems="Center"
@@ -367,7 +341,9 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
name={localSummary.name} name={localSummary.name}
topic={localSummary.topic} topic={localSummary.topic}
avatarUrl={ avatarUrl={
dm ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication) dm
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
} }
memberCount={localSummary.memberCount} memberCount={localSummary.memberCount}
suggested={content.suggested} suggested={content.suggested}
@@ -395,46 +371,46 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
)} )}
</LocalRoomSummaryLoader> </LocalRoomSummaryLoader>
) : ( ) : (
<HierarchyRoomSummaryLoader roomId={roomId}> <>
{(summaryState) => ( {!summary &&
<> (error ? (
{summaryState.status === AsyncStatus.Loading && <RoomProfileLoading />} <RoomProfileError
{summaryState.status === AsyncStatus.Error && ( roomId={roomId}
<RoomProfileError inaccessibleRoom={false}
roomId={roomId} suggested={content.suggested}
error={summaryState.error} via={content.via}
suggested={content.suggested} />
via={content.via} ) : (
/> <>
)} {loading && <RoomProfileLoading />}
{summaryState.status === AsyncStatus.Success && ( {!loading && (
<> <RoomProfileError
{summaryState.data.room_type === RoomType.Space && (
<CallbackOnFoundSpace
roomId={summaryState.data.room_id}
onSpaceFound={onSpaceFound}
/>
)}
<RoomProfile
roomId={roomId} roomId={roomId}
name={summaryState.data.name || summaryState.data.canonical_alias || roomId} inaccessibleRoom
topic={summaryState.data.topic}
avatarUrl={
summaryState.data?.avatar_url
? mxcUrlToHttp(mx, summaryState.data.avatar_url, useAuthentication, 96, 96, 'crop') ??
undefined
: undefined
}
memberCount={summaryState.data.num_joined_members}
suggested={content.suggested} suggested={content.suggested}
joinRule={summaryState.data.join_rule} via={content.via}
options={<RoomJoinButton roomId={roomId} via={content.via} />}
/> />
</> )}
)} </>
</> ))}
{summary && (
<RoomProfile
roomId={roomId}
name={summary.name || summary.canonical_alias || roomId}
topic={summary.topic}
avatarUrl={
summary?.avatar_url
? mxcUrlToHttp(mx, summary.avatar_url, useAuthentication, 96, 96, 'crop') ??
undefined
: undefined
}
memberCount={summary.num_joined_members}
suggested={content.suggested}
joinRule={summary.join_rule}
options={<RoomJoinButton roomId={roomId} via={content.via} />}
/>
)} )}
</HierarchyRoomSummaryLoader> </>
)} )}
</Box> </Box>
{options} {options}

View File

@@ -0,0 +1,225 @@
import React, { forwardRef, MouseEventHandler, useEffect, useMemo } from 'react';
import { MatrixError, Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { Box, config, Text } from 'folds';
import {
HierarchyItem,
HierarchyItemRoom,
HierarchyItemSpace,
useFetchSpaceHierarchyLevel,
} from '../../hooks/useSpaceHierarchy';
import { IPowerLevels, powerLevelAPI } from '../../hooks/usePowerLevels';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { SpaceItemCard } from './SpaceItem';
import { AfterItemDropTarget, CanDropCallback } from './DnD';
import { HierarchyItemMenu } from './HierarchyItemMenu';
import { RoomItemCard } from './RoomItem';
import { RoomType } from '../../../types/matrix/room';
import { SequenceCard } from '../../components/sequence-card';
type SpaceHierarchyProps = {
summary: IHierarchyRoom | undefined;
spaceItem: HierarchyItemSpace;
roomItems?: HierarchyItemRoom[];
allJoinedRooms: Set<string>;
mDirects: Set<string>;
roomsPowerLevels: Map<string, IPowerLevels>;
canEditSpaceChild: (powerLevels: IPowerLevels) => boolean;
categoryId: string;
closed: boolean;
handleClose: MouseEventHandler<HTMLButtonElement>;
draggingItem?: HierarchyItem;
onDragging: (item?: HierarchyItem) => void;
canDrop: CanDropCallback;
nextSpaceId?: string;
getRoom: (roomId: string) => Room | undefined;
pinned: boolean;
togglePinToSidebar: (roomId: string) => void;
onSpacesFound: (spaceItems: IHierarchyRoom[]) => void;
onOpenRoom: MouseEventHandler<HTMLButtonElement>;
};
export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
(
{
summary,
spaceItem,
roomItems,
allJoinedRooms,
mDirects,
roomsPowerLevels,
canEditSpaceChild,
categoryId,
closed,
handleClose,
draggingItem,
onDragging,
canDrop,
nextSpaceId,
getRoom,
pinned,
togglePinToSidebar,
onOpenRoom,
onSpacesFound,
},
ref
) => {
const mx = useMatrixClient();
const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(spaceItem.roomId, true);
const subspaces = useMemo(() => {
const s: Map<string, IHierarchyRoom> = new Map();
rooms.forEach((r) => {
if (r.room_type === RoomType.Space) {
s.set(r.room_id, r);
}
});
return s;
}, [rooms]);
const spacePowerLevels = roomsPowerLevels.get(spaceItem.roomId) ?? {};
const userPLInSpace = powerLevelAPI.getPowerLevel(
spacePowerLevels,
mx.getUserId() ?? undefined
);
const canInviteInSpace = powerLevelAPI.canDoAction(spacePowerLevels, 'invite', userPLInSpace);
const draggingSpace =
draggingItem?.roomId === spaceItem.roomId && draggingItem.parentId === spaceItem.parentId;
const { parentId } = spaceItem;
const parentPowerLevels = parentId ? roomsPowerLevels.get(parentId) ?? {} : undefined;
useEffect(() => {
onSpacesFound(Array.from(subspaces.values()));
}, [subspaces, onSpacesFound]);
let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId));
if (!canEditSpaceChild(spacePowerLevels)) {
// hide unknown rooms for normal user
childItems = childItems?.filter((i) => {
const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false;
const inaccessibleRoom = !rooms.get(i.roomId) && !fetching && (error ? forbidden : true);
return !inaccessibleRoom;
});
}
return (
<Box direction="Column" gap="100" ref={ref}>
<SpaceItemCard
summary={rooms.get(spaceItem.roomId) ?? summary}
loading={fetching}
item={spaceItem}
joined={allJoinedRooms.has(spaceItem.roomId)}
categoryId={categoryId}
closed={closed}
handleClose={handleClose}
getRoom={getRoom}
canEditChild={canEditSpaceChild(spacePowerLevels)}
canReorder={parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false}
options={
parentId &&
parentPowerLevels && (
<HierarchyItemMenu
item={{ ...spaceItem, parentId }}
canInvite={canInviteInSpace}
joined={allJoinedRooms.has(spaceItem.roomId)}
canEditChild={canEditSpaceChild(parentPowerLevels)}
pinned={pinned}
onTogglePin={togglePinToSidebar}
/>
)
}
after={
<AfterItemDropTarget
item={spaceItem}
nextRoomId={closed ? nextSpaceId : childItems?.[0]?.roomId}
afterSpace
canDrop={canDrop}
/>
}
onDragging={onDragging}
data-dragging={draggingSpace}
/>
{childItems && childItems.length > 0 ? (
<Box direction="Column" gap="100">
{childItems.map((roomItem, index) => {
const roomSummary = rooms.get(roomItem.roomId);
const roomPowerLevels = roomsPowerLevels.get(roomItem.roomId) ?? {};
const userPLInRoom = powerLevelAPI.getPowerLevel(
roomPowerLevels,
mx.getUserId() ?? undefined
);
const canInviteInRoom = powerLevelAPI.canDoAction(
roomPowerLevels,
'invite',
userPLInRoom
);
const lastItem = index === childItems.length;
const nextRoomId = lastItem ? nextSpaceId : childItems[index + 1]?.roomId;
const roomDragging =
draggingItem?.roomId === roomItem.roomId &&
draggingItem.parentId === roomItem.parentId;
return (
<RoomItemCard
key={roomItem.roomId}
item={roomItem}
loading={fetching}
error={error}
summary={roomSummary}
dm={mDirects.has(roomItem.roomId)}
onOpen={onOpenRoom}
getRoom={getRoom}
canReorder={canEditSpaceChild(spacePowerLevels)}
options={
<HierarchyItemMenu
item={roomItem}
canInvite={canInviteInRoom}
joined={allJoinedRooms.has(roomItem.roomId)}
canEditChild={canEditSpaceChild(spacePowerLevels)}
/>
}
after={
<AfterItemDropTarget
item={roomItem}
nextRoomId={nextRoomId}
canDrop={canDrop}
/>
}
data-dragging={roomDragging}
onDragging={onDragging}
/>
);
})}
</Box>
) : (
childItems && (
<SequenceCard variant="SurfaceVariant" gap="300" alignItems="Center">
<Box
grow="Yes"
style={{
padding: config.space.S700,
}}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="100"
>
<Text size="H5" align="Center">
No Rooms
</Text>
<Text align="Center" size="T300" priority="300">
This space does not contains rooms yet.
</Text>
</Box>
</SequenceCard>
)
)}
</Box>
);
}
);

View File

@@ -19,19 +19,16 @@ import {
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import classNames from 'classnames'; import classNames from 'classnames';
import { MatrixError, Room } from 'matrix-js-sdk'; import { MatrixError, Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomAvatar } from '../../components/room-avatar'; import { RoomAvatar } from '../../components/room-avatar';
import { nameInitials } from '../../utils/common'; import { nameInitials } from '../../utils/common';
import { import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader';
HierarchyRoomSummaryLoader,
LocalRoomSummaryLoader,
} from '../../components/RoomSummaryLoader';
import { getRoomAvatarUrl } from '../../utils/room'; import { getRoomAvatarUrl } from '../../utils/room';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import * as css from './SpaceItem.css'; import * as css from './SpaceItem.css';
import * as styleCss from './style.css'; import * as styleCss from './style.css';
import { ErrorCode } from '../../cs-errorcode';
import { useDraggableItem } from './DnD'; import { useDraggableItem } from './DnD';
import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation'; import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
@@ -53,18 +50,11 @@ function SpaceProfileLoading() {
); );
} }
type UnknownPrivateSpaceProfileProps = { type InaccessibleSpaceProfileProps = {
roomId: string; roomId: string;
name?: string;
avatarUrl?: string;
suggested?: boolean; suggested?: boolean;
}; };
function UnknownPrivateSpaceProfile({ function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfileProps) {
roomId,
name,
avatarUrl,
suggested,
}: UnknownPrivateSpaceProfileProps) {
return ( return (
<Chip <Chip
as="span" as="span"
@@ -75,11 +65,9 @@ function UnknownPrivateSpaceProfile({
<Avatar size="200" radii="300"> <Avatar size="200" radii="300">
<RoomAvatar <RoomAvatar
roomId={roomId} roomId={roomId}
src={avatarUrl}
alt={name}
renderFallback={() => ( renderFallback={() => (
<Text as="span" size="H6"> <Text as="span" size="H6">
{nameInitials(name)} U
</Text> </Text>
)} )}
/> />
@@ -88,11 +76,11 @@ function UnknownPrivateSpaceProfile({
> >
<Box alignItems="Center" gap="200"> <Box alignItems="Center" gap="200">
<Text size="H4" truncate> <Text size="H4" truncate>
{name || 'Unknown'} Unknown
</Text> </Text>
<Badge variant="Secondary" fill="Soft" radii="Pill" outlined> <Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
<Text size="L400">Private Space</Text> <Text size="L400">Inaccessible</Text>
</Badge> </Badge>
{suggested && ( {suggested && (
<Badge variant="Success" fill="Soft" radii="Pill" outlined> <Badge variant="Success" fill="Soft" radii="Pill" outlined>
@@ -104,20 +92,20 @@ function UnknownPrivateSpaceProfile({
); );
} }
type UnknownSpaceProfileProps = { type UnjoinedSpaceProfileProps = {
roomId: string; roomId: string;
via?: string[]; via?: string[];
name?: string; name?: string;
avatarUrl?: string; avatarUrl?: string;
suggested?: boolean; suggested?: boolean;
}; };
function UnknownSpaceProfile({ function UnjoinedSpaceProfile({
roomId, roomId,
via, via,
name, name,
avatarUrl, avatarUrl,
suggested, suggested,
}: UnknownSpaceProfileProps) { }: UnjoinedSpaceProfileProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>( const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
@@ -376,6 +364,8 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
} }
type SpaceItemCardProps = { type SpaceItemCardProps = {
summary: IHierarchyRoom | undefined;
loading?: boolean;
item: HierarchyItem; item: HierarchyItem;
joined?: boolean; joined?: boolean;
categoryId: string; categoryId: string;
@@ -393,6 +383,8 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
( (
{ {
className, className,
summary,
loading,
joined, joined,
closed, closed,
categoryId, categoryId,
@@ -451,37 +443,31 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
} }
</LocalRoomSummaryLoader> </LocalRoomSummaryLoader>
) : ( ) : (
<HierarchyRoomSummaryLoader roomId={roomId}> <>
{(summaryState) => ( {!summary &&
<> (loading ? (
{summaryState.status === AsyncStatus.Loading && <SpaceProfileLoading />} <SpaceProfileLoading />
{summaryState.status === AsyncStatus.Error && ) : (
(summaryState.error.name === ErrorCode.M_FORBIDDEN ? ( <InaccessibleSpaceProfile
<UnknownPrivateSpaceProfile roomId={roomId} suggested={content.suggested} /> roomId={item.roomId}
) : ( suggested={item.content.suggested}
<UnknownSpaceProfile />
roomId={roomId} ))}
via={item.content.via} {summary && (
suggested={content.suggested} <UnjoinedSpaceProfile
/> roomId={roomId}
))} via={item.content.via}
{summaryState.status === AsyncStatus.Success && ( name={summary.name || summary.canonical_alias || roomId}
<UnknownSpaceProfile avatarUrl={
roomId={roomId} summary?.avatar_url
via={item.content.via} ? mxcUrlToHttp(mx, summary.avatar_url, useAuthentication, 96, 96, 'crop') ??
name={summaryState.data.name || summaryState.data.canonical_alias || roomId} undefined
avatarUrl={ : undefined
summaryState.data?.avatar_url }
? mxcUrlToHttp(mx, summaryState.data.avatar_url, useAuthentication, 96, 96, 'crop') ?? suggested={content.suggested}
undefined />
: undefined
}
suggested={content.suggested}
/>
)}
</>
)} )}
</HierarchyRoomSummaryLoader> </>
)} )}
</Box> </Box>
{canEditChild && ( {canEditChild && (

View File

@@ -39,6 +39,8 @@ import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix'; import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
type RoomNavItemMenuProps = { type RoomNavItemMenuProps = {
room: Room; room: Room;
@@ -47,13 +49,14 @@ type RoomNavItemMenuProps = {
const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>( const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
({ room, requestClose }, ref) => { ({ room, requestClose }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
markAsRead(mx, room.roomId); markAsRead(mx, room.roomId, hideActivity);
requestClose(); requestClose();
}; };
@@ -182,7 +185,9 @@ export function RoomNavItem({
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const typingMember = useRoomTypingMember(room.roomId); const typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId()
);
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => { const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
evt.preventDefault(); evt.preventDefault();
@@ -219,7 +224,9 @@ export function RoomNavItem({
<RoomAvatar <RoomAvatar
roomId={room.roomId} roomId={room.roomId}
src={ src={
direct ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication) direct
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
} }
alt={room.name} alt={room.name}
renderFallback={() => ( renderFallback={() => (

View File

@@ -156,7 +156,7 @@ export type MembersFilterOptions = {
}; };
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 100, limit: 1000,
matchOptions: { matchOptions: {
contain: true, contain: true,
}, },
@@ -428,8 +428,9 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
}} }}
after={<Icon size="50" src={Icons.Cross} />} after={<Icon size="50" src={Icons.Cross} />}
> >
<Text size="B300">{`${result.items.length || 'No'} ${result.items.length === 1 ? 'Result' : 'Results' <Text size="B300">{`${result.items.length || 'No'} ${
}`}</Text> result.items.length === 1 ? 'Result' : 'Results'
}`}</Text>
</Chip> </Chip>
) )
} }
@@ -485,15 +486,17 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
const member = tagOrMember; const member = tagOrMember;
const name = getName(member); const name = getName(member);
const avatarMxcUrl = member.getMxcAvatarUrl(); const avatarMxcUrl = member.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp( const avatarUrl = avatarMxcUrl
avatarMxcUrl, ? mx.mxcUrlToHttp(
100, avatarMxcUrl,
100, 100,
'crop', 100,
undefined, 'crop',
false, undefined,
useAuthentication false,
) : undefined; useAuthentication
)
: undefined;
return ( return (
<MenuItem <MenuItem

View File

@@ -20,6 +20,7 @@ export function Room() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId); const members = useRoomMembers(mx, room.roomId);
@@ -29,10 +30,10 @@ export function Room() {
useCallback( useCallback(
(evt) => { (evt) => {
if (isKeyHotkey('escape', evt)) { if (isKeyHotkey('escape', evt)) {
markAsRead(mx, room.roomId); markAsRead(mx, room.roomId, hideActivity);
} }
}, },
[mx, room.roomId] [mx, room.roomId, hideActivity]
) )
); );

View File

@@ -53,16 +53,24 @@ import {
isEmptyEditor, isEmptyEditor,
getBeginCommand, getBeginCommand,
trimCommand, trimCommand,
getMentions,
} from '../../components/editor'; } from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board'; import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import {
TUploadContent,
encryptFile,
getImageInfo,
getMxIdLocalPart,
mxcUrlToHttp,
} from '../../utils/matrix';
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater'; import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
import { useFilePicker } from '../../hooks/useFilePicker'; import { useFilePicker } from '../../hooks/useFilePicker';
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler'; import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
import { useFileDropZone } from '../../hooks/useFileDrop'; import { useFileDropZone } from '../../hooks/useFileDrop';
import { import {
TUploadItem, TUploadItem,
TUploadMetadata,
roomIdToMsgDraftAtomFamily, roomIdToMsgDraftAtomFamily,
roomIdToReplyDraftAtomFamily, roomIdToReplyDraftAtomFamily,
roomIdToUploadItemsAtomFamily, roomIdToUploadItemsAtomFamily,
@@ -96,14 +104,11 @@ import colorMXID from '../../../util/colorMXID';
import { import {
getAllParents, getAllParents,
getMemberDisplayName, getMemberDisplayName,
parseReplyBody, getMentionContent,
parseReplyFormattedBody,
trimReplyFromBody, trimReplyFromBody,
trimReplyFromFormattedBody,
} from '../../utils/room'; } from '../../utils/room';
import { sanitizeText } from '../../utils/sanitize';
import { CommandAutocomplete } from './CommandAutocomplete'; import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, useCommands } from '../../hooks/useCommands'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent'; import { mobileOrTablet } from '../../utils/user-agent';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { ReplyLayout, ThreadIndicator } from '../../components/message'; import { ReplyLayout, ThreadIndicator } from '../../components/message';
@@ -122,6 +127,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const commands = useCommands(mx, room); const commands = useCommands(mx, room);
const emojiBtnRef = useRef<HTMLButtonElement>(null); const emojiBtnRef = useRef<HTMLButtonElement>(null);
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
@@ -157,14 +163,28 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const safeFiles = files.map(safeFile); const safeFiles = files.map(safeFile);
const fileItems: TUploadItem[] = []; const fileItems: TUploadItem[] = [];
if (mx.isRoomEncrypted(roomId)) { if (room.hasEncryptionStateEvent()) {
const encryptFiles = fulfilledPromiseSettledResult( const encryptFiles = fulfilledPromiseSettledResult(
await Promise.allSettled(safeFiles.map((f) => encryptFile(f))) await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
); );
encryptFiles.forEach((ef) => fileItems.push(ef)); encryptFiles.forEach((ef) =>
fileItems.push({
...ef,
metadata: {
markedAsSpoiler: false,
},
})
);
} else { } else {
safeFiles.forEach((f) => safeFiles.forEach((f) =>
fileItems.push({ file: f, originalFile: f, encInfo: undefined }) fileItems.push({
file: f,
originalFile: f,
encInfo: undefined,
metadata: {
markedAsSpoiler: false,
},
})
); );
} }
setSelectedFiles({ setSelectedFiles({
@@ -172,7 +192,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
item: fileItems, item: fileItems,
}); });
}, },
[setSelectedFiles, roomId, mx] [setSelectedFiles, room]
); );
const pickFile = useFilePicker(handleFiles, true); const pickFile = useFilePicker(handleFiles, true);
const handlePaste = useFilePasteHandler(handleFiles); const handlePaste = useFilePasteHandler(handleFiles);
@@ -202,6 +222,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
[roomId, editor, setMsgDraft] [roomId, editor, setMsgDraft]
); );
const handleFileMetadata = useCallback(
(fileItem: TUploadItem, metadata: TUploadMetadata) => {
setSelectedFiles({
type: 'REPLACE',
item: fileItem,
replacement: { ...fileItem, metadata },
});
},
[setSelectedFiles]
);
const handleRemoveUpload = useCallback( const handleRemoveUpload = useCallback(
(upload: TUploadContent | TUploadContent[]) => { (upload: TUploadContent | TUploadContent[]) => {
const uploads = Array.isArray(upload) ? upload : [upload]; const uploads = Array.isArray(upload) ? upload : [upload];
@@ -248,8 +279,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
uploadBoardHandlers.current?.handleSend(); uploadBoardHandlers.current?.handleSend();
const commandName = getBeginCommand(editor); const commandName = getBeginCommand(editor);
let plainText = toPlainText(editor.children, isMarkdown).trim();
let plainText = toPlainText(editor.children).trim();
let customHtml = trimCustomHtml( let customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, { toMatrixCustomHTML(editor.children, {
allowTextFormatting: true, allowTextFormatting: true,
@@ -270,6 +300,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
} else if (commandName === Command.Shrug) { } else if (commandName === Command.Shrug) {
plainText = `${SHRUG} ${plainText}`; plainText = `${SHRUG} ${plainText}`;
customHtml = `${SHRUG} ${customHtml}`; customHtml = `${SHRUG} ${customHtml}`;
} else if (commandName === Command.TableFlip) {
plainText = `${TABLEFLIP} ${plainText}`;
customHtml = `${TABLEFLIP} ${customHtml}`;
} else if (commandName === Command.UnFlip) {
plainText = `${UNFLIP} ${plainText}`;
customHtml = `${UNFLIP} ${customHtml}`;
} else if (commandName) { } else if (commandName) {
const commandContent = commands[commandName as Command]; const commandContent = commands[commandName as Command];
if (commandContent) { if (commandContent) {
@@ -283,25 +319,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (plainText === '') return; if (plainText === '') return;
let body = plainText; const body = plainText;
let formattedBody = customHtml; const formattedBody = customHtml;
if (replyDraft) { const mentionData = getMentions(mx, roomId, editor);
body = parseReplyBody(replyDraft.userId, trimReplyFromBody(replyDraft.body)) + body;
formattedBody =
parseReplyFormattedBody(
roomId,
replyDraft.userId,
replyDraft.eventId,
replyDraft.formattedBody
? trimReplyFromFormattedBody(replyDraft.formattedBody)
: sanitizeText(replyDraft.body)
) + formattedBody;
}
const content: IContent = { const content: IContent = {
msgtype: msgType, msgtype: msgType,
body, body,
}; };
if (replyDraft && replyDraft.userId !== mx.getUserId()) {
mentionData.users.add(replyDraft.userId);
}
const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
content['m.mentions'] = mMentions;
if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) { if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
content.format = 'org.matrix.custom.html'; content.format = 'org.matrix.custom.html';
content.formatted_body = formattedBody; content.formatted_body = formattedBody;
@@ -333,10 +366,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
} }
if (isKeyHotkey('escape', evt)) { if (isKeyHotkey('escape', evt)) {
evt.preventDefault(); evt.preventDefault();
if (autocompleteQuery) {
setAutocompleteQuery(undefined);
return;
}
setReplyDraft(undefined); setReplyDraft(undefined);
} }
}, },
[submit, setReplyDraft, enterForNewline] [submit, setReplyDraft, enterForNewline, autocompleteQuery]
); );
const handleKeyUp: KeyboardEventHandler = useCallback( const handleKeyUp: KeyboardEventHandler = useCallback(
@@ -346,7 +383,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
return; return;
} }
sendTypingStatus(!isEmptyEditor(editor)); if (!hideActivity) {
sendTypingStatus(!isEmptyEditor(editor));
}
const prevWordRange = getPrevWorldRange(editor); const prevWordRange = getPrevWorldRange(editor);
const query = prevWordRange const query = prevWordRange
@@ -354,7 +393,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
: undefined; : undefined;
setAutocompleteQuery(query); setAutocompleteQuery(query);
}, },
[editor, sendTypingStatus] [editor, sendTypingStatus, hideActivity]
); );
const handleCloseAutocomplete = useCallback(() => { const handleCloseAutocomplete = useCallback(() => {
@@ -407,9 +446,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<UploadCardRenderer <UploadCardRenderer
// eslint-disable-next-line react/no-array-index-key // eslint-disable-next-line react/no-array-index-key
key={index} key={index}
file={fileItem.file}
isEncrypted={!!fileItem.encInfo} isEncrypted={!!fileItem.encInfo}
uploadAtom={roomUploadAtomFamily(fileItem.file)} fileItem={fileItem}
setMetadata={handleFileMetadata}
onRemove={handleRemoveUpload} onRemove={handleRemoveUpload}
/> />
))} ))}

View File

@@ -85,7 +85,7 @@ import {
reactionOrEditEvent, reactionOrEditEvent,
} from '../../utils/room'; } from '../../utils/room';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { MessageLayout, settingsAtom } from '../../state/settings';
import { openProfileViewer } from '../../../client/action/navigation'; import { openProfileViewer } from '../../../client/action/navigation';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer'; import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { Reactions, Message, Event, EncryptedContent } from './message'; import { Reactions, Message, Event, EncryptedContent } from './message';
@@ -117,6 +117,7 @@ import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
const TimelineFloat = as<'div', css.TimelineFloatVariants>( const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => ( ({ position, className, ...props }, ref) => (
@@ -336,7 +337,10 @@ const useTimelinePagination = (
backwards ? Direction.Backward : Direction.Forward backwards ? Direction.Backward : Direction.Forward
) ?? timelineToPaginate; ) ?? timelineToPaginate;
// Decrypt all event ahead of render cycle // Decrypt all event ahead of render cycle
if (mx.isRoomEncrypted(fetchedTimeline.getRoomId() ?? '')) { const roomId = fetchedTimeline.getRoomId();
const room = roomId ? mx.getRoom(roomId) : null;
if (room?.hasEncryptionStateEvent()) {
await to(decryptAllTimelineEvent(mx, fetchedTimeline)); await to(decryptAllTimelineEvent(mx, fetchedTimeline));
} }
@@ -421,7 +425,7 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) { export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const encryptedRoom = mx.isRoomEncrypted(room.roomId); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
@@ -429,14 +433,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = encryptedRoom ? encUrlPreview : urlPreview; const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const ignoredUsersList = useIgnoredUsers();
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId)); const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
const powerLevels = usePowerLevelsContext(); const powerLevels = usePowerLevelsContext();
const { canDoAction, canSendEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels); const { canDoAction, canSendEvent, canSendStateEvent, getPowerLevel } =
usePowerLevelsAPI(powerLevels);
const myPowerLevel = getPowerLevel(mx.getUserId() ?? ''); const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
const canRedact = canDoAction('redact', myPowerLevel); const canRedact = canDoAction('redact', myPowerLevel);
const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel); const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
const canPinEvent = canSendStateEvent(StateEvent.RoomPinnedEvents, myPowerLevel);
const [editId, setEditId] = useState<string>(); const [editId, setEditId] = useState<string>();
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
@@ -582,15 +592,19 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// so timeline can be updated with evt like: edits, reactions etc // so timeline can be updated with evt like: edits, reactions etc
if (atBottomRef.current) { if (atBottomRef.current) {
if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) { if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) {
requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!)); // Check if the document is in focus (user is actively viewing the app),
// and either there are no unread messages or the latest message is from the current user.
// If either condition is met, trigger the markAsRead function to send a read receipt.
requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideActivity));
} }
if (document.hasFocus()) { if (!document.hasFocus() && !unreadInfo) {
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
} else if (!unreadInfo) {
setUnreadInfo(getRoomUnreadInfo(room)); setUnreadInfo(getRoomUnreadInfo(room));
} }
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
setTimeline((ct) => ({ setTimeline((ct) => ({
...ct, ...ct,
range: { range: {
@@ -605,10 +619,40 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
setUnreadInfo(getRoomUnreadInfo(room)); setUnreadInfo(getRoomUnreadInfo(room));
} }
}, },
[mx, room, unreadInfo] [mx, room, unreadInfo, hideActivity]
) )
); );
const handleOpenEvent = useCallback(
async (
evtId: string,
highlight = true,
onScroll: ((scrolled: boolean) => void) | undefined = undefined
) => {
const evtTimeline = getEventTimeline(room, evtId);
const absoluteIndex =
evtTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, evtTimeline, evtId);
if (typeof absoluteIndex === 'number') {
const scrolled = scrollToItem(absoluteIndex, {
behavior: 'smooth',
align: 'center',
stopInView: true,
});
if (onScroll) onScroll(scrolled);
setFocusItem({
index: absoluteIndex,
scrollTo: false,
highlight,
});
} else {
setTimeline(getEmptyTimeline());
loadEventTimeline(evtId);
}
},
[room, timeline, scrollToItem, loadEventTimeline]
);
useLiveTimelineRefresh( useLiveTimelineRefresh(
room, room,
useCallback(() => { useCallback(() => {
@@ -642,16 +686,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
); );
const tryAutoMarkAsRead = useCallback(() => { const tryAutoMarkAsRead = useCallback(() => {
if (!unreadInfo) { const readUptoEventId = readUptoEventIdRef.current;
requestAnimationFrame(() => markAsRead(mx, room.roomId)); if (!readUptoEventId) {
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
return; return;
} }
const evtTimeline = getEventTimeline(room, unreadInfo.readUptoEventId); const evtTimeline = getEventTimeline(room, readUptoEventId);
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward); const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
if (latestTimeline === room.getLiveTimeline()) { if (latestTimeline === room.getLiveTimeline()) {
requestAnimationFrame(() => markAsRead(mx, room.roomId)); requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
} }
}, [mx, room, unreadInfo]); }, [mx, room, hideActivity]);
const debounceSetAtBottom = useDebounce( const debounceSetAtBottom = useDebounce(
useCallback((entry: IntersectionObserverEntry) => { useCallback((entry: IntersectionObserverEntry) => {
@@ -668,7 +713,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (targetEntry) debounceSetAtBottom(targetEntry); if (targetEntry) debounceSetAtBottom(targetEntry);
if (targetEntry?.isIntersecting && atLiveEndRef.current) { if (targetEntry?.isIntersecting && atLiveEndRef.current) {
setAtBottom(true); setAtBottom(true);
tryAutoMarkAsRead(); if (document.hasFocus()) {
tryAutoMarkAsRead();
}
} }
}, },
[debounceSetAtBottom, tryAutoMarkAsRead] [debounceSetAtBottom, tryAutoMarkAsRead]
@@ -687,10 +734,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useCallback( useCallback(
(inFocus) => { (inFocus) => {
if (inFocus && atBottomRef.current) { if (inFocus && atBottomRef.current) {
if (unreadInfo?.inLiveTimeline) {
handleOpenEvent(unreadInfo.readUptoEventId, false, (scrolled) => {
// the unread event is already in view
// so, try mark as read;
if (!scrolled) {
tryAutoMarkAsRead();
}
});
return;
}
tryAutoMarkAsRead(); tryAutoMarkAsRead();
} }
}, },
[tryAutoMarkAsRead] [tryAutoMarkAsRead, unreadInfo, handleOpenEvent]
) )
); );
@@ -821,34 +878,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}; };
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
markAsRead(mx, room.roomId); markAsRead(mx, room.roomId, hideActivity);
}; };
const handleOpenReply: MouseEventHandler = useCallback( const handleOpenReply: MouseEventHandler = useCallback(
async (evt) => { async (evt) => {
const targetId = evt.currentTarget.getAttribute('data-event-id'); const targetId = evt.currentTarget.getAttribute('data-event-id');
if (!targetId) return; if (!targetId) return;
const replyTimeline = getEventTimeline(room, targetId); handleOpenEvent(targetId);
const absoluteIndex =
replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, targetId);
if (typeof absoluteIndex === 'number') {
scrollToItem(absoluteIndex, {
behavior: 'smooth',
align: 'center',
stopInView: true,
});
setFocusItem({
index: absoluteIndex,
scrollTo: false,
highlight: true,
});
} else {
setTimeline(getEmptyTimeline());
loadEventTimeline(targetId);
}
}, },
[room, timeline, scrollToItem, loadEventTimeline] [handleOpenEvent]
); );
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback( const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
@@ -898,7 +937,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content; const { body, formatted_body: formattedBody } = content;
const { 'm.relates_to': relation } = replyEvt.getOriginalContent(); const { 'm.relates_to': relation } = replyEvt.getWireContent();
const senderId = replyEvt.getSender(); const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') { if (senderId && typeof body === 'string') {
setReplyDraft({ setReplyDraft({
@@ -983,6 +1022,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
edit={editId === mEventId} edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined} relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick} onUserClick={handleUserClick}
@@ -993,7 +1033,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
reply={ reply={
replyEventId && ( replyEventId && (
<Reply <Reply
mx={mx}
room={room} room={room}
timelineSet={timelineSet} timelineSet={timelineSet}
replyEventId={replyEventId} replyEventId={replyEventId}
@@ -1014,6 +1053,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
/> />
) )
} }
hideReadReceipts={hideActivity}
> >
{mEvent.isRedacted() ? ( {mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} /> <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@@ -1028,7 +1068,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
urlPreview={showUrlPreview} urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === 2} outlineAttachment={messageLayout === MessageLayout.Bubble}
/> />
)} )}
</Message> </Message>
@@ -1055,6 +1095,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
edit={editId === mEventId} edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined} relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick} onUserClick={handleUserClick}
@@ -1065,7 +1106,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
reply={ reply={
replyEventId && ( replyEventId && (
<Reply <Reply
mx={mx}
room={room} room={room}
timelineSet={timelineSet} timelineSet={timelineSet}
replyEventId={replyEventId} replyEventId={replyEventId}
@@ -1086,6 +1126,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
/> />
) )
} }
hideReadReceipts={hideActivity}
> >
<EncryptedContent mEvent={mEvent}> <EncryptedContent mEvent={mEvent}>
{() => { {() => {
@@ -1124,7 +1165,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
urlPreview={showUrlPreview} urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === 2} outlineAttachment={messageLayout === MessageLayout.Bubble}
/> />
); );
} }
@@ -1163,6 +1204,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
highlight={highlighted} highlight={highlighted}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined} relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick} onUserClick={handleUserClick}
@@ -1181,6 +1223,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
/> />
) )
} }
hideReadReceipts={hideActivity}
> >
{mEvent.isRedacted() ? ( {mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} /> <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@@ -1208,7 +1251,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
const parsed = parseMemberEvent(mEvent); const parsed = parseMemberEvent(mEvent);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />; const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return ( return (
<Event <Event
@@ -1220,6 +1265,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
highlight={highlighted} highlight={highlighted}
messageSpacing={messageSpacing} messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
> >
<EventContent <EventContent
messageLayout={messageLayout} messageLayout={messageLayout}
@@ -1241,7 +1287,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />; const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return ( return (
<Event <Event
@@ -1253,6 +1301,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
highlight={highlighted} highlight={highlighted}
messageSpacing={messageSpacing} messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
> >
<EventContent <EventContent
messageLayout={messageLayout} messageLayout={messageLayout}
@@ -1275,7 +1324,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />; const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return ( return (
<Event <Event
@@ -1287,6 +1338,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
highlight={highlighted} highlight={highlighted}
messageSpacing={messageSpacing} messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
> >
<EventContent <EventContent
messageLayout={messageLayout} messageLayout={messageLayout}
@@ -1309,7 +1361,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />; const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return ( return (
<Event <Event
@@ -1321,6 +1375,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
highlight={highlighted} highlight={highlighted}
messageSpacing={messageSpacing} messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
> >
<EventContent <EventContent
messageLayout={messageLayout} messageLayout={messageLayout}
@@ -1345,7 +1400,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />; const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return ( return (
<Event <Event
@@ -1357,6 +1414,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
highlight={highlighted} highlight={highlighted}
messageSpacing={messageSpacing} messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
> >
<EventContent <EventContent
messageLayout={messageLayout} messageLayout={messageLayout}
@@ -1386,7 +1444,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />; const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
);
return ( return (
<Event <Event
@@ -1398,6 +1458,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
highlight={highlighted} highlight={highlighted}
messageSpacing={messageSpacing} messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
> >
<EventContent <EventContent
messageLayout={messageLayout} messageLayout={messageLayout}
@@ -1432,6 +1493,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (!mEvent || !mEventId) return null; if (!mEvent || !mEventId) return null;
const eventSender = mEvent.getSender();
if (eventSender && ignoredUsersSet.has(eventSender)) {
return null;
}
if (mEvent.isRedacted() && !showHiddenEvents) {
return null;
}
if (!newDivider && readUptoEventIdRef.current) { if (!newDivider && readUptoEventIdRef.current) {
newDivider = prevEvent?.getId() === readUptoEventIdRef.current; newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
} }
@@ -1442,9 +1511,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const collapsed = const collapsed =
isPrevRendered && isPrevRendered &&
!dayDivider && !dayDivider &&
(!newDivider || mEvent.getSender() === mx.getUserId()) && (!newDivider || eventSender === mx.getUserId()) &&
prevEvent !== undefined && prevEvent !== undefined &&
prevEvent.getSender() === mEvent.getSender() && prevEvent.getSender() === eventSender &&
prevEvent.getType() === mEvent.getType() && prevEvent.getType() === mEvent.getType() &&
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
@@ -1463,7 +1532,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
isPrevRendered = !!eventJSX; isPrevRendered = !!eventJSX;
const newDividerJSX = const newDividerJSX =
newDivider && eventJSX && mEvent.getSender() !== mx.getUserId() ? ( newDivider && eventJSX && eventSender !== mx.getUserId() ? (
<MessageBase space={messageSpacing}> <MessageBase space={messageSpacing}>
<TimelineDivider style={{ color: color.Success.Main }} variant="Inherit"> <TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
<Badge as="span" size="500" variant="Success" fill="Solid" radii="300"> <Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
@@ -1541,7 +1610,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<div <div
style={{ style={{
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${ padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
messageLayout === 1 ? config.space.S400 : toRem(64) messageLayout === MessageLayout.Compact ? config.space.S400 : toRem(64)
}`, }`,
}} }}
> >
@@ -1549,38 +1618,70 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</div> </div>
)} )}
{(canPaginateBack || !rangeAtStart) && {(canPaginateBack || !rangeAtStart) &&
(messageLayout === 1 ? ( (messageLayout === MessageLayout.Compact ? (
<> <>
<CompactPlaceholder /> <MessageBase>
<CompactPlaceholder /> <CompactPlaceholder key={getItems().length} />
<CompactPlaceholder /> </MessageBase>
<CompactPlaceholder /> <MessageBase>
<CompactPlaceholder ref={observeBackAnchor} /> <CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</> </>
) : ( ) : (
<> <>
<DefaultPlaceholder /> <MessageBase>
<DefaultPlaceholder /> <DefaultPlaceholder key={getItems().length} />
<DefaultPlaceholder ref={observeBackAnchor} /> </MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</> </>
))} ))}
{getItems().map(eventRenderer)} {getItems().map(eventRenderer)}
{(!liveTimelineLinked || !rangeAtEnd) && {(!liveTimelineLinked || !rangeAtEnd) &&
(messageLayout === 1 ? ( (messageLayout === MessageLayout.Compact ? (
<> <>
<CompactPlaceholder ref={observeFrontAnchor} /> <MessageBase ref={observeFrontAnchor}>
<CompactPlaceholder /> <CompactPlaceholder key={getItems().length} />
<CompactPlaceholder /> </MessageBase>
<CompactPlaceholder /> <MessageBase>
<CompactPlaceholder /> <CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</> </>
) : ( ) : (
<> <>
<DefaultPlaceholder ref={observeFrontAnchor} /> <MessageBase ref={observeFrontAnchor}>
<DefaultPlaceholder /> <DefaultPlaceholder key={getItems().length} />
<DefaultPlaceholder /> </MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</> </>
))} ))}
<span ref={atBottomAnchorRef} /> <span ref={atBottomAnchorRef} />

View File

@@ -13,13 +13,16 @@ import { RoomTimeline } from './RoomTimeline';
import { RoomViewTyping } from './RoomViewTyping'; import { RoomViewTyping } from './RoomViewTyping';
import { RoomTombstone } from './RoomTombstone'; import { RoomTombstone } from './RoomTombstone';
import { RoomInput } from './RoomInput'; import { RoomInput } from './RoomInput';
import { RoomViewFollowing } from './RoomViewFollowing'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
import { Page } from '../../components/page'; import { Page } from '../../components/page';
import { RoomViewHeader } from './RoomViewHeader'; import { RoomViewHeader } from './RoomViewHeader';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { editableActiveElement } from '../../utils/dom'; import { editableActiveElement } from '../../utils/dom';
import navigation from '../../../client/state/navigation'; import navigation from '../../../client/state/navigation';
import { settingsAtom } from '../../state/settings';
import { useSetting } from '../../state/hooks/settings';
const FN_KEYS_REGEX = /^F\d+$/;
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
const { code } = evt; const { code } = evt;
if (evt.metaKey || evt.altKey || evt.ctrlKey) { if (evt.metaKey || evt.altKey || evt.ctrlKey) {
@@ -27,7 +30,7 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
} }
// do not focus on F keys // do not focus on F keys
if (/^F\d+$/.test(code)) return false; if (FN_KEYS_REGEX.test(code)) return false;
// do not focus on numlock/scroll lock // do not focus on numlock/scroll lock
if ( if (
@@ -56,6 +59,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
const roomInputRef = useRef<HTMLDivElement>(null); const roomInputRef = useRef<HTMLDivElement>(null);
const roomViewRef = useRef<HTMLDivElement>(null); const roomViewRef = useRef<HTMLDivElement>(null);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const { roomId } = room; const { roomId } = room;
const editor = useEditor(); const editor = useEditor();
@@ -132,7 +137,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
</> </>
)} )}
</div> </div>
<RoomViewFollowing room={room} /> {hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
</Box> </Box>
</Page> </Page>
); );

View File

@@ -1,6 +1,14 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes'; import { recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds'; import { DefaultReset, color, config, toRem } from 'folds';
export const RoomViewFollowingPlaceholder = style([
DefaultReset,
{
height: toRem(28),
},
]);
export const RoomViewFollowing = recipe({ export const RoomViewFollowing = recipe({
base: [ base: [
DefaultReset, DefaultReset,

View File

@@ -24,6 +24,10 @@ import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
import { EventReaders } from '../../components/event-readers'; import { EventReaders } from '../../components/event-readers';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
export function RoomViewFollowingPlaceholder() {
return <div className={css.RoomViewFollowingPlaceholder} />;
}
export type RoomViewFollowingProps = { export type RoomViewFollowingProps = {
room: Room; room: Room;
}; };

View File

@@ -19,6 +19,7 @@ import {
Line, Line,
PopOut, PopOut,
RectCords, RectCords,
Badge,
} from 'folds'; } from 'folds';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { JoinRule, Room } from 'matrix-js-sdk'; import { JoinRule, Room } from 'matrix-js-sdk';
@@ -32,7 +33,7 @@ import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
import { useSetSetting } from '../../state/hooks/settings'; import { useSetSetting, useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { useSpaceOptionally } from '../../hooks/useSpace'; import { useSpaceOptionally } from '../../hooks/useSpace';
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils'; import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
@@ -54,6 +55,8 @@ import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
import { BackRouteHandler } from '../../components/BackRouteHandler'; import { BackRouteHandler } from '../../components/BackRouteHandler';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
import { RoomPinMenu } from './room-pin-menu';
type RoomMenuProps = { type RoomMenuProps = {
room: Room; room: Room;
@@ -61,13 +64,14 @@ type RoomMenuProps = {
}; };
const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose }, ref) => { const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevelsContext(); const powerLevels = usePowerLevelsContext();
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
markAsRead(mx, room.roomId); markAsRead(mx, room.roomId, hideActivity);
requestClose(); requestClose();
}; };
@@ -180,14 +184,18 @@ export function RoomViewHeader() {
const room = useRoom(); const room = useRoom();
const space = useSpaceOptionally(); const space = useSpaceOptionally();
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const pinnedEvents = useRoomPinnedEvents(room);
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const ecryptedRoom = !!encryptionEvent; const ecryptedRoom = !!encryptionEvent;
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId)); const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
const name = useRoomName(room); const name = useRoomName(room);
const topic = useRoomTopic(room); const topic = useRoomTopic(room);
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
@@ -205,6 +213,10 @@ export function RoomViewHeader() {
setMenuAnchor(evt.currentTarget.getBoundingClientRect()); setMenuAnchor(evt.currentTarget.getBoundingClientRect());
}; };
const handleOpenPinMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
return ( return (
<PageHeader balance={screenSize === ScreenSize.Mobile}> <PageHeader balance={screenSize === ScreenSize.Mobile}>
<Box grow="Yes" gap="300"> <Box grow="Yes" gap="300">
@@ -297,6 +309,62 @@ export function RoomViewHeader() {
)} )}
</TooltipProvider> </TooltipProvider>
)} )}
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Pinned Messages</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
style={{ position: 'relative' }}
onClick={handleOpenPinMenu}
ref={triggerRef}
aria-pressed={!!pinMenuAnchor}
>
{pinnedEvents.length > 0 && (
<Badge
style={{
position: 'absolute',
left: toRem(3),
top: toRem(3),
}}
variant="Secondary"
size="400"
fill="Solid"
radii="Pill"
>
<Text as="span" size="L400">
{pinnedEvents.length}
</Text>
</Badge>
)}
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={pinMenuAnchor}
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setPinMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
</FocusTrap>
}
/>
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"

View File

@@ -1,5 +1,6 @@
import { MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from 'matrix-js-sdk'; import { MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from 'matrix-js-sdk';
import React, { ReactNode, useEffect, useState } from 'react'; import React, { ReactNode, useEffect, useState } from 'react';
import { MessageEvent } from '../../../../types/matrix/room';
type EncryptedContentProps = { type EncryptedContentProps = {
mEvent: MatrixEvent; mEvent: MatrixEvent;
@@ -7,11 +8,12 @@ type EncryptedContentProps = {
}; };
export function EncryptedContent({ mEvent, children }: EncryptedContentProps) { export function EncryptedContent({ mEvent, children }: EncryptedContentProps) {
const [, toggleDecrypted] = useState(!mEvent.isBeingDecrypted()); const [, toggleEncrypted] = useState(mEvent.getType() === MessageEvent.RoomMessageEncrypted);
useEffect(() => { useEffect(() => {
const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = () => { toggleEncrypted(mEvent.getType() === MessageEvent.RoomMessageEncrypted);
toggleDecrypted((s) => !s); const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (event) => {
toggleEncrypted(event.getType() === MessageEvent.RoomMessageEncrypted);
}; };
mEvent.on(MatrixEventEvent.Decrypted, handleDecrypted); mEvent.on(MatrixEventEvent.Decrypted, handleDecrypted);
return () => { return () => {

View File

@@ -35,6 +35,7 @@ import { useHover, useFocusWithin } from 'react-aria';
import { MatrixEvent, Room } from 'matrix-js-sdk'; import { MatrixEvent, Room } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations'; import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames'; import classNames from 'classnames';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import { import {
AvatarBase, AvatarBase,
BubbleLayout, BubbleLayout,
@@ -51,7 +52,12 @@ import {
getMemberAvatarMxc, getMemberAvatarMxc,
getMemberDisplayName, getMemberDisplayName,
} from '../../../utils/room'; } from '../../../utils/room';
import { getCanonicalAliasOrRoomId, getMxIdLocalPart, isRoomAlias, mxcUrlToHttp } from '../../../utils/matrix'; import {
getCanonicalAliasOrRoomId,
getMxIdLocalPart,
isRoomAlias,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { MessageLayout, MessageSpacing } from '../../../state/settings'; import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
@@ -68,6 +74,8 @@ import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to'; import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers'; import { getViaServers } from '../../../plugins/via-servers';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
import { StateEvent } from '../../../../types/matrix/room';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
@@ -235,9 +243,9 @@ export const MessageSourceCodeItem = as<
const getContent = (evt: MatrixEvent) => const getContent = (evt: MatrixEvent) =>
evt.isEncrypted() evt.isEncrypted()
? { ? {
[`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(), [`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(),
[`<== ORIGINAL_EVENT ==>`]: evt.event, [`<== ORIGINAL_EVENT ==>`]: evt.event,
} }
: evt.event; : evt.event;
const getText = (): string => { const getText = (): string => {
@@ -340,6 +348,46 @@ export const MessageCopyLinkItem = as<
); );
}); });
export const MessagePinItem = as<
'button',
{
room: Room;
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
const mx = useMatrixClient();
const pinnedEvents = useRoomPinnedEvents(room);
const isPinned = pinnedEvents.includes(mEvent.getId() ?? '');
const handlePin = () => {
const eventId = mEvent.getId();
const pinContent: RoomPinnedEventsEventContent = {
pinned: Array.from(pinnedEvents).filter((id) => id !== eventId),
};
if (!isPinned && eventId) {
pinContent.pinned.push(eventId);
}
mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents, pinContent);
onClose?.();
};
return (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Pin} />}
radii="300"
onClick={handlePin}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
{isPinned ? 'Unpin Message' : 'Pin Message'}
</Text>
</MenuItem>
);
});
export const MessageDeleteItem = as< export const MessageDeleteItem = as<
'button', 'button',
{ {
@@ -611,6 +659,7 @@ export type MessageProps = {
edit?: boolean; edit?: boolean;
canDelete?: boolean; canDelete?: boolean;
canSendReaction?: boolean; canSendReaction?: boolean;
canPinEvent?: boolean;
imagePackRooms?: Room[]; imagePackRooms?: Room[];
relations?: Relations; relations?: Relations;
messageLayout: MessageLayout; messageLayout: MessageLayout;
@@ -622,6 +671,7 @@ export type MessageProps = {
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
reply?: ReactNode; reply?: ReactNode;
reactions?: ReactNode; reactions?: ReactNode;
hideReadReceipts?: boolean;
}; };
export const Message = as<'div', MessageProps>( export const Message = as<'div', MessageProps>(
( (
@@ -634,6 +684,7 @@ export const Message = as<'div', MessageProps>(
edit, edit,
canDelete, canDelete,
canSendReaction, canSendReaction,
canPinEvent,
imagePackRooms, imagePackRooms,
relations, relations,
messageLayout, messageLayout,
@@ -645,6 +696,7 @@ export const Message = as<'div', MessageProps>(
onEditId, onEditId,
reply, reply,
reactions, reactions,
hideReadReceipts,
children, children,
...props ...props
}, },
@@ -666,7 +718,7 @@ export const Message = as<'div', MessageProps>(
const headerJSX = !collapse && ( const headerJSX = !collapse && (
<Box <Box
gap="300" gap="300"
direction={messageLayout === 1 ? 'RowReverse' : 'Row'} direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
justifyContent="SpaceBetween" justifyContent="SpaceBetween"
alignItems="Baseline" alignItems="Baseline"
grow="Yes" grow="Yes"
@@ -678,12 +730,12 @@ export const Message = as<'div', MessageProps>(
onContextMenu={onUserClick} onContextMenu={onUserClick}
onClick={onUsernameClick} onClick={onUsernameClick}
> >
<Text as="span" size={messageLayout === 2 ? 'T300' : 'T400'} truncate> <Text as="span" size={messageLayout === MessageLayout.Bubble ? 'T300' : 'T400'} truncate>
<b>{senderDisplayName}</b> <b>{senderDisplayName}</b>
</Text> </Text>
</Username> </Username>
<Box shrink="No" gap="100"> <Box shrink="No" gap="100">
{messageLayout === 0 && hover && ( {messageLayout === MessageLayout.Modern && hover && (
<> <>
<Text as="span" size="T200" priority="300"> <Text as="span" size="T200" priority="300">
{senderId} {senderId}
@@ -693,12 +745,12 @@ export const Message = as<'div', MessageProps>(
</Text> </Text>
</> </>
)} )}
<Time ts={mEvent.getTs()} compact={messageLayout === 1} /> <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
</Box> </Box>
</Box> </Box>
); );
const avatarJSX = !collapse && messageLayout !== 1 && ( const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
<AvatarBase> <AvatarBase>
<Avatar <Avatar
className={css.MessageAvatar} className={css.MessageAvatar}
@@ -942,36 +994,41 @@ export const Message = as<'div', MessageProps>(
</Text> </Text>
</MenuItem> </MenuItem>
)} )}
<MessageReadReceiptItem {!hideReadReceipts && (
room={room} <MessageReadReceiptItem
eventId={mEvent.getId() ?? ''} room={room}
onClose={closeMenu} eventId={mEvent.getId() ?? ''}
/> onClose={closeMenu}
/>
)}
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} /> <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} /> <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && (
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
</Box> </Box>
{((!mEvent.isRedacted() && canDelete) || {((!mEvent.isRedacted() && canDelete) ||
mEvent.getSender() !== mx.getUserId()) && ( mEvent.getSender() !== mx.getUserId()) && (
<> <>
<Line size="300" /> <Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}> <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{!mEvent.isRedacted() && canDelete && ( {!mEvent.isRedacted() && canDelete && (
<MessageDeleteItem <MessageDeleteItem
room={room} room={room}
mEvent={mEvent} mEvent={mEvent}
onClose={closeMenu} onClose={closeMenu}
/> />
)} )}
{mEvent.getSender() !== mx.getUserId() && ( {mEvent.getSender() !== mx.getUserId() && (
<MessageReportItem <MessageReportItem
room={room} room={room}
mEvent={mEvent} mEvent={mEvent}
onClose={closeMenu} onClose={closeMenu}
/> />
)} )}
</Box> </Box>
</> </>
)} )}
</Menu> </Menu>
</FocusTrap> </FocusTrap>
} }
@@ -990,18 +1047,18 @@ export const Message = as<'div', MessageProps>(
</Menu> </Menu>
</div> </div>
)} )}
{messageLayout === 1 && ( {messageLayout === MessageLayout.Compact && (
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}> <CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
{msgContentJSX} {msgContentJSX}
</CompactLayout> </CompactLayout>
)} )}
{messageLayout === 2 && ( {messageLayout === MessageLayout.Bubble && (
<BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}> <BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
{headerJSX} {headerJSX}
{msgContentJSX} {msgContentJSX}
</BubbleLayout> </BubbleLayout>
)} )}
{messageLayout !== 1 && messageLayout !== 2 && ( {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && (
<ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}> <ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
{headerJSX} {headerJSX}
{msgContentJSX} {msgContentJSX}
@@ -1018,9 +1075,23 @@ export type EventProps = {
highlight: boolean; highlight: boolean;
canDelete?: boolean; canDelete?: boolean;
messageSpacing: MessageSpacing; messageSpacing: MessageSpacing;
hideReadReceipts?: boolean;
}; };
export const Event = as<'div', EventProps>( export const Event = as<'div', EventProps>(
({ className, room, mEvent, highlight, canDelete, messageSpacing, children, ...props }, ref) => { (
{
className,
room,
mEvent,
highlight,
canDelete,
messageSpacing,
hideReadReceipts,
children,
...props
},
ref
) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover }); const { hoverProps } = useHover({ onHoverChange: setHover });
@@ -1085,36 +1156,38 @@ export const Event = as<'div', EventProps>(
> >
<Menu {...props} ref={ref}> <Menu {...props} ref={ref}>
<Box direction="Column" gap="100" className={css.MessageMenuGroup}> <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
<MessageReadReceiptItem {!hideReadReceipts && (
room={room} <MessageReadReceiptItem
eventId={mEvent.getId() ?? ''} room={room}
onClose={closeMenu} eventId={mEvent.getId() ?? ''}
/> onClose={closeMenu}
/>
)}
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} /> <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} /> <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
</Box> </Box>
{((!mEvent.isRedacted() && canDelete && !stateEvent) || {((!mEvent.isRedacted() && canDelete && !stateEvent) ||
(mEvent.getSender() !== mx.getUserId() && !stateEvent)) && ( (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
<> <>
<Line size="300" /> <Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}> <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{!mEvent.isRedacted() && canDelete && ( {!mEvent.isRedacted() && canDelete && (
<MessageDeleteItem <MessageDeleteItem
room={room} room={room}
mEvent={mEvent} mEvent={mEvent}
onClose={closeMenu} onClose={closeMenu}
/> />
)} )}
{mEvent.getSender() !== mx.getUserId() && ( {mEvent.getSender() !== mx.getUserId() && (
<MessageReportItem <MessageReportItem
room={room} room={room}
mEvent={mEvent} mEvent={mEvent}
onClose={closeMenu} onClose={closeMenu}
/> />
)} )}
</Box> </Box>
</> </>
)} )}
</Menu> </Menu>
</FocusTrap> </FocusTrap>
} }

View File

@@ -21,7 +21,7 @@ import {
} from 'folds'; } from 'folds';
import { Editor, Transforms } from 'slate'; import { Editor, Transforms } from 'slate';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { IContent, MatrixEvent, RelationType, Room } from 'matrix-js-sdk'; import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { import {
AUTOCOMPLETE_PREFIXES, AUTOCOMPLETE_PREFIXES,
@@ -43,6 +43,7 @@ import {
toPlainText, toPlainText,
trimCustomHtml, trimCustomHtml,
useEditor, useEditor,
getMentions,
} from '../../../components/editor'; } from '../../../components/editor';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
@@ -50,7 +51,7 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
import { EmojiBoard } from '../../../components/emoji-board'; import { EmojiBoard } from '../../../components/emoji-board';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room'; import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
import { mobileOrTablet } from '../../../utils/user-agent'; import { mobileOrTablet } from '../../../utils/user-agent';
type MessageEditorProps = { type MessageEditorProps = {
@@ -74,25 +75,29 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const getPrevBodyAndFormattedBody = useCallback((): [ const getPrevBodyAndFormattedBody = useCallback((): [
string | undefined, string | undefined,
string | undefined string | undefined,
IMentions | undefined
] => { ] => {
const evtId = mEvent.getId()!; const evtId = mEvent.getId()!;
const evtTimeline = room.getTimelineForEvent(evtId); const evtTimeline = room.getTimelineForEvent(evtId);
const editedEvent = const editedEvent =
evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet()); evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
const { body, formatted_body: customHtml }: Record<string, unknown> = const content: IContent = editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent(); const { body, formatted_body: customHtml }: Record<string, unknown> = content;
const mMentions: IMentions | undefined = content['m.mentions'];
return [ return [
typeof body === 'string' ? body : undefined, typeof body === 'string' ? body : undefined,
typeof customHtml === 'string' ? customHtml : undefined, typeof customHtml === 'string' ? customHtml : undefined,
mMentions,
]; ];
}, [room, mEvent]); }, [room, mEvent]);
const [saveState, save] = useAsyncCallback( const [saveState, save] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const plainText = toPlainText(editor.children).trim(); const plainText = toPlainText(editor.children, isMarkdown).trim();
const customHtml = trimCustomHtml( const customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, { toMatrixCustomHTML(editor.children, {
allowTextFormatting: true, allowTextFormatting: true,
@@ -101,7 +106,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
}) })
); );
const [prevBody, prevCustomHtml] = getPrevBodyAndFormattedBody(); const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody();
if (plainText === '') return undefined; if (plainText === '') return undefined;
if (prevBody) { if (prevBody) {
@@ -122,6 +127,15 @@ export const MessageEditor = as<'div', MessageEditorProps>(
body: plainText, body: plainText,
}; };
const mentionData = getMentions(mx, roomId, editor);
prevMentions?.user_ids?.forEach((prevMentionId) => {
mentionData.users.add(prevMentionId);
});
const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
newContent['m.mentions'] = mMentions;
if (!customHtmlEqualsPlainText(customHtml, plainText)) { if (!customHtmlEqualsPlainText(customHtml, plainText)) {
newContent.format = 'org.matrix.custom.html'; newContent.format = 'org.matrix.custom.html';
newContent.formatted_body = customHtml; newContent.formatted_body = customHtml;
@@ -192,8 +206,8 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const initialValue = const initialValue =
typeof customHtml === 'string' typeof customHtml === 'string'
? htmlToEditorInput(customHtml) ? htmlToEditorInput(customHtml, isMarkdown)
: plainToEditorInput(typeof body === 'string' ? body : ''); : plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown);
Transforms.select(editor, { Transforms.select(editor, {
anchor: Editor.start(editor, []), anchor: Editor.start(editor, []),
@@ -202,7 +216,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
editor.insertFragment(initialValue); editor.insertFragment(initialValue);
if (!mobileOrTablet()) ReactEditor.focus(editor); if (!mobileOrTablet()) ReactEditor.focus(editor);
}, [editor, getPrevBodyAndFormattedBody]); }, [editor, getPrevBodyAndFormattedBody, isMarkdown]);
useEffect(() => { useEffect(() => {
if (saveState.status === AsyncStatus.Success) { if (saveState.status === AsyncStatus.Success) {

View File

@@ -1,6 +1,10 @@
import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk'; import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
import to from 'await-to-js'; import to from 'await-to-js';
import { IThumbnailContent, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../types/matrix/common'; import {
IThumbnailContent,
MATRIX_BLUR_HASH_PROPERTY_NAME,
MATRIX_SPOILER_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { import {
getImageFileUrl, getImageFileUrl,
getThumbnail, getThumbnail,
@@ -44,13 +48,15 @@ export const getImageMsgContent = async (
item: TUploadItem, item: TUploadItem,
mxc: string mxc: string
): Promise<IContent> => { ): Promise<IContent> => {
const { file, originalFile, encInfo } = item; const { file, originalFile, encInfo, metadata } = item;
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile))); const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
if (imgError) console.warn(imgError); if (imgError) console.warn(imgError);
const content: IContent = { const content: IContent = {
msgtype: MsgType.Image, msgtype: MsgType.Image,
filename: file.name,
body: file.name, body: file.name,
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
}; };
if (imgEl) { if (imgEl) {
const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height)); const blurHash = encodeBlurHash(imgEl, 512, scaleYDimension(imgEl.width, 512, imgEl.height));
@@ -83,6 +89,7 @@ export const getVideoMsgContent = async (
const content: IContent = { const content: IContent = {
msgtype: MsgType.Video, msgtype: MsgType.Video,
filename: file.name,
body: file.name, body: file.name,
}; };
if (videoEl) { if (videoEl) {
@@ -122,6 +129,7 @@ export const getAudioMsgContent = (item: TUploadItem, mxc: string): IContent =>
const { file, encInfo } = item; const { file, encInfo } = item;
const content: IContent = { const content: IContent = {
msgtype: MsgType.Audio, msgtype: MsgType.Audio,
filename: file.name,
body: file.name, body: file.name,
info: { info: {
mimetype: file.type, mimetype: file.type,

Some files were not shown because too many files have changed in this diff Show More