Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Ajay Bura
a2c8b0175f use room creation event for activity sorting if latest event not found 2026-10-28 22:07:50 +05:30
Ajay Bura
24f5f7d679 add mark as read hook 2026-10-28 21:24:47 +05:30
Ajay Bura
72371de4cb fix unread and notification not updating in UI 2026-10-28 20:21:27 +05:30
Ajay Bura
6f4f9c74b4 add read marker class 2026-10-28 18:27:08 +05:30
Ajay Bura
7374e9693c show read receipt timestamp in seen by dialog 2026-10-28 18:26:10 +05:30
Ajay Bura
ea609d8ea9 fix cached scroll top position ignored when value is 0 (on top) 2026-10-27 22:25:19 +05:30
Ajay Bura
e96de76289 exclude matrix-sdk-crypto-wasm from optimize deps in vite 2026-10-27 22:18:27 +05:30
Ajay Bura
4c29b6e10e remove unread notification when unread count does not appear in room
sync response
2026-10-27 22:01:18 +05:30
Ajay Bura
86e6e539a6 hold unread status and notificatoin count until first sync complete 2026-10-27 21:30:19 +05:30
Ajay Bura
d8cb89ca57 share main timeline logic with thread timeline 2026-10-26 22:38:12 +05:30
Ajay Bura
cfde8f20ee prevent scroll from stopping above when decrypting messages change view
height
2026-10-26 17:02:10 +05:30
Ajay Bura
f7a610adaf WIP - timeline jump to read/reply 2026-10-25 17:38:14 +05:30
Ajay Bura
ef83de5c23 improve timeline behaviour - WIP 2026-10-24 13:26:28 +05:30
Ajay Bura
f979a994e0 remove thread notification on read 2026-10-24 12:16:38 +05:30
Ajay Bura
ad65f10088 use transaction id for handling remote echo 2026-10-18 19:29:11 +05:30
Ajay Bura
08e2b5f93a Merge branch 'dev' into replace-matrix-js-sdk 2026-10-18 15:27:44 +05:30
Ajay Bura
45292022c5 uninstall matrix-js-sdk 2026-10-18 15:24:21 +05:30
Ajay Bura
a978a5b95d change how we encode indexeddb key to prevent conflicts 2026-10-18 14:48:56 +05:30
Ajay Bura
9f51e17b86 add camera preview in call view prescreen 2026-10-18 13:25:20 +05:30
Ajay Bura
f527b5f254 bring speaking member in glance 2026-10-18 12:22:20 +05:30
Ajay Bura
2339eba184 make incomming call dialog works 2026-10-18 11:37:02 +05:30
Ajay Bura
97dbdfe806 show speaking user at first in call status bar 2026-10-18 08:54:11 +05:30
Ajay Bura
a443a5bb31 hide call status bar when call is open 2026-10-18 08:44:42 +05:30
Ajay Bura
c480817e27 when dm member left room display it in room name 2026-10-18 08:28:07 +05:30
Ajay Bura
e5d683d51f move calls to new sdk 2026-10-18 08:17:21 +05:30
Ajay Bura
94e613171a upgrade element call and widget api dependencies 2026-10-07 19:34:20 +05:30
Ajay Bura
054064a543 pass editor to thread view component 2026-09-01 17:35:10 +05:30
Ajay Bura
c4bf52ab7f change reaction remove animation 2026-09-01 15:25:43 +05:30
Ajay Bura
566ac6dfa6 fix reaction local echo redaction 2026-09-01 15:19:52 +05:30
Ajay Bura
a116058362 add local echo for reaction sent from emojiboard 2026-09-01 15:02:01 +05:30
Ajay Bura
691fa34c4c handle reaction tooltip for empty users array 2026-09-01 15:01:04 +05:30
Ajay Bura
f59332cf99 allow reactions in timeline 2026-08-31 15:18:24 +05:30
Ajay Bura
ce0a3b90fe rollback reply to old style 2026-08-29 21:44:22 +05:30
Ajay Bura
521c9d5817 add room list avatar and sort by activity settings 2026-08-28 21:52:20 +05:30
Ajay Bura
bd53aaa96a show room access indicator on room header 2026-08-28 17:20:22 +05:30
Ajay Bura
ffe9b54dac change room input placeholder to show encryption support 2026-08-28 17:01:17 +05:30
Ajay Bura
f7ba5edb3a copy room list before sorting in home and direct tab 2026-08-28 16:52:51 +05:30
Ajay Bura
89f6409e4e fix closed category of home, direct and space tabs 2026-08-28 15:22:56 +05:30
Ajay Bura
21bc1521c9 add missing comparator in unread rooms hook 2026-08-28 15:22:02 +05:30
Ajay Bura
ccc29ae166 create hero member events state to prevent frequent async loads 2026-08-28 13:52:30 +05:30
Ajay Bura
7243e8f10a use direct room mapping for fallback for heroes room name discovery 2026-08-28 12:08:28 +05:30
Ajay Bura
6652e55584 move message editor to new sdk 2026-08-27 23:22:01 +05:30
Ajay Bura
ba35672cba recover from room avatar error on visibility change 2026-08-27 12:30:16 +05:30
Ajay Bura
138561f45b check aggregated redaction on notification, search and pin events rendering 2026-08-26 22:38:12 +05:30
Ajay Bura
1d58c7a68d render message with no content as empty message 2026-08-26 22:30:13 +05:30
Ajay Bura
47ac3cb762 fix reactions with empty content appears in timeline 2026-08-26 22:15:06 +05:30
Ajay Bura
69bc5eab70 move pinned menu to new sdk 2026-08-26 11:27:37 +05:30
Ajay Bura
1ff7e86eb8 fix typo when accessing replace relation 2026-08-26 11:24:50 +05:30
Ajay Bura
8e917d82e9 improve loading speed by stop waiting for outgoing request completion 2026-08-26 10:33:33 +05:30
Ajay Bura
869de1418a fix thread crash of continuwuity server bug 2026-08-25 20:47:31 +05:30
Ajay Bura
7fa5ee9fc6 move search messages to new sdk 2026-08-25 14:45:03 +05:30
Ajay Bura
8ef2ecb419 move notifications to new sdk 2026-08-25 11:25:30 +05:30
Ajay Bura
c727f76466 fix tombstone space not opening new space 2026-08-23 22:48:26 +05:30
Ajay Bura
73c4caf2e1 fix encrypted video not rendering 2026-08-23 18:39:30 +05:30
Ajay Bura
fcb3577cf6 fix infinite re-renders with malformed encrypted event 2026-08-23 17:56:53 +05:30
Ajay Bura
f1267d2338 pass decrypted reply event type to content renderer 2026-08-23 14:36:42 +05:30
Ajay Bura
15638851dd always pick the latest read receipt between events 2026-08-23 07:53:49 +05:30
Ajay Bura
1c023c6e62 request session per origin for better multi account requests 2026-08-22 15:16:53 +05:30
Ajay Bura
79800aeea4 migrate old app settings to multi-account 2026-08-21 17:54:31 +05:30
Ajay Bura
9ce9e82eb4 fix typo in fallback sync store name 2026-08-21 17:42:15 +05:30
Ajay Bura
e05d8b7b9b fix account select content for safari browser 2026-08-21 16:33:06 +05:30
Ajay Bura
ae551a2eee fix account switcher padding 2026-08-21 15:30:55 +05:30
Ajay Bura
2a7f0a7bcc fix settings tab menu offset 2026-08-21 15:19:13 +05:30
Ajay Bura
5272c5d505 upsize settings tab menu item text 2026-08-21 15:13:48 +05:30
Ajay Bura
2d7fb283bd allow single session tab 2026-08-21 15:00:13 +05:30
Ajay Bura
308b7db854 fix logout fails by twice close machine calls 2026-08-21 12:57:16 +05:30
Ajay Bura
a32b92c3c8 allow browser back button to load session when accounts opened 2026-08-21 11:51:43 +05:30
Ajay Bura
40ec962106 add last used badge in account selector 2026-08-21 11:47:32 +05:30
Ajay Bura
94d05c72c3 always open last used account on reloads 2026-08-21 11:33:20 +05:30
Ajay Bura
f69f6f6970 remove unused imports 2026-08-21 11:01:55 +05:30
Ajay Bura
6b0506a2ce add accounts button on login screen 2026-08-21 11:01:37 +05:30
Ajay Bura
91f9f7c399 add multi account - init 2026-08-21 10:41:40 +05:30
Ajay Bura
3409054bc5 move call status renderer to features 2026-08-19 08:10:42 +05:30
Ajay Bura
b9342175a4 fix unread - wip 2026-08-19 08:04:34 +05:30
Ajay Bura
b33deb36c8 fix reply text align 2026-08-18 14:54:15 +05:30
Ajay Bura
ae0fad21bf fix sending message reply spacing 2026-08-18 11:08:58 +05:30
Ajay Bura
ab9a4089bb make edited and redacted message work in replies 2026-08-18 11:03:01 +05:30
Ajay Bura
bd2d565422 wip - fix jump to unread 2026-08-18 09:39:29 +05:30
Ajay Bura
feb29924d2 fix formatting 2026-08-17 15:03:31 +05:30
Ajay Bura
bb013ef29e jump to latest using empty timeline and back pagination to prevent live message lost 2026-08-17 14:59:55 +05:30
Ajay Bura
599e938353 fix typo in main timeline jumpToEvent 2026-08-17 12:05:45 +05:30
Ajay Bura
bec63c409b fix link click in reply 2026-08-16 22:18:04 +05:30
Ajay Bura
3ea993dcef make reply username and content clickable 2026-08-16 22:07:42 +05:30
Ajay Bura
95bfa25b58 render reply in single line and render custom html 2026-08-16 21:38:26 +05:30
Ajay Bura
682d5be11d show following members in thread 2026-08-16 13:09:56 +05:30
Ajay Bura
50b6180796 fix room input focus on mod+a (select all) 2026-08-16 12:39:57 +05:30
Ajay Bura
f82cb15b6c fix typo 2026-08-16 12:36:10 +05:30
Ajay Bura
802949c093 fix thread input focus on thread switch 2026-08-16 12:33:17 +05:30
Ajay Bura
c3e508a8f9 keep the focus at end on initial load 2026-08-16 12:22:29 +05:30
Ajay Bura
e95305c1a0 show draft message in thread selector 2026-08-16 12:15:49 +05:30
Ajay Bura
bad6cdcd8a auto focus thread input on type 2026-08-16 11:33:04 +05:30
Ajay Bura
0647cf1ccb fix event collapse around thread replies 2026-08-15 21:34:21 +05:30
Ajay Bura
de91c6ed76 fix thread timeline reload on fist message 2026-08-15 21:26:01 +05:30
Ajay Bura
f8bcb5f9db render new message divider in timeline 2026-08-15 21:14:37 +05:30
Ajay Bura
a94acadbd8 show unread badge on sidebar 2026-08-15 21:06:03 +05:30
Ajay Bura
188dce0e30 show unread badge in nav items and improve logic 2026-08-15 09:41:21 +05:30
Ajay Bura
ce54cf4317 imporve thread icon and permission 2026-08-15 09:36:34 +05:30
Ajay Bura
e510ba619e add flickering animation on splash screen initial text 2026-08-15 07:51:28 +05:30
Ajay Bura
f6cada048b fix reply not working with thread root 2026-08-14 14:13:10 +05:30
Ajay Bura
447058d0b9 change loading screen message to sparking 2026-08-14 12:19:44 +05:30
Ajay Bura
b5b865986a show root event in thread and imporove timeline and divider 2026-08-14 11:56:02 +05:30
Ajay Bura
cbe5c79e24 add dialog to select authentication method for UIA 2026-08-10 14:09:56 +05:30
Ajay Bura
613d6006ca move device verification setup and reset to new sdk also fix OAuth UIA 2026-08-09 15:15:38 +05:30
Ajay Bura
8133cac919 move device rename input in-place of current name 2026-08-05 18:04:13 +05:30
Ajay Bura
0082a32236 move manual backup to new sdk 2026-08-05 17:47:33 +05:30
Ajay Bura
738025253c fix outgoing verification request not starting emoji verification 2026-08-05 15:52:35 +05:30
Ajay Bura
1447772209 move device verification and megolm backup to new sdk 2026-08-05 12:06:01 +05:30
Ajay Bura
5cbc88fbdb receive verification request 2026-08-02 11:36:30 +05:30
Ajay Bura
c4b36b9064 ‸fix editor crash with opened thread room switch 2026-07-29 09:00:27 +05:30
Ajay Bura
17011b3739 upgrade typescript to v6 2026-07-28 23:06:05 +05:30
Ajay Bura
31e46a7435 move invites to new sdk 2026-07-28 21:21:30 +05:30
Ajay Bura
d057233c45 move leave room dialog to new sdk 2026-07-28 15:01:08 +05:30
Ajay Bura
2d31b474fa fix search dm by username not working 2026-07-28 14:53:31 +05:30
Ajay Bura
1a90795dbf move quick room switcher to new sdk 2026-07-28 12:14:51 +05:30
Ajay Bura
4c07c9a428 remove event timeline option from space options menu 2026-07-28 10:59:47 +05:30
Ajay Bura
e367ba864c move jump to time to new 2026-07-28 10:56:57 +05:30
Ajay Bura
ec74b1a3f4 Merge branch 'dev' into replace-matrix-js-sdk 2026-07-27 17:02:56 +05:30
Ajay Bura
6d48539992 fix empty recent emoji section 2026-07-26 15:50:59 +05:30
Ajay Bura
b385683ae3 fix empty quick emoji section 2026-07-26 15:50:46 +05:30
Ajay Bura
452ad28ef5 fix computed observable remain pending when result is undefined 2026-07-26 15:50:20 +05:30
Ajay Bura
56c1d47a08 move explore communities to new sdk 2026-07-26 13:46:33 +05:30
Ajay Bura
5d9c9d588d add client boot screen options for clear cache and logout 2026-07-26 13:18:32 +05:30
Ajay Bura
5d6dab9cb9 move about settings to new sdk 2026-07-26 12:23:36 +05:30
Ajay Bura
c4f58415db move settings developer tool to new sdk 2026-07-24 21:48:35 +05:30
Ajay Bura
bd5d4f8958 move global emoji and stickers settings to new sdk 2026-07-24 21:28:54 +05:30
Ajay Bura
88bb27c0d1 throw error properly when changing various settings 2026-07-24 21:04:29 +05:30
Ajay Bura
4da96b68f1 move space settings to new sdk 2026-07-24 20:58:25 +05:30
Ajay Bura
bdb9abb2e2 add room account data hook 2026-07-24 15:10:04 +05:30
Ajay Bura
7787055df1 update room devtool to new sdk 2026-07-24 15:09:35 +05:30
Ajay Bura
3a94fe7bf4 move room emoji and stickers to new sdk 2026-07-24 14:00:03 +05:30
Ajay Bura
f54880395b move room permission to new sdk 2026-07-24 10:02:57 +05:30
Ajay Bura
d254565447 move room members to new sdk 2026-07-24 09:48:54 +05:30
Ajay Bura
e56eeace64 add new sdk in general room settings 2026-07-23 22:01:44 +05:30
Ajay Bura
919fe50ac0 add user bio viewer 2026-07-23 21:21:30 +05:30
Ajay Bura
a214fcca2e fix unexpected hdr in images 2026-07-23 13:49:28 +05:30
Ajay Bura
9ba0a132b8 fix profile cover error state 2026-07-23 11:54:37 +05:30
Ajay Bura
4fc9619636 improve timezone 2026-07-23 10:28:26 +05:30
Ajay Bura
758d328a39 add user biography 2026-07-23 09:40:28 +05:30
Ajay Bura
f167780d15 update profile layout and add pronouns 2026-07-23 09:09:04 +05:30
Ajay Bura
199e66b117 move profile message button upwards 2026-07-22 21:15:54 +05:30
Ajay Bura
9a0bba256c display user timezone in profile 2026-07-22 14:09:26 +05:30
Ajay Bura
7dae0fa7cc render profile banner 2026-07-22 12:41:28 +05:30
Ajay Bura
5a786984d0 fix room card spinner color 2026-07-22 11:44:44 +05:30
Ajay Bura
c2f483e0a9 show presence in member drawer 2026-07-22 10:07:24 +05:30
Ajay Bura
edc48f7a5a linkify user status 2026-07-22 09:50:09 +05:30
Ajay Bura
a981a4d47e fix transparent avatar background color in profile 2026-07-22 09:10:06 +05:30
Ajay Bura
12eda423bb remove logs and fix avatar container corner visible 2026-07-21 23:20:27 +05:30
Ajay Bura
33fc3a00a9 display presence status message in profile banner 2026-07-21 23:16:43 +05:30
Ajay Bura
b55e66da7d fix room header 2026-07-21 15:59:19 +05:30
Ajay Bura
3fbf379d97 fix presence appear when doesn't exist 2026-07-21 15:59:05 +05:30
Ajay Bura
c7d4c56a5c Create useRoomMember hook 2026-07-21 15:58:40 +05:30
Ajay Bura
23537b7a76 move room user profile to new sdk 2026-07-21 15:33:11 +05:30
Ajay Bura
baf281bca9 fix restricted room wrong allow type 2026-07-21 10:01:07 +05:30
Ajay Bura
a67f855e09 render create room modal 2026-07-21 09:59:00 +05:30
Ajay Bura
2788c8f813 replace sdk in lobby add room/space and existing window 2026-07-20 22:41:51 +05:30
Ajay Bura
b6e68d9c19 fix creators & history visibility map reactivity 2026-07-20 22:05:53 +05:30
Ajay Bura
48bc35dad6 add roomToTopic map for syncronus reads 2026-07-20 21:44:01 +05:30
Ajay Bura
57ca5e4615 replace sdk from space lobby 2026-07-20 21:30:29 +05:30
Ajay Bura
5f69cc671e fix merge conflict 2026-07-19 22:21:43 +05:30
Ajay Bura
04a80dcd45 merge dev into replace-mstrix-js-sdk 2026-07-19 22:19:16 +05:30
Ajay Bura
c27f45161e add neurone - wip 2026-07-19 19:58:32 +05:30
530 changed files with 28430 additions and 16669 deletions

View file

@ -31,12 +31,15 @@ module.exports = {
'linebreak-style': 0, 'linebreak-style': 0,
'no-underscore-dangle': 0, 'no-underscore-dangle': 0,
"no-shadow": "off", "no-shadow": "off",
"max-classes-per-file": "off",
"no-useless-constructor": "off",
"no-empty-function": "off",
"import/prefer-default-export": "off", "import/prefer-default-export": "off",
"import/extensions": "off", "import/extensions": "off",
"import/no-unresolved": "off", "import/no-unresolved": "off",
"import/no-extraneous-dependencies": [ "import/no-extraneous-dependencies": [
"error", "off",
{ {
devDependencies: true, devDependencies: true,
}, },
@ -58,8 +61,10 @@ module.exports = {
"react-hooks/rules-of-hooks": "error", "react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error", "react-hooks/exhaustive-deps": "error",
"@typescript-eslint/no-useless-constructor": "error",
"@typescript-eslint/no-empty-function": "error",
"@typescript-eslint/no-unused-vars": "error", "@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-shadow": "error" "@typescript-eslint/no-shadow": "error",
}, },
overrides: [ overrides: [
{ {

7
.zed/settings.json Normal file
View file

@ -0,0 +1,7 @@
{
"format_on_save": "on",
"prettier": {
"parser": "",
"allowed": true
}
}

1
call.config.json Normal file
View file

@ -0,0 +1 @@
{}

196
package-lock.json generated
View file

@ -32,20 +32,21 @@
"emojibase-data": "15.3.2", "emojibase-data": "15.3.2",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"focus-trap-react": "10.0.2", "focus-trap-react": "10.0.2",
"folds": "2.7.2", "folds": "2.7.3",
"html-dom-parser": "4.0.0", "html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0", "html-react-parser": "4.2.0",
"i18next": "23.12.2", "i18next": "23.12.2",
"i18next-browser-languagedetector": "8.0.0", "i18next-browser-languagedetector": "8.0.0",
"i18next-http-backend": "4.0.3", "i18next-http-backend": "4.0.3",
"idb": "8.0.3",
"immer": "9.0.16", "immer": "9.0.16",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.6.0", "jotai": "2.6.0",
"linkify-react": "4.3.2", "linkify-react": "4.3.2",
"linkifyjs": "4.3.2", "linkifyjs": "4.3.2",
"matrix-js-sdk": "41.7.0", "matrix-widget-api": "1.20.0",
"matrix-widget-api": "1.16.1",
"millify": "6.1.0", "millify": "6.1.0",
"neurone": "0.0.9",
"pdfjs-dist": "4.2.67", "pdfjs-dist": "4.2.67",
"prismjs": "1.30.0", "prismjs": "1.30.0",
"react": "18.2.0", "react": "18.2.0",
@ -66,7 +67,7 @@
"ua-parser-js": "1.0.35" "ua-parser-js": "1.0.35"
}, },
"devDependencies": { "devDependencies": {
"@element-hq/element-call-embedded": "0.20.1", "@element-hq/element-call-embedded": "0.28.0",
"@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3", "@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1", "@rollup/plugin-wasm": "6.1.1",
@ -95,7 +96,7 @@
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "16.3.2", "lint-staged": "16.3.2",
"prettier": "2.8.1", "prettier": "2.8.1",
"typescript": "4.9.4", "typescript": "6.0.3",
"vite": "5.4.19", "vite": "5.4.19",
"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",
@ -1774,9 +1775,9 @@
} }
}, },
"node_modules/@element-hq/element-call-embedded": { "node_modules/@element-hq/element-call-embedded": {
"version": "0.20.1", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.20.1.tgz", "resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.28.0.tgz",
"integrity": "sha512-ODg2r7UmR8UjRpapLKbn6v1PS8fu/r58zdbvXMYaAlUEAC2f6L/9Moc9S4noG1+ARgWxY+m2vLmNDK9G9uFZYQ==", "integrity": "sha512-wM5tLXrSCLbveGlvQg1Pj6dNAp75mhJe2Zr3Vb8dtK6z9tuuFMcsn1q1Gjy1KYWQdzBzKK2wLc1O8H7DzYZRHQ==",
"dev": true "dev": true
}, },
"node_modules/@emotion/hash": { "node_modules/@emotion/hash": {
@ -6215,19 +6216,6 @@
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"optional": true "optional": true
}, },
"node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/conventional-commit-types": { "node_modules/conventional-commit-types": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/conventional-commit-types/-/conventional-commit-types-3.0.0.tgz", "resolved": "https://registry.npmjs.org/conventional-commit-types/-/conventional-commit-types-3.0.0.tgz",
@ -7906,9 +7894,9 @@
} }
}, },
"node_modules/folds": { "node_modules/folds": {
"version": "2.7.2", "version": "2.7.3",
"resolved": "https://registry.npmjs.org/folds/-/folds-2.7.2.tgz", "resolved": "https://registry.npmjs.org/folds/-/folds-2.7.3.tgz",
"integrity": "sha512-T/PQuN+ku/55hMVabamwsGp1uPSKkFw4xPEf7uZjAZNoejUL1s0leecA00sudYM2HvZaIUzUrosp+f0+8rWC6w==", "integrity": "sha512-oMdMOBmU5RCUI4nmQKiEKpJNh+ZmCijvR4+aalk/btZPp1jIRGha7MXBb2NFOy1y9od+RynNFp+bw6E7GMATBA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peerDependencies": { "peerDependencies": {
"@vanilla-extract/css": "1.9.2", "@vanilla-extract/css": "1.9.2",
@ -8593,10 +8581,10 @@
} }
}, },
"node_modules/idb": { "node_modules/idb": {
"version": "7.1.1", "version": "8.0.3",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"dev": true "license": "ISC"
}, },
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
@ -9026,18 +9014,6 @@
"integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
"dev": true "dev": true
}, },
"node_modules/is-network-error": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz",
"integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-number": { "node_modules/is-number": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@ -9460,15 +9436,6 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -10010,19 +9977,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
} }
}, },
"node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/longest": { "node_modules/longest": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/longest/-/longest-2.0.1.tgz", "resolved": "https://registry.npmjs.org/longest/-/longest-2.0.1.tgz",
@ -10097,40 +10051,10 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/matrix-events-sdk": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz",
"integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==",
"license": "Apache-2.0"
},
"node_modules/matrix-js-sdk": {
"version": "41.7.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz",
"integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^2.0.0",
"jwt-decode": "^4.0.0",
"loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.16.1",
"oidc-client-ts": "^3.0.1",
"p-retry": "8",
"sdp-transform": "^3.0.0",
"unhomoglyph": "^1.0.6"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/matrix-widget-api": { "node_modules/matrix-widget-api": {
"version": "1.16.1", "version": "1.20.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.16.1.tgz", "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.20.0.tgz",
"integrity": "sha512-oCfTV4xNPo02qIgveqdkIyKQjOPpsjhF3bmJBotHrhr8TsrhVa7kx8PtuiUPnQTjz0tdBle7falR2Fw8VKsedw==", "integrity": "sha512-qwFP37rgns7mPesM5/vvZgknzb+kvyRB5BISv5sCuBxsGMceMr+Kogf7OkjRxBXdMCPBFuf6gIuYRe5S+lpzQw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@types/events": "^3.0.0", "@types/events": "^3.0.0",
@ -10382,6 +10306,24 @@
"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
"dev": true "dev": true
}, },
"node_modules/neurone": {
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/neurone/-/neurone-0.0.9.tgz",
"integrity": "sha512-A//2eeyxJLwuDGCZh7N6jNy0tloQ1pgY/0pNna5SrOsj5UPKb2W7U3wE9+2q7sN2kT+0SZr2xijfi06EGkrlyQ==",
"license": "Apache-2.0",
"dependencies": {
"@matrix-org/matrix-sdk-crypto-wasm": "18.3.1",
"another-json": "0.2.0",
"bs58": "6.0.0",
"idb": "8.0.0"
}
},
"node_modules/neurone/node_modules/idb": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz",
"integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==",
"license": "ISC"
},
"node_modules/node-fetch": { "node_modules/node-fetch": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@ -10574,18 +10516,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/oidc-client-ts": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.1.tgz",
"integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==",
"license": "Apache-2.0",
"dependencies": {
"jwt-decode": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -10725,21 +10655,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-retry": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-8.0.0.tgz",
"integrity": "sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==",
"license": "MIT",
"dependencies": {
"is-network-error": "^1.3.0"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -11810,15 +11725,6 @@
"compute-scroll-into-view": "^3.0.2" "compute-scroll-into-view": "^3.0.2"
} }
}, },
"node_modules/sdp-transform": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",
"integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.6.3", "version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
@ -12724,9 +12630,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "4.9.4", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@ -12734,7 +12640,7 @@
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
}, },
"engines": { "engines": {
"node": ">=4.2.0" "node": ">=14.17"
} }
}, },
"node_modules/ua-parser-js": { "node_modules/ua-parser-js": {
@ -12778,12 +12684,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/unhomoglyph": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
"integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==",
"license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": { "node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
@ -13626,6 +13526,13 @@
"workbox-core": "7.3.0" "workbox-core": "7.3.0"
} }
}, },
"node_modules/workbox-background-sync/node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"dev": true,
"license": "ISC"
},
"node_modules/workbox-broadcast-update": { "node_modules/workbox-broadcast-update": {
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.3.0.tgz", "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.3.0.tgz",
@ -13772,6 +13679,13 @@
"workbox-core": "7.3.0" "workbox-core": "7.3.0"
} }
}, },
"node_modules/workbox-expiration/node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"dev": true,
"license": "ISC"
},
"node_modules/workbox-google-analytics": { "node_modules/workbox-google-analytics": {
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.3.0.tgz", "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.3.0.tgz",

View file

@ -56,20 +56,21 @@
"emojibase-data": "15.3.2", "emojibase-data": "15.3.2",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"focus-trap-react": "10.0.2", "focus-trap-react": "10.0.2",
"folds": "2.7.2", "folds": "2.7.3",
"html-dom-parser": "4.0.0", "html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0", "html-react-parser": "4.2.0",
"i18next": "23.12.2", "i18next": "23.12.2",
"i18next-browser-languagedetector": "8.0.0", "i18next-browser-languagedetector": "8.0.0",
"i18next-http-backend": "4.0.3", "i18next-http-backend": "4.0.3",
"idb": "8.0.3",
"immer": "9.0.16", "immer": "9.0.16",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.6.0", "jotai": "2.6.0",
"linkify-react": "4.3.2", "linkify-react": "4.3.2",
"linkifyjs": "4.3.2", "linkifyjs": "4.3.2",
"matrix-js-sdk": "41.7.0", "matrix-widget-api": "1.20.0",
"matrix-widget-api": "1.16.1",
"millify": "6.1.0", "millify": "6.1.0",
"neurone": "0.0.9",
"pdfjs-dist": "4.2.67", "pdfjs-dist": "4.2.67",
"prismjs": "1.30.0", "prismjs": "1.30.0",
"react": "18.2.0", "react": "18.2.0",
@ -90,7 +91,7 @@
"ua-parser-js": "1.0.35" "ua-parser-js": "1.0.35"
}, },
"devDependencies": { "devDependencies": {
"@element-hq/element-call-embedded": "0.20.1", "@element-hq/element-call-embedded": "0.28.0",
"@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3", "@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1", "@rollup/plugin-wasm": "6.1.1",
@ -119,7 +120,7 @@
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "16.3.2", "lint-staged": "16.3.2",
"prettier": "2.8.1", "prettier": "2.8.1",
"typescript": "4.9.4", "typescript": "6.0.3",
"vite": "5.4.19", "vite": "5.4.19",
"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",

View file

@ -14,7 +14,7 @@ import {
Scroll, Scroll,
config, config,
} from 'folds'; } from 'folds';
import { MatrixError } from 'matrix-js-sdk'; import { AccountDataEvent } from 'neurone/lib/api/types';
import { Cursor } from '../plugins/text-area'; import { Cursor } from '../plugins/text-area';
import { syntaxErrorPosition } from '../utils/dom'; import { syntaxErrorPosition } from '../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
@ -28,17 +28,12 @@ const EDITOR_INTENT_SPACE_COUNT = 2;
export type AccountDataSubmitCallback = (type: string, content: object) => Promise<void>; export type AccountDataSubmitCallback = (type: string, content: object) => Promise<void>;
type AccountDataInfo = {
type: string;
content: object;
};
type AccountDataEditProps = { type AccountDataEditProps = {
type: string; type: string;
defaultContent: string; defaultContent: string;
submitChange: AccountDataSubmitCallback; submitChange: AccountDataSubmitCallback;
onCancel: () => void; onCancel: () => void;
onSave: (info: AccountDataInfo) => void; onSave: (info: AccountDataEvent) => void;
}; };
function AccountDataEdit({ function AccountDataEdit({
type, type,
@ -57,7 +52,7 @@ function AccountDataEdit({
EDITOR_INTENT_SPACE_COUNT EDITOR_INTENT_SPACE_COUNT
); );
const [submitState, submit] = useAsyncCallback<void, MatrixError, [string, object]>(submitChange); const [submitState, submit] = useAsyncCallback<void, unknown, [string, object]>(submitChange);
const submitting = submitState.status === AsyncStatus.Loading; const submitting = submitState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => { const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
@ -159,7 +154,9 @@ function AccountDataEdit({
{submitState.status === AsyncStatus.Error && ( {submitState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}> <Text size="T200" style={{ color: color.Critical.Main }}>
<b>{submitState.error.message}</b> <b>
{submitState.error instanceof Error ? submitState.error.message : 'Unknown Error'}
</b>
</Text> </Text>
)} )}
</Box> </Box>
@ -242,34 +239,30 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
} }
export type AccountDataEditorProps = { export type AccountDataEditorProps = {
type?: string; event?: AccountDataEvent;
content?: object;
submitChange: AccountDataSubmitCallback; submitChange: AccountDataSubmitCallback;
requestClose: () => void; requestClose: () => void;
}; };
export function AccountDataEditor({ export function AccountDataEditor({ event, submitChange, requestClose }: AccountDataEditorProps) {
type, const [data, setData] = useState<AccountDataEvent>(
content, event ?? {
submitChange, type: '',
requestClose, content: {},
}: AccountDataEditorProps) { }
const [data, setData] = useState<AccountDataInfo>({ );
type: type ?? '',
content: content ?? {},
});
const [edit, setEdit] = useState(!type); const [edit, setEdit] = useState(!data.type);
const closeEdit = useCallback(() => { const closeEdit = useCallback(() => {
if (!type) { if (!event) {
requestClose(); requestClose();
return; return;
} }
setEdit(false); setEdit(false);
}, [type, requestClose]); }, [event, requestClose]);
const handleSave = useCallback((info: AccountDataInfo) => { const handleSave = useCallback((info: AccountDataEvent) => {
setData(info); setData(info);
setEdit(false); setEdit(false);
}, []); }, []);

View file

@ -1,33 +1,48 @@
import React, { ReactNode } from 'react'; import React, { ReactNode, useState } from 'react';
import { AuthDict, AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk'; import {
import { getUIAFlowForStages } from '../utils/matrix-uia'; config,
Dialog,
Header,
Text,
IconButton,
Box,
Icon,
Icons,
Button,
OverlayBackdrop,
Overlay,
OverlayCenter,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { AuthResponse, AuthType, getUIAFlowForStages, UIAFlow } from '../utils/matrix-uia';
import { useSupportedUIAFlows, useUIACompleted, useUIAFlow } from '../hooks/useUIAFlows'; import { useSupportedUIAFlows, useUIACompleted, useUIAFlow } from '../hooks/useUIAFlows';
import { UIAFlowOverlay } from './UIAFlowOverlay'; import { UIAFlowOverlay } from './UIAFlowOverlay';
import { PasswordStage, SSOStage } from './uia-stages'; import { OAuthStage, PasswordStage, SSOStage } from './uia-stages';
import { useMatrixClient } from '../hooks/useMatrixClient'; import { useClient } from '../hooks/useClient';
export const SUPPORTED_IN_APP_UIA_STAGES = [AuthType.Password, AuthType.Sso]; export const SUPPORTED_IN_APP_UIA_STAGES = [AuthType.Password, AuthType.Sso, AuthType.OAuth];
export const pickUIAFlow = (uiaFlows: UIAFlow[]): UIAFlow | undefined => {
const passwordFlow = getUIAFlowForStages(uiaFlows, [AuthType.Password]);
if (passwordFlow) return passwordFlow;
return getUIAFlowForStages(uiaFlows, [AuthType.Sso]);
};
type ActionUIAProps = { type ActionUIAProps = {
authData: IAuthData; authData: AuthResponse;
ongoingFlow: UIAFlow; ongoingFlow: UIAFlow;
action: (authDict: AuthDict) => void; action: (authDict: object) => void;
onCancel: () => void; onCancel: () => void;
}; };
export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) { export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) {
const mx = useMatrixClient(); const ct = useClient();
const completed = useUIACompleted(authData); const completed = useUIACompleted(authData);
const { getStageToComplete } = useUIAFlow(authData, ongoingFlow); const { getStageToComplete } = useUIAFlow(authData, ongoingFlow);
const stageToComplete = getStageToComplete(); const stageToComplete = getStageToComplete();
if (!stageToComplete) return null; if (!stageToComplete) return null;
const ssoRedirectURL = new URL(`/auth/${AuthType.Sso}/fallback/web`, ct.session.baseUrl);
if (stageToComplete.session) {
ssoRedirectURL.searchParams.set('session', stageToComplete.session);
}
return ( return (
<UIAFlowOverlay <UIAFlowOverlay
currentStep={completed.length + 1} currentStep={completed.length + 1}
@ -36,7 +51,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
> >
{stageToComplete.type === AuthType.Password && ( {stageToComplete.type === AuthType.Password && (
<PasswordStage <PasswordStage
userId={mx.getUserId()!} userId={ct.session.userId}
stageData={stageToComplete} stageData={stageToComplete}
onCancel={onCancel} onCancel={onCancel}
submitAuthDict={action} submitAuthDict={action}
@ -44,30 +59,110 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
)} )}
{stageToComplete.type === AuthType.Sso && stageToComplete.session && ( {stageToComplete.type === AuthType.Sso && stageToComplete.session && (
<SSOStage <SSOStage
ssoRedirectURL={mx.getFallbackAuthUrl(AuthType.Sso, stageToComplete.session)} ssoRedirectURL={ssoRedirectURL.toString()}
stageData={stageToComplete} stageData={stageToComplete}
onCancel={onCancel} onCancel={onCancel}
submitAuthDict={action} submitAuthDict={action}
/> />
)} )}
{stageToComplete.type === AuthType.OAuth && (
<OAuthStage stageData={stageToComplete} onCancel={onCancel} submitAuthDict={action} />
)}
</UIAFlowOverlay> </UIAFlowOverlay>
); );
} }
function UIAFlowPicker({
flows,
onCancel,
onSelect,
}: {
flows: UIAFlow[];
onCancel: () => void;
onSelect: (flow: UIAFlow) => void;
}) {
const passwordFlow = getUIAFlowForStages(flows, [AuthType.Password]);
const ssoFlow = getUIAFlowForStages(flows, [AuthType.Sso]);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap focusTrapOptions={{ initialFocus: false, escapeDeactivates: false }}>
<Dialog>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Authentication</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
as="form"
style={{ padding: `0 ${config.space.S400} ${config.space.S400}` }}
direction="Column"
gap="400"
>
<Box direction="Column" gap="400">
<Text size="T200">Choose an authentication method to perform this action.</Text>
</Box>
<Box direction="Column" gap="200">
{passwordFlow && (
<Button variant="Primary" type="button" onClick={() => onSelect(passwordFlow)}>
<Text as="span" size="B400">
Continue with Password
</Text>
</Button>
)}
{ssoFlow && (
<Button
variant="Secondary"
fill="Soft"
type="button"
onClick={() => onSelect(ssoFlow)}
>
<Text as="span" size="B400">
Continue with SSO
</Text>
</Button>
)}
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
type ActionUIAFlowsLoaderProps = { type ActionUIAFlowsLoaderProps = {
authData: IAuthData; authData: AuthResponse;
onCancel: () => void;
unsupported: () => ReactNode; unsupported: () => ReactNode;
children: (ongoingFlow: UIAFlow) => ReactNode; children: (ongoingFlow: UIAFlow) => ReactNode;
}; };
export function ActionUIAFlowsLoader({ export function ActionUIAFlowsLoader({
authData, authData,
onCancel,
unsupported, unsupported,
children, children,
}: ActionUIAFlowsLoaderProps) { }: ActionUIAFlowsLoaderProps) {
const supportedFlows = useSupportedUIAFlows(authData.flows ?? [], SUPPORTED_IN_APP_UIA_STAGES); const supportedFlows = useSupportedUIAFlows(authData.flows ?? [], SUPPORTED_IN_APP_UIA_STAGES);
const ongoingFlow = supportedFlows.length > 0 ? supportedFlows[0] : undefined; const [ongoingFlow, setOngoingFlow] = useState(
supportedFlows.length === 1 ? supportedFlows[0] : undefined
);
if (!ongoingFlow) return unsupported(); if (supportedFlows.length === 0) return unsupported();
if (!ongoingFlow) {
return <UIAFlowPicker flows={supportedFlows} onCancel={onCancel} onSelect={setOngoingFlow} />;
}
return children(ongoingFlow); return children(ongoingFlow);
} }

View file

@ -1,14 +1,18 @@
import { ReactNode, useCallback, useEffect, useMemo } from 'react'; import { ReactNode, useCallback, useEffect, useMemo } from 'react';
import { MatrixError, createClient } from 'matrix-js-sdk'; import { getLogin, GetLoginOK } from 'neurone/lib/api/matrix/client/v3/login';
import { postRegister } from 'neurone/lib/api/matrix/client/v3/register';
import { MatrixErrorData, parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo'; import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo';
import { promiseFulfilledResult, promiseRejectedResult } from '../utils/common'; import { promiseFulfilledResult } from '../utils/common';
import { import {
AuthFlows, AuthFlows,
RegisterFlowStatus, RegisterFlowStatus,
RegisterFlowsResponse, RegisterFlowsResponse,
parseRegisterErrResp, parseRegisterErrResp,
} from '../hooks/useAuthFlows'; } from '../hooks/useAuthFlows';
import { getJSONRequest } from '../../client/request';
import { AuthResponse } from '../utils/matrix-uia';
type AuthFlowsLoaderProps = { type AuthFlowsLoaderProps = {
fallback?: () => ReactNode; fallback?: () => ReactNode;
@ -19,24 +23,33 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
const autoDiscoveryInfo = useAutoDiscoveryInfo(); const autoDiscoveryInfo = useAutoDiscoveryInfo();
const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url; const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url;
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]); const requestJSON = useMemo(() => getJSONRequest(baseUrl), [baseUrl]);
const [state, load] = useAsyncCallback( const [state, load] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const result = await Promise.allSettled([mx.loginFlows(), mx.registerRequest({})]); const loginEndpoint = getLogin({});
const loginFlows = promiseFulfilledResult(result[0]); const registerEndpoint = postRegister({});
const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined;
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
if (typeof registerResp === 'object' && registerResp.httpStatus) { const [loginResponse, registerResponse] = (
registerFlows = parseRegisterErrResp(registerResp); await Promise.allSettled([requestJSON(loginEndpoint), requestJSON(registerEndpoint)])
).map(promiseFulfilledResult);
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
const [registerError] =
typeof registerResponse === 'object'
? await parseMatrixResp<undefined, AuthResponse | MatrixErrorData>(registerResponse)
: [];
if (registerError) {
registerFlows = parseRegisterErrResp(registerError);
} }
if (!loginFlows) { if (!loginResponse) {
throw new Error('Missing auth flow!'); throw new Error('Missing auth flow!');
} }
if ('errcode' in loginFlows) { const [loginError, loginFlows] = await parseMatrixResp<GetLoginOK>(loginResponse);
throw new Error('Failed to load auth flow!'); if (loginError) {
throw new Error(`Failed to load auth flow! ${printMatrixError(loginError)}`);
} }
const authFlows: AuthFlows = { const authFlows: AuthFlows = {
@ -45,7 +58,7 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
}; };
return authFlows; return authFlows;
}, [mx]) }, [requestJSON])
); );
useEffect(() => { useEffect(() => {

View file

@ -1,6 +1,4 @@
import React, { MouseEventHandler, useCallback, useState } from 'react'; import React, { MouseEventHandler, useCallback, useState } from 'react';
import { useAtom } from 'jotai';
import { CryptoApi, KeyBackupInfo } from 'matrix-js-sdk/lib/crypto-api';
import { import {
Badge, Badge,
Box, Box,
@ -19,130 +17,113 @@ import {
Text, Text,
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { BackupProgressStatus, backupRestoreProgressAtom } from '../state/backupRestore';
import { InfoCard } from './info-card'; 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 { stopPropagation } from '../utils/keyboard';
import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification'; import { bytesToSize } from '../utils/common';
import { useObservableValue } from '../hooks/useObservableValue';
import { useClient } from '../hooks/useClient';
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
type BackupStatusProps = { type BackupStatusProps = {
enabled: boolean; active: boolean;
}; };
function BackupStatus({ enabled }: BackupStatusProps) { function BackupStatus({ active }: BackupStatusProps) {
return ( return (
<Box as="span" gap="100" alignItems="Center"> <Box as="span" gap="100" alignItems="Center">
<Badge variant={enabled ? 'Success' : 'Critical'} fill="Solid" size="200" radii="Pill" /> <Badge variant={active ? 'Success' : 'Critical'} fill="Solid" size="200" radii="Pill" />
<Text <Text
as="span" as="span"
size="L400" size="L400"
style={{ color: enabled ? color.Success.Main : color.Critical.Main }} style={{ color: active ? color.Success.Main : color.Critical.Main }}
> >
{enabled ? 'Connected' : 'Disconnected'} {active ? 'Connected' : 'Disconnected'}
</Text> </Text>
</Box> </Box>
); );
} }
type BackupSyncingProps = {
count: number; function BackupSyncing() {
}; const ct = useClient();
function BackupSyncing({ count }: BackupSyncingProps) { const [countState] = useAsyncCallbackValue(useCallback(() => ct.machine.roomKeys.count(), [ct]));
const count = countState.status === AsyncStatus.Success ? countState.data : undefined;
const remaining = count ? count.total - count.backedUp : undefined;
return ( return (
<Box as="span" gap="100" alignItems="Center"> <Box as="span" gap="100" alignItems="Center">
<Spinner size="50" variant="Primary" fill="Soft" /> <Spinner size="50" variant="Primary" fill="Soft" />
<Text as="span" size="L400" style={{ color: color.Primary.Main }}> <Text as="span" size="L400" style={{ color: color.Primary.Main }}>
Syncing ({count}) Syncing ({remaining ?? '...'})
</Text> </Text>
</Box> </Box>
); );
} }
function BackupProgressFetching() { function BackupProgressFetching({
receivedBytes,
totalBytes,
}: {
receivedBytes: number;
totalBytes?: number;
}) {
const receviedStr = bytesToSize(receivedBytes);
const totalStr = totalBytes ? bytesToSize(totalBytes) : undefined;
return ( return (
<Box grow="Yes" gap="200" alignItems="Center"> <Box grow="Yes" gap="200" alignItems="Center">
<Badge variant="Secondary" fill="Solid" radii="300"> <Badge variant="Secondary" fill="Solid" radii="300">
<Text size="L400">Restoring: 0%</Text> <Text size="L400">Downloading</Text>
</Badge> </Badge>
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<ProgressBar variant="Secondary" size="300" min={0} max={1} value={0} /> <ProgressBar
variant="Secondary"
size="300"
min={0}
max={totalBytes}
value={receivedBytes}
/>
</Box> </Box>
<Spinner size="50" variant="Secondary" fill="Soft" /> <Badge variant="Secondary" fill="Soft" radii="Pill">
<Text size="L400">{totalStr ? `${receivedBytes} / ${totalStr}` : receviedStr}</Text>
</Badge>
</Box> </Box>
); );
} }
type BackupProgressProps = { type BackupProgressProps = {
total: number; total?: number;
downloaded: number; imported: number;
}; };
function BackupProgress({ total, downloaded }: BackupProgressProps) { function BackupProgress({ total, imported }: BackupProgressProps) {
return ( return (
<Box grow="Yes" gap="200" alignItems="Center"> <Box grow="Yes" gap="200" alignItems="Center">
<Badge variant="Secondary" fill="Solid" radii="300"> <Badge variant="Secondary" fill="Solid" radii="300">
<Text size="L400">Restoring: {`${Math.round(percent(0, total, downloaded))}%`}</Text> <Text size="L400">
Restoring: {total ? `${Math.round(percent(0, total, imported))}%` : '0%'}
</Text>
</Badge> </Badge>
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<ProgressBar variant="Secondary" size="300" min={0} max={total} value={downloaded} /> <ProgressBar variant="Secondary" size="300" min={0} max={total} value={imported} />
</Box> </Box>
<Badge variant="Secondary" fill="Soft" radii="Pill"> {total && (
<Text size="L400"> <Badge variant="Secondary" fill="Soft" radii="Pill">
{downloaded} / {total} <Text size="L400">
</Text> {imported} / {total}
</Badge> </Text>
</Box> </Badge>
);
}
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> </Box>
); );
} }
type BackupRestoreTileProps = { export function BackupRestoreTile() {
crypto: CryptoApi; const ct = useClient();
}; const backupInfo = useObservableValue(ct.backupInfo);
export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) { const keyBackup = useObservableValue(ct.megolmBackup.keyBackup);
const [restoreProgress, setRestoreProgress] = useAtom(backupRestoreProgressAtom); const restoreState = useObservableValue(ct.megolmBackup.restoreState);
const restoring = const backupInProgress = useObservableValue(ct.megolmBackup.backupInProgress);
restoreProgress.status === BackupProgressStatus.Fetching || const restoring = restoreState.type === 'download' || restoreState.type === 'import';
restoreProgress.status === BackupProgressStatus.Loading;
const backupEnabled = useKeyBackupStatus(crypto);
const backupInfo = useKeyBackupInfo(crypto);
const [remainingSession, syncFailure] = useKeyBackupSync();
const [menuCords, setMenuCords] = useState<RectCords>(); const [menuCords, setMenuCords] = useState<RectCords>();
@ -150,19 +131,11 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
setMenuCords(evt.currentTarget.getBoundingClientRect()); setMenuCords(evt.currentTarget.getBoundingClientRect());
}; };
const [restoreState, restoreBackup] = useAsyncCallback<void, Error, []>(
useCallback(async () => {
await crypto.restoreKeyBackup({
progressCallback(progress) {
setRestoreProgress(progress);
},
});
}, [crypto, setRestoreProgress])
);
const handleRestore = () => { const handleRestore = () => {
setMenuCords(undefined); setMenuCords(undefined);
restoreBackup(); if (keyBackup) {
ct.megolmBackup.restore(keyBackup);
}
}; };
return ( return (
@ -171,111 +144,99 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
title="Encryption Backup" title="Encryption Backup"
after={ after={
<Box alignItems="Center" gap="200"> <Box alignItems="Center" gap="200">
{remainingSession === 0 ? ( {backupInfo && (
<BackupStatus enabled={backupEnabled} /> <>
) : ( {backupInProgress ? <BackupSyncing /> : <BackupStatus active={!!keyBackup} />}
<BackupSyncing count={remainingSession} /> <IconButton
)} aria-pressed={!!menuCords}
<IconButton size="300"
aria-pressed={!!menuCords} variant="Surface"
size="300" radii="300"
variant="Surface" onClick={handleMenu}
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 <Icon size="100" src={Icons.VerticalDots} />
style={{ </IconButton>
padding: config.space.S100, <PopOut
}} anchor={menuCords}
> offset={5}
<Box direction="Column" gap="100"> position="Bottom"
<Box direction="Column" gap="200"> align="End"
<InfoCard content={
variant="SurfaceVariant" <FocusTrap
title="Backup Details" focusTrapOptions={{
description={ initialFocus: false,
<> onDeactivate: () => setMenuCords(undefined),
<span>Version: {backupInfo?.version ?? 'NIL'}</span> clickOutsideDeactivates: true,
<br /> isKeyForward: (evt: KeyboardEvent) =>
<span>Keys: {backupInfo?.count ?? 'NIL'}</span> evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
</> isKeyBackward: (evt: KeyboardEvent) =>
} evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
/> escapeDeactivates: stopPropagation,
</Box> }}
<Button >
size="300" <Menu style={{ padding: config.space.S100 }}>
variant="Success" <Box direction="Column" gap="100">
radii="300" <Box direction="Column" gap="200">
aria-disabled={restoreState.status === AsyncStatus.Loading || restoring} <InfoCard
onClick={ variant="SurfaceVariant"
restoreState.status === AsyncStatus.Loading || restoring title="Backup Details"
? undefined description={
: handleRestore <>
} <span>Version: {backupInfo.version}</span>
before={<Icon size="100" src={Icons.Download} />} <br />
> <span>Keys: {backupInfo.count}</span>
<Text size="B300">Restore Backup</Text> </>
</Button> }
</Box> />
</Menu> </Box>
</FocusTrap> <Button
} size="300"
/> variant="Success"
radii="300"
aria-disabled={!keyBackup || restoring}
onClick={restoring ? undefined : handleRestore}
before={<Icon size="100" src={Icons.Download} />}
>
<Text size="B300">Restore Backup</Text>
</Button>
</Box>
</Menu>
</FocusTrap>
}
/>
</>
)}
</Box> </Box>
} }
> >
{syncFailure && ( {!backupInfo && (
<Text size="T200" style={{ color: color.Critical.Main }}> <Text size="T200" style={{ color: color.Critical.Main }}>
<b>{syncFailure}</b> <b>No key backup has been configured.</b>
</Text> </Text>
)} )}
{!backupEnabled && backupInfo === null && (
{backupInfo && !keyBackup && (
<Text size="T200" style={{ color: color.Critical.Main }}> <Text size="T200" style={{ color: color.Critical.Main }}>
<b>No backup present on server!</b> <b>Cannot start key backup because the backup could not be verified.</b>
</Text> </Text>
)} )}
{!syncFailure && !backupEnabled && backupInfo && ( {restoreState.type === 'download' && (
<BackupTrustInfo crypto={crypto} backupInfo={backupInfo} /> <BackupProgressFetching
)} receivedBytes={restoreState.receivedBytes}
{restoreState.status === AsyncStatus.Loading && !restoring && <BackupProgressFetching />} totalBytes={restoreState.totalBytes}
{restoreProgress.status === BackupProgressStatus.Fetching && <BackupProgressFetching />}
{restoreProgress.status === BackupProgressStatus.Loading && (
<BackupProgress
total={restoreProgress.data.total}
downloaded={restoreProgress.data.downloaded}
/> />
)} )}
{restoreState.status === AsyncStatus.Error && ( {restoreState.type === 'import' && (
<BackupProgress
imported={restoreState.progress?.imported ?? 0}
total={restoreState.progress?.total}
/>
)}
{restoreState.type === 'error' && (
<Text size="T200" style={{ color: color.Critical.Main }}> <Text size="T200" style={{ color: color.Critical.Main }}>
<b>{restoreState.error.message}</b> <b>{restoreState.error.data.error ?? restoreState.error.data.errcode}</b>
</Text> </Text>
)} )}
</InfoCard> </InfoCard>
); );
} }
export function AutoRestoreBackupOnVerification() {
useRestoreBackupOnVerification();
return null;
}

View file

@ -17,15 +17,10 @@ import {
Text, Text,
toRem, toRem,
} from 'folds'; } from 'folds';
import { import { putSend } from 'neurone/lib/api/matrix/client/v3/rooms';
EventTimelineSetHandlerMap, import { roomTimeline } from 'neurone/lib/utils/sync/v3/join';
EventType, import { ClientEventWithoutRoomId } from 'neurone/lib/api/types';
RelationType, import { ObjectReader } from 'neurone/lib/utils';
Room,
RoomEvent,
} from 'matrix-js-sdk';
import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { import {
CallEmbedContextProvider, CallEmbedContextProvider,
CallEmbedRefContextProvider, CallEmbedRefContextProvider,
@ -39,57 +34,73 @@ import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { CallEmbed } from '../plugins/call'; import { CallEmbed } from '../plugins/call';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { useMatrixClient } from '../hooks/useMatrixClient';
import CallSound from '../../../public/sound/call.ogg'; import CallSound from '../../../public/sound/call.ogg';
import { useCallMembersChange, useCallSession } from '../hooks/useCall'; import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
import { mDirectAtom } from '../state/mDirectList'; import { mDirectAtom } from '../state/mDirectList';
import { useMediaAuthentication } from '../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../utils/matrix';
import { RoomAvatar, RoomIcon } from './room-avatar'; import { RoomAvatar, RoomIcon } from './room-avatar';
import { useRoomNavigate } from '../hooks/useRoomNavigate'; import { useRoomNavigate } from '../hooks/useRoomNavigate';
import { getStateEvent } from '../utils/room'; import { CallEventType, MessageEvent, StateEvent, StateEventType } from '../../types/matrix/room';
import { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
import { useLivekitSupport } from '../hooks/useLivekitSupport'; import { useLivekitSupport } from '../hooks/useLivekitSupport';
import { CallAvatarAnimation } from '../styles/Animations.css'; import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc'; import { webRTCSupported } from '../utils/rtc';
import { useClient } from '../hooks/useClient';
import { useGetRoomName } from '../hooks/storage/useGetRoomName';
import { useRoomHeroes } from '../hooks/storage/useRoomHeros';
import { useGetRoomAvatar } from '../hooks/storage/useGetRoomAvatar';
import { mxcToHttp } from '../../client/mxcToHttp';
import { RelationType } from '../../types/matrix/common';
import { ensureEncryptionSession } from '../hooks/useEnsureRoomEncryptionSession';
import { SyncValue } from '../../client/sync';
import { isCallRoom } from '../utils/room';
import { reference } from '../../client/room/relations';
import { rContent, rMentions, rRelatesTo } from '../../client/reader';
import { getJoinRoomStateId } from '../../client/storage/collections';
import { getPowerLevels } from '../hooks/usePowerLevels';
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
type IncomingCallInfo = { type IncomingCallInfo = {
room: Room; roomId: string;
sender: string; sender: string;
senderTs: number; senderTs: number;
lifetime: number; lifetime: number;
intent?: string; intent?: string;
notificationType: RTCNotificationType; silent?: boolean;
refEventId: string; refEventId: string;
}; };
type IncomingCallProps = { type IncomingCallProps = {
dm: boolean; dm: boolean;
info: IncomingCallInfo; info: IncomingCallInfo;
onIgnore: () => void; onIgnore: () => void;
onAnswer: (room: Room, video: boolean) => void; onAnswer: (roomId: string, video: boolean) => void;
onReject: (room: Room, eventId: string) => void; onReject: (roomId: string, eventId: string) => void;
}; };
function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallProps) { function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallProps) {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const livekitSupported = useLivekitSupport(); const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported(); const rtcSupported = webRTCSupported();
const canAnswer = livekitSupported && rtcSupported; const canAnswer = livekitSupported && rtcSupported;
const { room } = info; const { roomId } = info;
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const roomName = useRoomName(room); const getRoomHeroes = useRoomHeroes();
const roomAvatar = useRoomAvatar(room, dm); const getRoomName = useGetRoomName(getRoomHeroes);
const getRoomAvatar = useGetRoomAvatar(getRoomHeroes);
const roomName = getRoomName(roomId);
const roomAvatar = getRoomAvatar(roomId);
const avatarUrl = roomAvatar const avatarUrl = roomAvatar
? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined ? mxcToHttp(ct.session.baseUrl, roomAvatar, useAuthentication).thumbnail({
width: 96,
height: 96,
method: 'crop',
})
: undefined; : undefined;
const session = useCallSession(room); const session = useCallSession(roomId);
useCallMembersChange( useCallMembersChange(
session, session,
useCallback( useCallback(
@ -108,10 +119,10 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
}, []); }, []);
useEffect(() => { useEffect(() => {
if (info.notificationType === 'ring') { if (!info.silent) {
playSound(); playSound();
} }
}, [playSound, info.notificationType]); }, [playSound, info.silent]);
return ( return (
<> <>
@ -134,14 +145,14 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
<Box shrink="No"> <Box shrink="No">
<Avatar size="500" className={CallAvatarAnimation}> <Avatar size="500" className={CallAvatarAnimation}>
<RoomAvatar <RoomAvatar
roomId={room.roomId} roomId={roomId}
src={avatarUrl} src={avatarUrl}
alt={roomName} alt={roomName}
renderFallback={() => ( renderFallback={() => (
<RoomIcon <RoomIcon
roomType={room.getType()} roomType={ct.roomToType.read.get(roomId)}
size="400" size="400"
joinRule={room.getJoinRule()} joinRule={ct.roomToJoinRule.read.get(roomId)}
filled filled
/> />
)} )}
@ -181,7 +192,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
variant="Success" variant="Success"
size="400" size="400"
radii="400" radii="400"
onClick={() => onAnswer(room, info.intent === 'video')} onClick={() => onAnswer(roomId, info.intent === 'video')}
before={ before={
<Icon <Icon
size="200" size="200"
@ -201,7 +212,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
fill="Soft" fill="Soft"
size="400" size="400"
radii="400" radii="400"
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())} onClick={() => (dm ? onReject(roomId, info.refEventId) : onIgnore())}
before={<Icon size="200" src={Icons.Cross} filled />} before={<Icon size="200" src={Icons.Cross} filled />}
> >
<Text as="span" size="B400"> <Text as="span" size="B400">
@ -226,117 +237,150 @@ type IncomingCallListenerProps = {
joined?: boolean; joined?: boolean;
}; };
function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) { function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) {
const mx = useMatrixClient(); const ct = useClient();
const directs = useAtomValue(mDirectAtom); const directs = useAtomValue(mDirectAtom);
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const [callInfo, setCallInfo] = useState<IncomingCallInfo>(); const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
const dm = callInfo ? directs.has(callInfo.room.roomId) : false; const dm = callInfo ? directs.has(callInfo.roomId) : false;
const startCall = useCallStart(dm); const startCall = useCallStart();
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback( const handleReferenceEvent = useCallback(
async (event, room, toStartOfTimeline, removed, data) => { async (roomId: string, event: ClientEventWithoutRoomId) => {
// only process rtc notification reference events. let targetEvent = event;
// we do not want to wait to decrypt all events.
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
if (room?.isCallRoom()) return;
if (event.isEncrypted()) { if (ct.encryptedRooms.read.has(roomId) && event.type === MessageEvent.RoomMessageEncrypted) {
if (!event.isBeingDecrypted()) { targetEvent = await ct.decryption.tryDecryptEvent(roomId, event);
await event.attemptDecryption(mx.getCrypto() as CryptoBackend);
}
await event.getDecryptionPromise();
} }
if ( if (
!room || targetEvent.type !== CallEventType.RTCInvite ||
event.getType() !== EventType.RTCNotification || targetEvent.sender === ct.session.userId
event.getSender() === mx.getSafeUserId() ||
!data.liveEvent
) { ) {
return; return;
} }
const sender = event.getSender(); const { sender, content } = targetEvent;
const content = event.getContent<IRTCNotificationContent>();
const senderTs = const senderTs = ObjectReader.number(content, 'sender_ts') ?? targetEvent.origin_server_ts;
content.sender_ts - event.getTs() > 20000 ? event.getTs() : content.sender_ts; const lifetime = Math.min(ObjectReader.number(content, 'lifetime') ?? 120000, 120000);
const lifetime = Math.min(content.lifetime, 120000);
const notificationType = content.notification_type; const refEventId = rRelatesTo.eventId(rContent.relatesTo(content));
const relation =
event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined;
const refEventId = relation?.event_id;
const mention = const mention =
content['m.mentions']?.room || rMentions.room(rContent.mentions(content)) ||
content['m.mentions']?.user_ids?.includes(mx.getSafeUserId()); rMentions.userIds(rContent.mentions(content))?.includes(ct.session.userId);
if (!sender || !refEventId || !mention || Date.now() >= senderTs + lifetime) {
if (!refEventId || !mention || Date.now() >= senderTs + lifetime) {
return; return;
} }
const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels); const powerLevelsObj = await ct.storage.joinRoomState.read.get(
const powerLevels = getPowersLevelFromMatrixEvent(powerLevelsEvent); getJoinRoomStateId(roomId, StateEventType.RoomPowerLevels, '')
const creators = getRoomCreatorsForRoomId(mx, room.roomId); );
if (!powerLevelsObj) return;
const powerLevels = getPowerLevels(powerLevelsObj);
const creators = ct.roomToCreators.read.get(roomId);
const permissions = getRoomPermissionsAPI(creators, powerLevels); const permissions = getRoomPermissionsAPI(creators, powerLevels);
const hasCallPermission = permissions.stateEvent( const hasCallPermission = permissions.stateEvent(
StateEvent.GroupCallMemberPrefix, StateEvent.GroupCallMemberPrefix,
mx.getSafeUserId() ct.session.userId
); );
if (!hasCallPermission) return; if (!hasCallPermission) return;
const info: IncomingCallInfo = { const info: IncomingCallInfo = {
room, roomId,
sender, sender,
senderTs, senderTs,
lifetime, lifetime,
intent: intent: ObjectReader.string(content, 'm.call.intent'),
'm.call.intent' in content && typeof content['m.call.intent'] === 'string' silent: !ct.directRooms.read.has(roomId),
? content['m.call.intent']
: undefined,
notificationType,
refEventId, refEventId,
}; };
setCallInfo(info); setCallInfo((currentInfo) => {
if (currentInfo) return currentInfo;
return info;
});
}, },
[mx] [ct]
); );
useEffect(() => { useEffect(() => {
mx.on(RoomEvent.Timeline, handleTimelineEvent); const unsub = ct.sync.observable.subscribe(async (syncValue: SyncValue) => {
const [, data] = syncValue ?? [];
if (!data) return;
Object.keys(data.rooms?.join ?? {}).forEach(async (roomId) => {
if (isCallRoom(ct.roomToType.read.get(roomId))) return;
const joinedRoom = data.rooms?.join?.[roomId];
if (!joinedRoom) return;
const timelineEvents = roomTimeline(joinedRoom);
Array.from(timelineEvents)
.reverse()
.forEach((event) => {
if (reference(event)) handleReferenceEvent(roomId, event);
});
});
});
return () => { return () => {
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent); unsub();
}; };
}, [mx, handleTimelineEvent]); }, [ct, handleReferenceEvent]);
const handleIgnore = useCallback(() => { const handleIgnore = useCallback(() => {
setCallInfo(undefined); setCallInfo(undefined);
}, []); }, []);
const handleReject = useCallback( const handleReject = useCallback(
(room: Room, eventId: string) => { async (roomId: string, eventId: string) => {
mx.sendEvent(room.roomId, EventType.RTCDecline, { setCallInfo(undefined);
const content = {
'm.relates_to': { 'm.relates_to': {
rel_type: RelationType.Reference, rel_type: RelationType.Reference,
event_id: eventId, event_id: eventId,
}, },
}); };
setCallInfo(undefined);
let encContent: object | undefined;
if (ct.encryptedRooms.read.has(roomId)) {
await ensureEncryptionSession(ct, roomId);
const encStr = await ct.machine.roomEncryption.encryptMessageEvent(
roomId,
CallEventType.RTCDecline,
content
);
encContent = JSON.parse(encStr);
}
ct.requestAuthJSON(
putSend({
pathParams: {
roomId,
eventType: encContent ? MessageEvent.RoomMessageEncrypted : CallEventType.RTCDecline,
txnId: ct.getTxnId(),
},
body: encContent ?? content,
})
);
}, },
[mx] [ct]
); );
const handleAnswer = useCallback( const handleAnswer = useCallback(
(room: Room, video: boolean) => { (roomId: string, video: boolean) => {
startCall(room, { microphone: true, video, sound: true }); startCall(roomId, { microphone: true, video, sound: true });
setCallInfo(undefined); setCallInfo(undefined);
navigateRoom(room.roomId); navigateRoom(roomId);
}, },
[startCall, navigateRoom] [startCall, navigateRoom]
); );
if (callInfo && callEmbed?.roomId === callInfo.room.roomId) { if (callInfo && callEmbed?.roomId === callInfo.roomId) {
return null; return null;
} }
return !joined && callInfo ? ( return !joined && callInfo ? (

View file

@ -1,15 +1,23 @@
import { ReactNode, useCallback, useEffect } from 'react'; import { ReactNode, useCallback, useEffect } from 'react';
import { Capabilities } from 'matrix-js-sdk'; import { getCapabilities, GetCapabilitiesOK } from 'neurone/lib/api/matrix/client/v3/capabilities';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient'; import { useClient } from '../hooks/useClient';
import { Capabilities } from '../hooks/useCapabilities';
type CapabilitiesLoaderProps = { type CapabilitiesLoaderProps = {
children: (capabilities: Capabilities | undefined) => ReactNode; children: (capabilities: Capabilities | undefined) => ReactNode;
}; };
export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) { export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) {
const mx = useMatrixClient(); const ct = useClient();
const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(), [mx])); const [state, load] = useAsyncCallback(
useCallback(async () => {
const resp = await ct.requestAuthJSON(getCapabilities({}));
const [, result] = await parseMatrixResp<GetCapabilitiesOK>(resp);
return result?.capabilities;
}, [ct])
);
useEffect(() => { useEffect(() => {
load(); load();

View file

@ -1,11 +1,4 @@
import {
ShowSasCallbacks,
VerificationPhase,
VerificationRequest,
Verifier,
} from 'matrix-js-sdk/lib/crypto-api';
import React, { CSSProperties, useCallback, useEffect, useState } from 'react'; import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
import { import {
Box, Box,
Button, Button,
@ -23,13 +16,24 @@ import {
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { import {
useVerificationRequestPhase, CancelableVerificationStage,
useVerificationRequestReceived, CancelVerificationStage,
useVerifierCancel, DoneVerificationStage,
useVerifierShowSas, InboundRequestStage,
} from '../hooks/useVerificationRequest'; InboundVerification,
OutboundRequestStage,
OutboundVerification,
ReadyVerificationStage,
SasAcceptStage,
SasCancelState,
SasMethod,
SasStartStage,
StartVerificationStage,
} from 'neurone/lib/crypto/machine/verification';
import { useVerificationRequestReceived } from '../hooks/useVerificationRequest';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ContainerColor } from '../styles/ContainerColor.css'; import { ContainerColor } from '../styles/ContainerColor.css';
import { useObservableValue } from '../hooks/useObservableValue';
const DialogHeaderStyles: CSSProperties = { const DialogHeaderStyles: CSSProperties = {
padding: `0 ${config.space.S200} 0 ${config.space.S400}`, padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@ -70,10 +74,12 @@ function VerificationWaitAccept() {
} }
type VerificationAcceptProps = { type VerificationAcceptProps = {
onAccept: () => Promise<void>; stage: InboundRequestStage;
}; };
function VerificationAccept({ onAccept }: VerificationAcceptProps) { function VerificationAccept({ stage }: VerificationAcceptProps) {
const [acceptState, accept] = useAsyncCallback(onAccept); const [acceptState, accept] = useAsyncCallback(
useCallback(() => stage.ready(['m.sas.v1']), [stage])
);
const accepting = acceptState.status === AsyncStatus.Loading; const accepting = acceptState.status === AsyncStatus.Loading;
return ( return (
@ -102,12 +108,12 @@ function VerificationWaitStart() {
} }
type VerificationStartProps = { type VerificationStartProps = {
onStart: () => Promise<void>; stage: ReadyVerificationStage;
}; };
function AutoVerificationStart({ onStart }: VerificationStartProps) { function AutoVerificationStart({ stage }: VerificationStartProps) {
useEffect(() => { useEffect(() => {
onStart(); stage.startSas();
}, [onStart]); }, [stage]);
return ( return (
<Box direction="Column" gap="400"> <Box direction="Column" gap="400">
@ -116,8 +122,8 @@ function AutoVerificationStart({ onStart }: VerificationStartProps) {
); );
} }
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) { function CompareEmoji({ stage }: { stage: SasAcceptStage }) {
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData])); const [confirmState, confirm] = useAsyncCallback(useCallback(() => stage.match(), [stage]));
const confirming = const confirming =
confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success; confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success;
@ -135,17 +141,17 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
wrap="Wrap" wrap="Wrap"
justifyContent="Center" justifyContent="Center"
> >
{sasData.sas.emoji?.map(([emoji, name], index) => ( {stage.emoji?.map((emoji, index) => (
<Box <Box
// eslint-disable-next-line react/no-array-index-key // eslint-disable-next-line react/no-array-index-key
key={`${emoji}${name}${index}`} key={`${emoji.description}${index}`}
direction="Column" direction="Column"
gap="100" gap="100"
justifyContent="Center" justifyContent="Center"
alignItems="Center" alignItems="Center"
> >
<Text size="H1">{emoji}</Text> <Text size="H1">{emoji.emoji}</Text>
<Text size="T200">{name}</Text> <Text size="T200">{emoji.description}</Text>
</Box> </Box>
))} ))}
</Box> </Box>
@ -162,7 +168,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
<Button <Button
variant="Primary" variant="Primary"
fill="Soft" fill="Soft"
onClick={() => sasData.mismatch()} onClick={() => stage.mismatch()}
disabled={confirming} disabled={confirming}
> >
<Text size="B400">Do not Match</Text> <Text size="B400">Do not Match</Text>
@ -173,21 +179,23 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
} }
type SasVerificationProps = { type SasVerificationProps = {
verifier: Verifier; sas: SasMethod;
onCancel: () => void; onCancel: () => void;
}; };
function SasVerification({ verifier, onCancel }: SasVerificationProps) { function SasVerification({ sas, onCancel }: SasVerificationProps) {
const [sasData, setSasData] = useState<ShowSasCallbacks>(); const stage = useObservableValue(sas.stage);
useVerifierShowSas(verifier, setSasData);
useVerifierCancel(verifier, onCancel);
useEffect(() => { useEffect(() => {
verifier.verify(); if (stage instanceof SasStartStage) {
}, [verifier]); stage.accept();
}
if (stage instanceof SasCancelState) {
onCancel();
}
}, [stage, onCancel]);
if (sasData) { if (stage instanceof SasAcceptStage && stage.emoji) {
return <CompareEmoji sasData={sasData} />; return <CompareEmoji stage={stage} />;
} }
return ( return (
@ -214,12 +222,13 @@ function VerificationDone({ onExit }: VerificationDoneProps) {
} }
type VerificationCanceledProps = { type VerificationCanceledProps = {
stage: CancelVerificationStage;
onClose: () => void; onClose: () => void;
}; };
function VerificationCanceled({ onClose }: VerificationCanceledProps) { function VerificationCanceled({ stage, onClose }: VerificationCanceledProps) {
return ( return (
<Box direction="Column" gap="400"> <Box direction="Column" gap="400">
<Text>Verification has been canceled.</Text> <Text>Verification has been canceled. {stage.reason}</Text>
<Button variant="Secondary" fill="Soft" onClick={onClose}> <Button variant="Secondary" fill="Soft" onClick={onClose}>
<Text size="B400">Close</Text> <Text size="B400">Close</Text>
</Button> </Button>
@ -228,23 +237,18 @@ function VerificationCanceled({ onClose }: VerificationCanceledProps) {
} }
type DeviceVerificationProps = { type DeviceVerificationProps = {
request: VerificationRequest; request: InboundVerification | OutboundVerification;
onExit: () => void; onExit: () => void;
}; };
export function DeviceVerification({ request, onExit }: DeviceVerificationProps) { export function DeviceVerification({ request, onExit }: DeviceVerificationProps) {
const phase = useVerificationRequestPhase(request); const stage = useObservableValue(request.stage);
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
if (request.phase !== VerificationPhase.Done && request.phase !== VerificationPhase.Cancelled) { if (stage instanceof CancelableVerificationStage) {
request.cancel(); stage.cancel();
} }
onExit(); onExit();
}, [request, onExit]); }, [stage, onExit]);
const handleAccept = useCallback(() => request.accept(), [request]);
const handleStart = useCallback(async () => {
await request.startVerification(VerificationMethod.Sas);
}, [request]);
return ( return (
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={<OverlayBackdrop />}>
@ -266,30 +270,27 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
</IconButton> </IconButton>
</Header> </Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400"> <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{phase === VerificationPhase.Requested && {stage instanceof InboundRequestStage && <VerificationAccept stage={stage} />}
(request.initiatedByMe ? ( {stage instanceof OutboundRequestStage && <VerificationWaitAccept />}
<VerificationWaitAccept /> {stage instanceof ReadyVerificationStage &&
) : ( (request instanceof OutboundVerification ? (
<VerificationAccept onAccept={handleAccept} /> <AutoVerificationStart stage={stage} />
))}
{phase === VerificationPhase.Ready &&
(request.initiatedByMe ? (
<AutoVerificationStart onStart={handleStart} />
) : ( ) : (
<VerificationWaitStart /> <VerificationWaitStart />
))} ))}
{phase === VerificationPhase.Started && {stage instanceof StartVerificationStage &&
(request.verifier ? ( (stage.method ? (
<SasVerification verifier={request.verifier} onCancel={handleCancel} /> <SasVerification sas={stage.method} onCancel={handleCancel} />
) : ( ) : (
<VerificationUnexpected <VerificationUnexpected
message="Unexpected Error! Verification is started but verifier is missing." message="Unexpected Error! Verification is started but verifier is missing."
onClose={handleCancel} onClose={handleCancel}
/> />
))} ))}
{phase === VerificationPhase.Done && <VerificationDone onExit={onExit} />}
{phase === VerificationPhase.Cancelled && ( {stage instanceof DoneVerificationStage && <VerificationDone onExit={onExit} />}
<VerificationCanceled onClose={handleCancel} /> {stage instanceof CancelVerificationStage && (
<VerificationCanceled stage={stage} onClose={handleCancel} />
)} )}
</Box> </Box>
</Dialog> </Dialog>
@ -300,7 +301,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
} }
export function ReceiveSelfDeviceVerification() { export function ReceiveSelfDeviceVerification() {
const [request, setRequest] = useState<VerificationRequest>(); const [request, setRequest] = useState<InboundVerification | OutboundVerification>();
useVerificationRequestReceived(setRequest); useVerificationRequestReceived(setRequest);
@ -310,7 +311,7 @@ export function ReceiveSelfDeviceVerification() {
if (!request) return null; if (!request) return null;
if (!request.isSelfVerification) { if (!request.selfVerification) {
return null; return null;
} }

View file

@ -1,4 +1,11 @@
import React, { FormEventHandler, forwardRef, useCallback, useState } from 'react'; import React, {
FormEventHandler,
forwardRef,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { import {
Dialog, Dialog,
Header, Header,
@ -12,154 +19,236 @@ import {
Chip, Chip,
color, color,
Spinner, Spinner,
ProgressBar,
} from 'folds'; } from 'folds';
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import to from 'await-to-js'; import { BackupManager } from 'neurone/lib/crypto/machine/backup';
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk'; import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user';
import {
AesHmacSha2,
AesHmacSha2KeyDescription,
DefaultKey,
KeyStorage,
Passphrase,
RecoveryKey,
Secret,
SECRET_STORAGE_ALGORITHM_V1_AES_HMAC_SHA2,
} from 'neurone/lib/crypto/ssss';
import {
MatrixRequestRetrier,
MatrixUIAData,
MatrixUIAHandler,
randomString,
sleep,
} from 'neurone/lib/utils';
import { postVersion, PostVersionBody } from 'neurone/lib/api/matrix/client/v3/roomKeys';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { postDeviceSigningUpload } from 'neurone/lib/api/matrix/client/v3/keys';
import { PasswordInput } from './password-input'; import { PasswordInput } from './password-input';
import { ContainerColor } from '../styles/ContainerColor.css'; import { ContainerColor } from '../styles/ContainerColor.css';
import { copyToClipboard } from '../utils/dom'; import { copyToClipboard } from '../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { clearSecretStorageKeys } from '../../client/secretStorageKeys';
import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA'; import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAlive } from '../hooks/useAlive';
import { UseStateProvider } from './UseStateProvider'; import { UseStateProvider } from './UseStateProvider';
import { useClient } from '../hooks/useClient';
import { getMxIdLocalPart, getMxIdServer } from '../utils/matrix';
import { AccountDataType } from '../../types/matrix/accountData';
import { useAlive } from '../hooks/useAlive';
type UIACallback<T> = ( type SetupProgressChecklist = {
authDict: AuthDict | null bootstrapCrossSinging?: boolean;
) => Promise<[IAuthData, undefined] | [undefined, T]>; putMasterAccountData?: boolean;
putSelfAccountData?: boolean;
type PerformAction<T> = (authDict: AuthDict | null) => Promise<T>; putUserAcountData?: boolean;
verfiyOwnDevice?: boolean;
type UIAAction<T> = { postBackupVersion?: boolean;
authData: IAuthData; putBackupAccountData?: boolean;
callback: UIACallback<T>; putRecoveryKeyAccountData?: boolean;
cancelCallback: () => void; putDefaultKeyAccountData?: boolean;
}; };
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 = { type SetupVerificationProps = {
onComplete: (recoveryKey: string) => void; onComplete: (recoveryKey: string) => void;
}; };
function SetupVerification({ onComplete }: SetupVerificationProps) { function SetupVerification({ onComplete }: SetupVerificationProps) {
const mx = useMatrixClient(); const ct = useClient();
const uia = useMemo(() => new MatrixUIAHandler(), []);
const [uiaData, setUIAData] = useState<MatrixUIAData>();
const [setupProgress, setSetupProgress] = useState<SetupProgressChecklist>();
const alive = useAlive(); const alive = useAlive();
const [uiaAction, setUIAAction] = useState<UIAAction<void>>(); useEffect(() => uia.onUIA.subscribe(setUIAData), [uia]);
const [nextAuthData, setNextAuthData] = useState<IAuthData | null>(); // null means no next action.
const handleAction = useCallback( const handleAction = useCallback(
async (authDict: AuthDict) => { (authDict: object) => {
if (!uiaAction) { setUIAData(undefined);
throw new Error('Unexpected Error! UIA action is perform without data.'); uia.retry(authDict);
}
if (alive()) {
setNextAuthData(null);
}
const [authData] = await uiaAction.callback(authDict);
if (alive() && authData) {
setNextAuthData(authData);
}
}, },
[uiaAction, alive] [uia]
); );
const resetUIA = useCallback(() => { const handleCancel = useCallback(() => {
if (!alive()) return; setUIAData(undefined);
setUIAAction(undefined); uia.clear();
setNextAuthData(undefined); }, [uia]);
}, [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]>( const [setupState, setup] = useAsyncCallback<void, Error, [string | undefined]>(
useCallback( useCallback(
async (passphrase) => { async (passphrase) => {
const crypto = mx.getCrypto(); setSetupProgress({});
if (!crypto) throw new Error('Unexpected Error! Crypto module not found!'); const retrier = new MatrixRequestRetrier(sleep);
const recoveryKeyData = await crypto.createRecoveryKeyFromPassphrase(passphrase); const defaultKeyId = randomString(16);
if (!recoveryKeyData.encodedPrivateKey) {
throw new Error('Unexpected Error! Failed to create recovery key.'); const passData = passphrase ? await Passphrase.create(passphrase) : undefined;
const recoveryKey = passData?.key ?? AesHmacSha2.generateRawKey();
const uploadDeviceSigningKeys = async (bodyStr: string) => {
const body = JSON.parse(bodyStr);
const resp = await retrier.execute(() =>
uia.execute((authData) =>
ct.requestAuthJSON(postDeviceSigningUpload({ body: { ...body, auth: authData } }))
)
);
const [err] = await parseMatrixResp(resp);
if (err) throw new Error(err.data.error ?? err.data.errcode);
};
await ct.machine.crossSigning.bootstrapCrossSigning(uploadDeviceSigningKeys);
const crossSigning = await ct.machine.crossSigning.exportKeys();
if (!crossSigning || !crossSigning.master || !crossSigning.self || !crossSigning.user) {
throw new Error('Failed to reset cross signing. No cross signing keys available.');
} }
clearSecretStorageKeys(); if (alive()) setSetupProgress((v) => ({ ...v, bootstrapCrossSinging: true }));
await crypto.bootstrapSecretStorage({ const masterEnc = await AesHmacSha2.encrypt(
createSecretStorageKey: async () => recoveryKeyData, recoveryKey,
setupNewSecretStorage: true, AccountDataType.CrossSigningMaster,
}); crossSigning.master
);
const selfEnc = await AesHmacSha2.encrypt(
recoveryKey,
AccountDataType.CrossSigningSelf,
crossSigning.self
);
const userEnc = await AesHmacSha2.encrypt(
recoveryKey,
AccountDataType.CrossSigningUser,
crossSigning.user
);
await crypto.bootstrapCrossSigning({ const { userId } = ct.session;
authUploadDeviceSigningKeys, const masterSecret: Secret = { encrypted: { [defaultKeyId]: masterEnc } };
setupNewCrossSigning: true, const selfSecret: Secret = { encrypted: { [defaultKeyId]: selfEnc } };
}); const userSecret: Secret = { encrypted: { [defaultKeyId]: userEnc } };
await retrier.execute(() =>
ct.requestAuthJSON(
putAccountData({
pathParams: { userId, type: AccountDataType.CrossSigningMaster },
body: masterSecret,
})
)
);
if (alive()) setSetupProgress((v) => ({ ...v, putMasterAccountData: true }));
await retrier.execute(() =>
ct.requestAuthJSON(
putAccountData({
pathParams: { userId, type: AccountDataType.CrossSigningSelf },
body: selfSecret,
})
)
);
if (alive()) setSetupProgress((v) => ({ ...v, putSelfAccountData: true }));
await retrier.execute(() =>
ct.requestAuthJSON(
putAccountData({
pathParams: { userId, type: AccountDataType.CrossSigningUser },
body: userSecret,
})
)
);
if (alive()) setSetupProgress((v) => ({ ...v, putUserAcountData: true }));
await crypto.resetKeyBackup(); const device = await ct.machine.userTracker.getDevice(
ct.session.userId,
ct.session.deviceId
);
if (device && !device.crossSigningTrusted) {
const resp = await device.verify();
const [err] = await parseMatrixResp(resp);
if (err) {
throw new Error(err.data.error ?? 'Failed to verify. Signature upload request failed.');
}
await ct.machine.outgoingRequests.queryKeys([ct.session.userId]);
}
if (alive()) setSetupProgress((v) => ({ ...v, verfiyOwnDevice: true }));
onComplete(recoveryKeyData.encodedPrivateKey); const backupKey = BackupManager.createBackupKey();
await ct.megolmBackup.cacheDecryptionKey(backupKey.privateKey);
const backupEnc = await AesHmacSha2.encrypt(
recoveryKey,
AccountDataType.MegolmBackupV1,
backupKey.privateKey
);
const backupAuthData = {
public_key: backupKey.publicKey,
};
await ct.machine.sign(backupAuthData);
const backupVersion: PostVersionBody = {
algorithm: backupKey.algorithm,
auth_data: backupAuthData,
};
await retrier.execute(() => ct.requestAuthJSON(postVersion({ body: backupVersion })));
if (alive()) setSetupProgress((v) => ({ ...v, postBackupVersion: true }));
const backupSecret: Secret = { encrypted: { [defaultKeyId]: backupEnc } };
await retrier.execute(() =>
ct.requestAuthJSON(
putAccountData({
pathParams: { userId, type: AccountDataType.MegolmBackupV1 },
body: backupSecret,
})
)
);
if (alive()) setSetupProgress((v) => ({ ...v, putBackupAccountData: true }));
const { iv, mac } = await AesHmacSha2.driveKeyMetaData(recoveryKey);
const keyDescription: AesHmacSha2KeyDescription = {
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES_HMAC_SHA2,
passphrase: passData?.passphrase,
iv,
mac,
};
await retrier.execute(() =>
ct.requestAuthJSON(
putAccountData({
pathParams: { userId, type: KeyStorage.keyIdentifier(defaultKeyId) },
body: keyDescription,
})
)
);
if (alive()) setSetupProgress((v) => ({ ...v, putRecoveryKeyAccountData: true }));
const defaultKey: DefaultKey = {
key: defaultKeyId,
};
await retrier.execute(() =>
ct.requestAuthJSON(
putAccountData({
pathParams: { userId, type: KeyStorage.defaultKeyIdentifier() },
body: defaultKey,
})
)
);
if (alive()) setSetupProgress((v) => ({ ...v, putDefaultKeyAccountData: true }));
onComplete(RecoveryKey.encode(recoveryKey));
}, },
[mx, onComplete, authUploadDeviceSigningKeys] [ct, uia, onComplete, alive]
) )
); );
@ -179,43 +268,80 @@ function SetupVerification({ onComplete }: SetupVerificationProps) {
setup(passphrase); setup(passphrase);
}; };
const setupSteps: {
key: keyof SetupProgressChecklist;
label: string;
}[] = useMemo(
() => [
{ key: 'bootstrapCrossSinging', label: 'Setting up cross-signing' },
{ key: 'putMasterAccountData', label: 'Securing your master signing key' },
{ key: 'putSelfAccountData', label: 'Securing your self signing key' },
{ key: 'putUserAcountData', label: 'Securing your user signing key' },
{ key: 'verfiyOwnDevice', label: 'Verifying this device' },
{ key: 'postBackupVersion', label: 'Creating your secure backup' },
{ key: 'putBackupAccountData', label: 'Securing your backup key' },
{ key: 'putRecoveryKeyAccountData', label: 'Saving your recovery key information' },
{ key: 'putDefaultKeyAccountData', label: 'Setting up your recovery key' },
],
[]
);
const completedSteps = setupSteps.filter(({ key }) => setupProgress?.[key] === true).length;
return ( return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="400"> <Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
<Text size="T300"> {setupProgress ? (
Generate a <b>Recovery Key</b> for verifying identity if you do not have access to other <Box direction="Column" gap="400">
devices. Additionally, setup a passphrase as a memorable alternative. <Text size="L400">Setup progress</Text>
</Text> <Box style={{ paddingLeft: config.space.S200 }} direction="Column" gap="100">
<Box direction="Column" gap="100"> {setupSteps.map(({ key, label }) => (
<Text size="L400">Passphrase (Optional)</Text> <Text size="T200" key={key}>
<PasswordInput name="passphraseInput" size="400" readOnly={loading} /> {setupProgress[key] ? '✅' : '⏳'} {label}
</Box> </Text>
<Button ))}
type="submit" </Box>
disabled={loading} <ProgressBar variant="Secondary" size="300" min={0} value={completedSteps} />
before={loading && <Spinner size="200" variant="Primary" fill="Solid" />} </Box>
> ) : (
<Text size="B400">Continue</Text> <>
</Button> <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 && ( {setupState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}> <Text size="T200" style={{ color: color.Critical.Main }}>
<b>{setupState.error ? setupState.error.message : 'Unexpected Error!'}</b> <b>{setupState.error ? setupState.error.message : 'Unexpected Error!'}</b>
</Text> </Text>
)} )}
{nextAuthData !== null && uiaAction && ( {uiaData && (
<ActionUIAFlowsLoader <ActionUIAFlowsLoader
authData={nextAuthData ?? uiaAction.authData} authData={uiaData}
onCancel={handleCancel}
unsupported={() => ( unsupported={() => (
<Text size="T200"> <Text size="T200" style={{ color: color.Critical.Main }}>
Authentication steps to perform this action are not supported by client. Authentication steps to perform this action are not supported by client.
</Text> </Text>
)} )}
> >
{(ongoingFlow) => ( {(ongoingFlow) => (
<ActionUIA <ActionUIA
authData={nextAuthData ?? uiaAction.authData} authData={uiaData}
ongoingFlow={ongoingFlow} ongoingFlow={ongoingFlow}
action={handleAction} action={handleAction}
onCancel={uiaAction.cancelCallback} onCancel={handleCancel}
/> />
)} )}
</ActionUIAFlowsLoader> </ActionUIAFlowsLoader>
@ -228,6 +354,7 @@ type RecoveryKeyDisplayProps = {
recoveryKey: string; recoveryKey: string;
}; };
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) { function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const ct = useClient();
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const handleCopy = () => { const handleCopy = () => {
@ -235,10 +362,13 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
}; };
const handleDownload = () => { const handleDownload = () => {
const username = getMxIdLocalPart(ct.session.userId);
const servername = getMxIdServer(ct.session.userId);
const blob = new Blob([recoveryKey], { const blob = new Blob([recoveryKey], {
type: 'text/plain;charset=us-ascii', type: 'text/plain;charset=us-ascii',
}); });
FileSaver.saveAs(blob, 'recovery-key.txt'); FileSaver.saveAs(blob, `Recovery Key - ${username} (${servername}).txt`);
}; };
const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*'); const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
@ -270,12 +400,12 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
</Box> </Box>
</Box> </Box>
<Box direction="Column" gap="200"> <Box direction="Column" gap="200">
<Button onClick={handleCopy}> <Button onClick={handleDownload}>
<Text size="B400">Copy</Text>
</Button>
<Button onClick={handleDownload} fill="Soft">
<Text size="B400">Download</Text> <Text size="B400">Download</Text>
</Button> </Button>
<Button onClick={handleCopy} fill="Soft">
<Text size="B400">Copy</Text>
</Button>
</Box> </Box>
</Box> </Box>
); );

View file

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

View file

@ -13,44 +13,43 @@ import {
Button, Button,
Spinner, Spinner,
} from 'folds'; } from 'folds';
import { JoinRule } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard'; import { stopPropagation } from '../utils/keyboard';
import { getRoomIconSrc } from '../utils/room'; import { getRoomIconSrc } from '../utils/room';
import { JoinRule } from '../../types/matrix/room';
export type ExtraJoinRules = 'knock_restricted'; export type ExtraJoinRules = 'knock_restricted';
export type ExtendedJoinRules = JoinRule | ExtraJoinRules;
type JoinRuleIcons = Record<ExtendedJoinRules, IconSrc>; type JoinRuleIcons = Record<JoinRule, IconSrc>;
export const useJoinRuleIcons = (roomType?: string): JoinRuleIcons => export const useJoinRuleIcons = (roomType?: string): JoinRuleIcons =>
useMemo( useMemo(
() => ({ () => ({
[JoinRule.Invite]: getRoomIconSrc(Icons, roomType, JoinRule.Invite), invite: getRoomIconSrc(Icons, roomType, 'invite'),
[JoinRule.Knock]: getRoomIconSrc(Icons, roomType, JoinRule.Knock), knock: getRoomIconSrc(Icons, roomType, 'knock'),
knock_restricted: getRoomIconSrc(Icons, roomType, JoinRule.Restricted), restricted: getRoomIconSrc(Icons, roomType, 'restricted'),
[JoinRule.Restricted]: getRoomIconSrc(Icons, roomType, JoinRule.Restricted), knock_restricted: getRoomIconSrc(Icons, roomType, 'knock_restricted'),
[JoinRule.Public]: getRoomIconSrc(Icons, roomType, JoinRule.Public), public: getRoomIconSrc(Icons, roomType, 'public'),
[JoinRule.Private]: getRoomIconSrc(Icons, roomType, JoinRule.Private), private: getRoomIconSrc(Icons, roomType, 'private'),
}), }),
[roomType] [roomType]
); );
type JoinRuleLabels = Record<ExtendedJoinRules, string>; type JoinRuleLabels = Record<JoinRule, string>;
export const useRoomJoinRuleLabel = (): JoinRuleLabels => export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
useMemo( useMemo(
() => ({ () => ({
[JoinRule.Invite]: 'Invite Only', invite: 'Invite Only',
[JoinRule.Knock]: 'Knock & Invite', knock: 'Knock & Invite',
knock_restricted: 'Space Members or Knock', knock_restricted: 'Space Members or Knock',
[JoinRule.Restricted]: 'Space Members', restricted: 'Space Members',
[JoinRule.Public]: 'Public', public: 'Public',
[JoinRule.Private]: 'Invite Only', private: 'Invite Only',
}), }),
[] []
); );
type JoinRulesSwitcherProps<T extends ExtendedJoinRules[]> = { type JoinRulesSwitcherProps<T extends JoinRule[]> = {
icons: JoinRuleIcons; icons: JoinRuleIcons;
labels: JoinRuleLabels; labels: JoinRuleLabels;
rules: T; rules: T;
@ -59,7 +58,7 @@ type JoinRulesSwitcherProps<T extends ExtendedJoinRules[]> = {
disabled?: boolean; disabled?: boolean;
changing?: boolean; changing?: boolean;
}; };
export function JoinRulesSwitcher<T extends ExtendedJoinRules[]>({ export function JoinRulesSwitcher<T extends JoinRule[]>({
icons, icons,
labels, labels,
rules, rules,
@ -75,7 +74,7 @@ export function JoinRulesSwitcher<T extends ExtendedJoinRules[]>({
}; };
const handleChange = useCallback( const handleChange = useCallback(
(selectedRule: ExtendedJoinRules) => { (selectedRule: JoinRule) => {
setCords(undefined); setCords(undefined);
onChange(selectedRule); onChange(selectedRule);
}, },
@ -127,7 +126,7 @@ export function JoinRulesSwitcher<T extends ExtendedJoinRules[]>({
fill="Soft" fill="Soft"
radii="300" radii="300"
outlined outlined
before={<Icon size="100" src={icons[value] ?? icons[JoinRule.Restricted]} />} before={<Icon size="100" src={icons[value] ?? 'restricted'} />}
after={ after={
changing ? ( changing ? (
<Spinner size="100" variant="Secondary" fill="Soft" /> <Spinner size="100" variant="Secondary" fill="Soft" />

View file

@ -1,10 +1,9 @@
import React, { forwardRef, useCallback } from 'react'; import React, { forwardRef, useCallback } from 'react';
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds'; import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { logoutClient } from '../../client/initMatrix'; import { useClient } from '../hooks/useClient';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useCrossSigningActive } from '../hooks/useCrossSigning';
import { InfoCard } from './info-card'; import { InfoCard } from './info-card';
import { useCrossSigning } from '../hooks/useCrossSigning';
import { import {
useDeviceVerificationStatus, useDeviceVerificationStatus,
VerificationStatus, VerificationStatus,
@ -15,19 +14,16 @@ type LogoutDialogProps = {
}; };
export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>( export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
({ handleClose }, ref) => { ({ handleClose }, ref) => {
const mx = useMatrixClient(); const ct = useClient();
const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent()); const hasEncryptedRoom = ct.encryptedRooms.read.size > 0;
const crossSigningActive = useCrossSigningActive(); const crossSigning = useCrossSigning();
const verificationStatus = useDeviceVerificationStatus(
mx.getCrypto(), const verificationStatus = useDeviceVerificationStatus(ct.session.userId, ct.session.deviceId);
mx.getSafeUserId(),
mx.getDeviceId() ?? undefined
);
const [logoutState, logout] = useAsyncCallback<void, Error, []>( const [logoutState, logout] = useAsyncCallback<void, Error, []>(
useCallback(async () => { useCallback(async () => {
await logoutClient(mx); await ct.logout();
}, [mx]) }, [ct])
); );
const ongoingLogout = logoutState.status === AsyncStatus.Loading; const ongoingLogout = logoutState.status === AsyncStatus.Loading;
@ -48,7 +44,7 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
</Header> </Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400"> <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{hasEncryptedRoom && {hasEncryptedRoom &&
(crossSigningActive ? ( (crossSigning ? (
verificationStatus === VerificationStatus.Unverified && ( verificationStatus === VerificationStatus.Unverified && (
<InfoCard <InfoCard
variant="Critical" variant="Critical"

View file

@ -13,13 +13,15 @@ import {
color, color,
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { AesHmacSha2, AesHmacSha2KeyDescription } from 'neurone/lib/crypto/ssss';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { stopPropagation } from '../utils/keyboard'; import { stopPropagation } from '../utils/keyboard';
import { SettingTile } from './setting-tile'; import { SettingTile } from './setting-tile';
import { SecretStorageKeyContent } from '../../types/matrix/accountData';
import { SecretStorageRecoveryKey, SecretStorageRecoveryPassphrase } from './SecretStorage'; import { SecretStorageRecoveryKey, SecretStorageRecoveryPassphrase } from './SecretStorage';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { storePrivateKey } from '../../client/secretStorageKeys'; import { useClient } from '../hooks/useClient';
import { getCrossSigningPrivateKeys, useCrossSigningSecretData } from '../hooks/useCrossSigning';
import { AccountDataType } from '../../types/matrix/accountData';
export enum ManualVerificationMethod { export enum ManualVerificationMethod {
RecoveryPassphrase = 'passphrase', RecoveryPassphrase = 'passphrase',
@ -112,7 +114,7 @@ export function ManualVerificationMethodSwitcher({
type ManualVerificationTileProps = { type ManualVerificationTileProps = {
secretStorageKeyId: string; secretStorageKeyId: string;
secretStorageKeyContent: SecretStorageKeyContent; secretStorageKeyContent: AesHmacSha2KeyDescription;
options?: ReactNode; options?: ReactNode;
}; };
export function ManualVerificationTile({ export function ManualVerificationTile({
@ -120,7 +122,8 @@ export function ManualVerificationTile({
secretStorageKeyContent, secretStorageKeyContent,
options, options,
}: ManualVerificationTileProps) { }: ManualVerificationTileProps) {
const mx = useMatrixClient(); const ct = useClient();
const csSecretData = useCrossSigningSecretData(secretStorageKeyId);
const hasPassphrase = !!secretStorageKeyContent.passphrase; const hasPassphrase = !!secretStorageKeyContent.passphrase;
const [method, setMethod] = useState( const [method, setMethod] = useState(
@ -130,25 +133,53 @@ export function ManualVerificationTile({
); );
const verifyAndRestoreBackup = useCallback( const verifyAndRestoreBackup = useCallback(
async (recoveryKey: Uint8Array) => { async (recoveryKey: Uint8Array<ArrayBuffer>) => {
const crypto = mx.getCrypto(); const status = await ct.machine.crossSigning.getStatus();
if (!crypto) { const machineHasKeys = status.master && status.self && status.user;
throw new Error('Unexpected Error! Crypto object not found.');
if (!machineHasKeys && csSecretData) {
const crossSigningKeys = await getCrossSigningPrivateKeys(recoveryKey, csSecretData);
const s = await ct.machine.crossSigning.importKeys(crossSigningKeys);
if (!s.master || !s.self || !s.user) {
throw new Error('Failed to import to CrossSigningSecrets keys!');
}
} }
storePrivateKey(secretStorageKeyId, recoveryKey); const { userId, deviceId } = ct.session;
const device = await ct.machine.userTracker.getDevice(userId, deviceId);
if (device && !device.crossSigningTrusted) {
const resp = await device.verify();
const [err] = await parseMatrixResp(resp);
if (err) {
throw new Error(err.data.error ?? 'Failed to verify. Signature upload request failed.');
}
await crypto.bootstrapCrossSigning({}); // device.verify uploads signatures to mark device as verifed
await crypto.bootstrapSecretStorage({}); // query keys to receive signs now instead of update via /sync
// if we don't do that device remains unverified until next sync device_list change
await ct.machine.outgoingRequests.queryKeys([ct.session.userId]);
}
await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); const backupKeyEncryptedData = ct.secrets.megolmBackup.read?.get(secretStorageKeyId);
if (!backupKeyEncryptedData) {
return;
}
const backupKey = await AesHmacSha2.decrypt(
recoveryKey,
AccountDataType.MegolmBackupV1,
backupKeyEncryptedData
);
await ct.megolmBackup.saveDecryptionKey(backupKey);
}, },
[mx, secretStorageKeyId] [ct, secretStorageKeyId, csSecretData]
); );
const [verifyState, handleDecodedRecoveryKey] = useAsyncCallback<void, Error, [Uint8Array]>( const [verifyState, handleDecodedRecoveryKey] = useAsyncCallback<
verifyAndRestoreBackup void,
); Error,
[Uint8Array<ArrayBuffer>]
>(verifyAndRestoreBackup);
const verifying = verifyState.status === AsyncStatus.Loading; const verifying = verifyState.status === AsyncStatus.Loading;
return ( return (
@ -174,7 +205,7 @@ export function ManualVerificationTile({
{method === ManualVerificationMethod.RecoveryKey && ( {method === ManualVerificationMethod.RecoveryKey && (
<SecretStorageRecoveryKey <SecretStorageRecoveryKey
processing={verifying} processing={verifying}
keyContent={secretStorageKeyContent} keyDescription={secretStorageKeyContent}
onDecodedRecoveryKey={handleDecodedRecoveryKey} onDecodedRecoveryKey={handleDecodedRecoveryKey}
/> />
)} )}
@ -182,8 +213,8 @@ export function ManualVerificationTile({
secretStorageKeyContent.passphrase && ( secretStorageKeyContent.passphrase && (
<SecretStorageRecoveryPassphrase <SecretStorageRecoveryPassphrase
processing={verifying} processing={verifying}
keyContent={secretStorageKeyContent} keyDescription={secretStorageKeyContent}
passphraseContent={secretStorageKeyContent.passphrase} passphraseInfo={secretStorageKeyContent.passphrase}
onDecodedRecoveryKey={handleDecodedRecoveryKey} onDecodedRecoveryKey={handleDecodedRecoveryKey}
/> />
)} )}

View file

@ -1,15 +1,25 @@
import { ReactNode, useCallback, useEffect } from 'react'; import { ReactNode, useCallback, useEffect } from 'react';
import { getConfig, GetConfigOK } from 'neurone/lib/api/matrix/client/v1/media';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { MediaConfig } from '../hooks/useMediaConfig'; import { MediaConfig } from '../hooks/useMediaConfig';
import { useClient } from '../hooks/useClient';
type MediaConfigLoaderProps = { type MediaConfigLoaderProps = {
children: (mediaConfig: MediaConfig | undefined) => ReactNode; children: (mediaConfig: MediaConfig | undefined) => ReactNode;
}; };
export function MediaConfigLoader({ children }: MediaConfigLoaderProps) { export function MediaConfigLoader({ children }: MediaConfigLoaderProps) {
const mx = useMatrixClient(); const ct = useClient();
const [state, load] = useAsyncCallback(useCallback(() => mx.getMediaConfig(), [mx])); const [state, load] = useAsyncCallback(
useCallback(async () => {
const resp = await ct.requestAuthJSON(getConfig({}));
const [, config] = await parseMatrixResp<GetConfigOK>(resp);
return config;
}, [ct])
);
useEffect(() => { useEffect(() => {
load(); load();

View file

@ -0,0 +1,40 @@
import React from 'react';
import { MessageEventWithoutRoomId } from 'neurone/lib/api/types';
import { DecryptionManager } from '../../client/decryption';
import { MessageEvent } from '../../types/matrix/room';
import { EncryptedMessageEvent } from './encrypted-event';
type MessageContentProps = {
event: MessageEventWithoutRoomId;
editedEvent?: MessageEventWithoutRoomId;
roomId: string;
decryptionManager: DecryptionManager;
reply?: boolean;
children: (event: MessageEventWithoutRoomId) => React.ReactNode;
};
export function MessageContent({
event,
editedEvent,
roomId,
decryptionManager,
reply,
children,
}: MessageContentProps) {
const effectiveEvent = editedEvent ?? event;
if (effectiveEvent.type === MessageEvent.RoomMessageEncrypted) {
return (
<EncryptedMessageEvent
roomId={roomId}
decryptionManager={decryptionManager}
event={effectiveEvent}
reply={reply}
>
{(decryptedEvent) => children(decryptedEvent)}
</EncryptedMessageEvent>
);
}
return children(effectiveEvent);
}

View file

@ -1,43 +1,30 @@
import React from 'react'; import React from 'react';
import { MsgType } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser'; import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs'; import { Opts } from 'linkifyjs';
import { config } from 'folds'; import { config } from 'folds';
import { import {
AudioContent, EmptyContent,
DownloadFile,
FileContent,
ImageContent,
MAudio, MAudio,
MBadEncrypted,
MEmote, MEmote,
MFile, MFile,
MImage, MImage,
MLocation, MLocation,
MNotice, MNotice,
MSticker,
MText, MText,
MVideo, MVideo,
ReadPdfFile,
ReadTextFile,
RenderBody,
ThumbnailContent,
UnsupportedContent, UnsupportedContent,
VideoContent,
} from './message'; } from './message';
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview'; import { MsgType } from '../../types/matrix/common';
import { Image, MediaControl, Video } from './media'; import { rContent } from '../../client/reader';
import { ImageViewer } from './image-viewer'; import { MessageEvent } from '../../types/matrix/room';
import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to';
import { IImageContent } from '../../types/matrix/common';
type RenderMessageContentProps = { type RenderMessageContentProps = {
displayName: string; displayName: string;
msgType: string; eventType: string;
ts: number; ts: number;
edited?: boolean; edited?: boolean;
getContent: <T>() => T; content: object;
mediaAutoLoad?: boolean; mediaAutoLoad?: boolean;
urlPreview?: boolean; urlPreview?: boolean;
highlightRegex?: RegExp; highlightRegex?: RegExp;
@ -47,10 +34,10 @@ type RenderMessageContentProps = {
}; };
export function RenderMessageContent({ export function RenderMessageContent({
displayName, displayName,
msgType, eventType,
ts, ts,
edited, edited,
getContent, content,
mediaAutoLoad, mediaAutoLoad,
urlPreview, urlPreview,
highlightRegex, highlightRegex,
@ -58,90 +45,53 @@ export function RenderMessageContent({
linkifyOpts, linkifyOpts,
outlineAttachment, outlineAttachment,
}: RenderMessageContentProps) { }: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => { if (eventType === MessageEvent.Sticker) {
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
if (filteredUrls.length === 0) return undefined;
return ( return (
<UrlPreviewHolder> <MSticker
{filteredUrls.map((url) => ( content={content}
<UrlPreviewCard key={url} url={url} ts={ts} /> mediaAutoLoad={mediaAutoLoad}
))} outlineAttachment={outlineAttachment}
</UrlPreviewHolder> />
); );
}; }
if (eventType !== MessageEvent.RoomMessage) {
return <UnsupportedContent eventType={eventType} />;
}
const renderCaption = () => { const renderCaption = () => {
const content: IImageContent = getContent(); const filename = rContent.filename(content);
if (content.filename && content.filename !== content.body) { const body = rContent.body(content);
if (filename && filename !== body && body) {
return ( return (
<MText <MText
style={{ marginTop: config.space.S200 }} style={{ marginTop: config.space.S100 }}
edited={edited} edited={edited}
content={content} content={content}
renderBody={(props) => ( urlPreview={urlPreview}
<RenderBody ts={ts}
{...props} highlightRegex={highlightRegex}
highlightRegex={highlightRegex} htmlReactParserOptions={htmlReactParserOptions}
htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/> />
); );
} }
return null; return null;
}; };
const renderFile = () => ( const msgType = rContent.msgtype(content);
<>
<MFile
content={getContent()}
renderFileContent={({ body, mimeType, info, encInfo, url }) => (
<FileContent
body={body}
mimeType={mimeType}
renderAsPdfFile={() => (
<ReadPdfFile
body={body}
mimeType={mimeType}
url={url}
encInfo={encInfo}
renderViewer={(p) => <PdfViewer {...p} />}
/>
)}
renderAsTextFile={() => (
<ReadTextFile
body={body}
mimeType={mimeType}
url={url}
encInfo={encInfo}
renderViewer={(p) => <TextViewer {...p} />}
/>
)}
>
<DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} />
</FileContent>
)}
outlined={outlineAttachment}
/>
{renderCaption()}
</>
);
if (msgType === MsgType.Text) { if (msgType === MsgType.Text) {
return ( return (
<MText <MText
edited={edited} edited={edited}
content={getContent()} content={content}
renderBody={(props) => ( urlPreview={urlPreview}
<RenderBody ts={ts}
{...props} highlightRegex={highlightRegex}
highlightRegex={highlightRegex} htmlReactParserOptions={htmlReactParserOptions}
htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/> />
); );
} }
@ -151,16 +101,12 @@ export function RenderMessageContent({
<MEmote <MEmote
displayName={displayName} displayName={displayName}
edited={edited} edited={edited}
content={getContent()} content={content}
renderBody={(props) => ( urlPreview={urlPreview}
<RenderBody ts={ts}
{...props} highlightRegex={highlightRegex}
highlightRegex={highlightRegex} htmlReactParserOptions={htmlReactParserOptions}
htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/> />
); );
} }
@ -169,16 +115,12 @@ export function RenderMessageContent({
return ( return (
<MNotice <MNotice
edited={edited} edited={edited}
content={getContent()} content={content}
renderBody={(props) => ( urlPreview={urlPreview}
<RenderBody ts={ts}
{...props} highlightRegex={highlightRegex}
highlightRegex={highlightRegex} htmlReactParserOptions={htmlReactParserOptions}
htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/> />
); );
} }
@ -186,18 +128,7 @@ export function RenderMessageContent({
if (msgType === MsgType.Image) { if (msgType === MsgType.Image) {
return ( return (
<> <>
<MImage <MImage content={content} mediaAutoLoad={mediaAutoLoad} outlined={outlineAttachment} />
content={getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
outlined={outlineAttachment}
/>
{renderCaption()} {renderCaption()}
</> </>
); );
@ -206,31 +137,7 @@ export function RenderMessageContent({
if (msgType === MsgType.Video) { if (msgType === MsgType.Video) {
return ( return (
<> <>
<MVideo <MVideo content={content} mediaAutoLoad={mediaAutoLoad} outlined={outlineAttachment} />
content={getContent()}
renderAsFile={renderFile}
renderVideoContent={({ body, info, ...props }) => (
<VideoContent
body={body}
info={info}
{...props}
renderThumbnail={
mediaAutoLoad
? () => (
<ThumbnailContent
info={info}
renderImage={(src) => (
<Image alt={body} title={body} src={src} loading="lazy" />
)}
/>
)
: undefined
}
renderVideo={(p) => <Video {...p} />}
/>
)}
outlined={outlineAttachment}
/>
{renderCaption()} {renderCaption()}
</> </>
); );
@ -239,30 +146,38 @@ export function RenderMessageContent({
if (msgType === MsgType.Audio) { if (msgType === MsgType.Audio) {
return ( return (
<> <>
<MAudio <MAudio content={content} outlined={outlineAttachment} />
content={getContent()}
renderAsFile={renderFile}
renderAudioContent={(props) => (
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
)}
outlined={outlineAttachment}
/>
{renderCaption()} {renderCaption()}
</> </>
); );
} }
if (msgType === MsgType.File) { if (msgType === MsgType.File) {
return renderFile(); return (
<>
<MFile content={content} outlined={outlineAttachment} />
{renderCaption()}
</>
);
} }
if (msgType === MsgType.Location) { if (msgType === MsgType.Location) {
return <MLocation content={getContent()} />; return <MLocation content={content} />;
} }
if (msgType === 'm.bad.encrypted') { if (Object.keys(content).length === 0) {
return <MBadEncrypted />; return <EmptyContent />;
} }
return <UnsupportedContent />; return (
<MText
edited={edited}
content={content}
urlPreview={urlPreview}
ts={ts}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
} }

View file

@ -0,0 +1,141 @@
import React from 'react';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { Text } from 'folds';
import {
MessageAudioContent,
MessageFileContent,
MessageImageContent,
MessageStickerContent,
MessageVideoContent,
UnsupportedContent,
ReplyMEmote,
ReplyMNotice,
ReplyMText,
ReplyMLocation,
EmptyContent,
} from './message';
import { MsgType } from '../../types/matrix/common';
import { rContent } from '../../client/reader';
import { MessageEvent } from '../../types/matrix/room';
type RenderReplyContentProps = {
displayName: string;
eventType: string;
edited?: boolean;
content: object;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function RenderReplyContent({
displayName,
eventType,
edited,
content,
highlightRegex,
htmlReactParserOptions,
linkifyOpts,
}: RenderReplyContentProps) {
if (eventType === MessageEvent.Sticker) {
return (
<Text size="T300" priority="300">
<MessageStickerContent />
</Text>
);
}
if (eventType !== MessageEvent.RoomMessage) {
return <UnsupportedContent size="T300" eventType={eventType} />;
}
const msgType = rContent.msgtype(content);
if (msgType === MsgType.Text) {
return (
<ReplyMText
edited={edited}
content={content}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}
if (msgType === MsgType.Emote) {
return (
<ReplyMEmote
displayName={displayName}
edited={edited}
content={content}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}
if (msgType === MsgType.Notice) {
return (
<ReplyMNotice
edited={edited}
content={content}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}
if (msgType === MsgType.Image) {
return (
<Text size="T300" priority="300">
<MessageImageContent />
</Text>
);
}
if (msgType === MsgType.Video) {
return (
<Text size="T300" priority="300">
<MessageVideoContent />
</Text>
);
}
if (msgType === MsgType.Audio) {
return (
<Text size="T300" priority="300">
<MessageAudioContent />
</Text>
);
}
if (msgType === MsgType.File) {
return (
<Text size="T300" priority="300">
<MessageFileContent />
</Text>
);
}
if (msgType === MsgType.Location) {
return <ReplyMLocation content={content} />;
}
if (Object.keys(content).length === 0) {
return <EmptyContent size="T300" />;
}
return (
<ReplyMText
edited={edited}
content={content}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}

View file

@ -0,0 +1,41 @@
import { Icon, Icons, IconSrc, Text, Tooltip, TooltipProvider } from 'folds';
import React from 'react';
import { JoinRule } from '../../types/matrix/room';
import { useClient } from '../hooks/useClient';
import { useObservableValue } from '../hooks/useObservableValue';
import { useRoomJoinRuleLabel } from './JoinRulesSwitcher';
export const getRoomAccessIconSrc = (joinRule?: JoinRule): IconSrc | undefined => {
if (!joinRule) return Icons.Lock;
if (joinRule === 'public') return Icons.Globe;
if (joinRule === 'invite' || joinRule === 'knock' || joinRule === 'private') {
return Icons.Lock;
}
return undefined;
};
export function RoomAccessIndicator({ roomId }: { roomId: string }) {
const ct = useClient();
const roomToJoinRule = useObservableValue(ct.roomToJoinRule);
const ruleToLabels = useRoomJoinRuleLabel();
const iconSrc = getRoomAccessIconSrc(roomToJoinRule.get(roomId));
if (!iconSrc) return null;
return (
<TooltipProvider
position="Right"
align="Center"
tooltip={
<Tooltip>
<Text size="L400">{ruleToLabels[roomToJoinRule.get(roomId) ?? 'private']}</Text>
</Tooltip>
}
>
{(triggerRef) => <Icon ref={triggerRef} opacity={0.8} src={iconSrc} size="50" />}
</TooltipProvider>
);
}

View file

@ -1,22 +1,37 @@
import { ReactNode, useCallback, useState } from 'react'; import { ReactElement, useCallback, useState } from 'react';
import { MatrixClient, Room } from 'matrix-js-sdk';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request';
import { useMatrixClient } from '../hooks/useMatrixClient'; import { getRoomSummary, GetRoomSummaryOK } from 'neurone/lib/api/matrix/client/v1/roomSummary';
import { LocalRoomSummary, useLocalRoomSummary } from '../hooks/useLocalRoomSummary'; import { getHierarchy, GetHierarchyOK } from 'neurone/lib/api/matrix/client/v1/rooms';
import { SpaceHierarchyRoomChunk } from 'neurone/lib/api/types';
import { AsyncState, AsyncStatus } from '../hooks/useAsyncCallback'; import { AsyncState, AsyncStatus } from '../hooks/useAsyncCallback';
import { useClient } from '../hooks/useClient';
export type IRoomSummary = Awaited<ReturnType<MatrixClient['getRoomSummary']>>; import { useSpecVersionSupport } from '../hooks/useSpecVersions';
import { LocalRoomSummary, useLocalRoomSummary } from '../hooks/useLocalRoomSummary';
type RoomSummaryLoaderProps = { type RoomSummaryLoaderProps = {
roomIdOrAlias: string; roomIdOrAlias: string;
children: (roomSummary?: IRoomSummary) => ReactNode; children: (roomSummary?: GetRoomSummaryOK) => ReactElement;
}; };
export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoaderProps) { export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoaderProps) {
const mx = useMatrixClient(); const ct = useClient();
const roomSummarySupported = useSpecVersionSupport('v1.15');
const fetchSummary = useCallback(() => mx.getRoomSummary(roomIdOrAlias), [mx, roomIdOrAlias]); const fetchSummary = useCallback(async (): Promise<GetRoomSummaryOK> => {
const endpoint = getRoomSummary({ pathParams: { roomIdOrAlias } });
if (!roomSummarySupported) {
endpoint.path = `/_matrix/client/unstable/im.nheko.summary/summary/${encodeURIComponent(
roomIdOrAlias
)}`;
}
const response = await ct.requestAuthJSON(endpoint);
const [err, result] = await parseMatrixResp<GetRoomSummaryOK>(response);
if (err) {
throw new Error(printMatrixError(err));
}
return result;
}, [ct, roomIdOrAlias, roomSummarySupported]);
const { data } = useQuery({ const { data } = useQuery({
queryKey: [roomIdOrAlias, `summary`], queryKey: [roomIdOrAlias, `summary`],
@ -27,27 +42,42 @@ export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoader
} }
export function LocalRoomSummaryLoader({ export function LocalRoomSummaryLoader({
room, roomId,
children, children,
}: { }: {
room: Room; roomId: string;
children: (roomSummary: LocalRoomSummary) => ReactNode; children: (summary: LocalRoomSummary) => ReactElement;
}) { }) {
const summary = useLocalRoomSummary(room); const summary = useLocalRoomSummary(roomId);
return children(summary); return children(summary);
} }
export function HierarchyRoomSummaryLoader({ export function HierarchyRoomSummaryLoader({
roomId, roomId,
children, children,
}: { }: {
roomId: string; roomId: string;
children: (state: AsyncState<IHierarchyRoom, Error>) => ReactNode; children: (state: AsyncState<SpaceHierarchyRoomChunk, Error>) => ReactElement;
}) { }) {
const mx = useMatrixClient(); const ct = useClient();
const fetchSummary = useCallback(() => mx.getRoomHierarchy(roomId, 1, 1), [mx, roomId]); const fetchSummary = useCallback(async (): Promise<GetHierarchyOK> => {
const response = await ct.requestAuthJSON(
getHierarchy({
pathParams: { roomId },
searchParams: {
limit: 1,
max_depth: 1,
},
})
);
const [err, result] = await parseMatrixResp<GetHierarchyOK>(response);
if (err) {
throw new Error(printMatrixError(err));
}
return result;
}, [ct, roomId]);
const [errorMemo, setError] = useState<Error>(); const [errorMemo, setError] = useState<Error>();
const { data, error } = useQuery({ const { data, error } = useQuery({
@ -62,7 +92,7 @@ export function HierarchyRoomSummaryLoader({
}, },
}); });
let state: AsyncState<IHierarchyRoom, Error> = { let state: AsyncState<SpaceHierarchyRoomChunk, Error> = {
status: AsyncStatus.Loading, status: AsyncStatus.Loading,
}; };
if (error) { if (error) {
@ -78,7 +108,7 @@ export function HierarchyRoomSummaryLoader({
}; };
} }
const summary = data?.rooms[0] ?? undefined; const summary = data?.rooms[0];
if (summary) { if (summary) {
state = { state = {
status: AsyncStatus.Success, status: AsyncStatus.Success,

View file

@ -1,24 +0,0 @@
import { ReactElement } from 'react';
import { Unread } from '../../types/matrix/room';
import { useRoomUnread, useRoomsUnread } from '../state/hooks/unread';
import { roomToUnreadAtom } from '../state/room/roomToUnread';
type RoomUnreadProviderProps = {
roomId: string;
children: (unread?: Unread) => ReactElement;
};
export function RoomUnreadProvider({ roomId, children }: RoomUnreadProviderProps) {
const unread = useRoomUnread(roomId, roomToUnreadAtom);
return children(unread);
}
type RoomsUnreadProviderProps = {
rooms: string[];
children: (unread?: Unread) => ReactElement;
};
export function RoomsUnreadProvider({ rooms, children }: RoomsUnreadProviderProps) {
const unread = useRoomsUnread(rooms, roomToUnreadAtom);
return children(unread);
}

View file

@ -1,53 +1,51 @@
import React, { FormEventHandler, useCallback } from 'react'; import React, { FormEventHandler, useCallback } from 'react';
import { Box, Text, Button, Spinner, color } from 'folds'; import { Box, Text, Button, Spinner, color } from 'folds';
import { decodeRecoveryKey, deriveRecoveryKeyFromPassphrase } from 'matrix-js-sdk/lib/crypto-api';
import { PasswordInput } from './password-input';
import { import {
SecretStorageKeyContent, AesHmacSha2,
SecretStoragePassphraseContent, AesHmacSha2KeyDescription,
} from '../../types/matrix/accountData'; Passphrase,
PassphraseInfo,
RecoveryKey,
} from 'neurone/lib/crypto/ssss';
import { PasswordInput } from './password-input';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAlive } from '../hooks/useAlive'; import { useAlive } from '../hooks/useAlive';
type SecretStorageRecoveryPassphraseProps = { type SecretStorageRecoveryPassphraseProps = {
processing?: boolean; processing?: boolean;
keyContent: SecretStorageKeyContent; keyDescription: AesHmacSha2KeyDescription;
passphraseContent: SecretStoragePassphraseContent; passphraseInfo: PassphraseInfo;
onDecodedRecoveryKey: (recoveryKey: Uint8Array) => void; onDecodedRecoveryKey: (recoveryKey: Uint8Array<ArrayBuffer>) => void;
}; };
export function SecretStorageRecoveryPassphrase({ export function SecretStorageRecoveryPassphrase({
processing, processing,
keyContent, keyDescription,
passphraseContent, passphraseInfo,
onDecodedRecoveryKey, onDecodedRecoveryKey,
}: SecretStorageRecoveryPassphraseProps) { }: SecretStorageRecoveryPassphraseProps) {
const mx = useMatrixClient();
const alive = useAlive(); const alive = useAlive();
const [driveKeyState, submitPassphrase] = useAsyncCallback< const [driveKeyState, submitPassphrase] = useAsyncCallback<
Uint8Array, Uint8Array<ArrayBuffer>,
Error, Error,
Parameters<typeof deriveRecoveryKeyFromPassphrase> [passphrase: string]
>( >(
useCallback( useCallback(
async (passphrase, salt, iterations, bits) => { async (passphrase) => {
const decodedRecoveryKey = await deriveRecoveryKeyFromPassphrase( const decodedRecoveryKey = await Passphrase.derive(passphrase, passphraseInfo);
passphrase,
salt, const { mac: regeneratedMac } = await AesHmacSha2.driveKeyMetaData(
iterations, decodedRecoveryKey,
bits keyDescription.iv
); );
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any); if (regeneratedMac !== keyDescription.mac) {
if (!match) {
throw new Error('Invalid recovery passphrase.'); throw new Error('Invalid recovery passphrase.');
} }
return decodedRecoveryKey; return decodedRecoveryKey;
}, },
[mx, keyContent] [keyDescription, passphraseInfo]
) )
); );
@ -64,8 +62,7 @@ export function SecretStorageRecoveryPassphrase({
const recoveryPassphrase = recoveryPassphraseInput.value.trim(); const recoveryPassphrase = recoveryPassphraseInput.value.trim();
if (!recoveryPassphrase) return; if (!recoveryPassphrase) return;
const { salt, iterations, bits } = passphraseContent; submitPassphrase(recoveryPassphrase).then((decodedRecoveryKey) => {
submitPassphrase(recoveryPassphrase, salt, iterations, bits).then((decodedRecoveryKey) => {
if (alive()) { if (alive()) {
recoveryPassphraseInput.value = ''; recoveryPassphraseInput.value = '';
onDecodedRecoveryKey(decodedRecoveryKey); onDecodedRecoveryKey(decodedRecoveryKey);
@ -115,31 +112,37 @@ export function SecretStorageRecoveryPassphrase({
type SecretStorageRecoveryKeyProps = { type SecretStorageRecoveryKeyProps = {
processing?: boolean; processing?: boolean;
keyContent: SecretStorageKeyContent; keyDescription: AesHmacSha2KeyDescription;
onDecodedRecoveryKey: (recoveryKey: Uint8Array) => void; onDecodedRecoveryKey: (recoveryKey: Uint8Array<ArrayBuffer>) => void;
}; };
export function SecretStorageRecoveryKey({ export function SecretStorageRecoveryKey({
processing, processing,
keyContent, keyDescription,
onDecodedRecoveryKey, onDecodedRecoveryKey,
}: SecretStorageRecoveryKeyProps) { }: SecretStorageRecoveryKeyProps) {
const mx = useMatrixClient();
const alive = useAlive(); const alive = useAlive();
const [driveKeyState, submitRecoveryKey] = useAsyncCallback<Uint8Array, Error, [string]>( const [driveKeyState, submitRecoveryKey] = useAsyncCallback<
Uint8Array<ArrayBuffer>,
Error,
[string]
>(
useCallback( useCallback(
async (recoveryKey) => { async (recoveryKey) => {
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey); const decodedRecoveryKey = RecoveryKey.decode(recoveryKey);
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any); const { mac: regeneratedMac } = await AesHmacSha2.driveKeyMetaData(
decodedRecoveryKey,
keyDescription.iv
);
if (!match) { if (regeneratedMac !== keyDescription.mac) {
throw new Error('Invalid recovery key.'); throw new Error('Invalid recovery key.');
} }
return decodedRecoveryKey; return decodedRecoveryKey;
}, },
[mx, keyContent] [keyDescription]
) )
); );

View file

@ -1,48 +1,48 @@
import { ReactNode, useCallback, useMemo } from 'react'; import { ReactNode, useCallback, useMemo } from 'react';
import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk'; import { getCapabilities, GetCapabilitiesOK } from 'neurone/lib/api/matrix/client/v3/capabilities';
import { getConfig, GetConfigOK } from 'neurone/lib/api/matrix/client/v1/media';
import { getAuthMetadata, GetAuthMetadataOK } from 'neurone/lib/api/matrix/client/v1/authMetadata';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { MediaConfig } from '../hooks/useMediaConfig';
import { promiseFulfilledResult } from '../utils/common'; import { promiseFulfilledResult } from '../utils/common';
import { useClient } from '../hooks/useClient';
import { MediaConfig } from '../hooks/useMediaConfig';
import { Capabilities } from '../hooks/useCapabilities';
import { getJSONRequest } from '../../client/request';
export type ServerConfigs = { export type ServerConfigs = {
capabilities?: Capabilities; capabilities?: Capabilities;
mediaConfig?: MediaConfig; mediaConfig?: MediaConfig;
authMetadata?: ValidatedAuthMetadata; authMetadata?: GetAuthMetadataOK;
}; };
type ServerConfigsLoaderProps = { type ServerConfigsLoaderProps = {
children: (configs: ServerConfigs) => ReactNode; children: (configs: ServerConfigs) => ReactNode;
}; };
export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) { export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
const mx = useMatrixClient(); const ct = useClient();
const fallbackConfigs = useMemo(() => ({}), []); const fallbackConfigs = useMemo(() => ({}), []);
const [configsState] = useAsyncCallbackValue<ServerConfigs, unknown>( const [configsState] = useAsyncCallbackValue<ServerConfigs, unknown>(
useCallback(async () => { useCallback(async () => {
const result = await Promise.allSettled([ const result = await Promise.allSettled([
mx.getCapabilities(), ct.requestAuthJSON(getCapabilities({})).then(parseMatrixResp<GetCapabilitiesOK>),
mx.getMediaConfig(), ct.requestAuthJSON(getConfig({})).then(parseMatrixResp<GetConfigOK>),
mx.getAuthMetadata(), getJSONRequest(ct.session.baseUrl)(getAuthMetadata({})).then(
parseMatrixResp<GetAuthMetadataOK>
),
]); ]);
const capabilities = promiseFulfilledResult(result[0]); const [, capResult] = promiseFulfilledResult(result[0]) ?? [];
const mediaConfig = promiseFulfilledResult(result[1]); const [, mediaConfig] = promiseFulfilledResult(result[1]) ?? [];
const authMetadata = promiseFulfilledResult(result[2]); const [, authMetadata] = promiseFulfilledResult(result[2]) ?? [];
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
try {
validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) {
console.error(e);
}
return { return {
capabilities, capabilities: capResult?.capabilities,
mediaConfig, mediaConfig,
authMetadata: validatedAuthMetadata, authMetadata,
}; };
}, [mx]) }, [ct])
); );
const configs: ServerConfigs = const configs: ServerConfigs =

View file

@ -2,6 +2,29 @@ import { ReactNode, useCallback, useEffect, useState } from 'react';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { SpecVersions, specVersions } from '../cs-api'; import { SpecVersions, specVersions } from '../cs-api';
const getSpecVersionKey = (baseUrl: string) => `specVersions:${baseUrl}`;
const setSpecVersions = (baseUrl: string, data: SpecVersions) => {
localStorage.setItem(getSpecVersionKey(baseUrl), JSON.stringify(data));
};
const getSpecVersions = (baseUrl: string): SpecVersions | undefined => {
try {
const dataStr = localStorage.getItem(getSpecVersionKey(baseUrl));
if (dataStr === null) return undefined;
const data = JSON.parse(dataStr);
if (typeof data === 'object' && 'versions' in data && Array.isArray(data.versions)) {
return data;
}
return undefined;
} catch {
return undefined;
}
};
const FALLBACK_DATA: SpecVersions = {
versions: [],
};
type SpecVersionsLoaderProps = { type SpecVersionsLoaderProps = {
baseUrl: string; baseUrl: string;
fallback?: () => ReactNode; fallback?: () => ReactNode;
@ -15,8 +38,15 @@ export function SpecVersionsLoader({
children, children,
}: SpecVersionsLoaderProps) { }: SpecVersionsLoaderProps) {
const [state, load] = useAsyncCallback( const [state, load] = useAsyncCallback(
useCallback(() => specVersions(fetch, baseUrl), [baseUrl]) useCallback(async () => {
const data = await specVersions(fetch, baseUrl);
setSpecVersions(baseUrl, data);
return data;
}, [baseUrl])
); );
const data = state.status === AsyncStatus.Success ? state.data : getSpecVersions(baseUrl);
const [ignoreError, setIgnoreError] = useState(false); const [ignoreError, setIgnoreError] = useState(false);
const ignoreCallback = useCallback(() => setIgnoreError(true), []); const ignoreCallback = useCallback(() => setIgnoreError(true), []);
@ -25,19 +55,13 @@ export function SpecVersionsLoader({
load(); load();
}, [load]); }, [load]);
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) { if (!data && (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading)) {
return fallback?.(); return fallback?.();
} }
if (!ignoreError && state.status === AsyncStatus.Error) { if (!data && !ignoreError && state.status === AsyncStatus.Error) {
return error?.(state.error, load, ignoreCallback); return error?.(state.error, load, ignoreCallback);
} }
return children( return children(state.status === AsyncStatus.Success ? state.data : data ?? FALLBACK_DATA);
state.status === AsyncStatus.Success
? state.data
: {
versions: [],
}
);
} }

View file

@ -1,6 +1,6 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { UIAFlow } from 'matrix-js-sdk';
import { useSupportedUIAFlows } from '../hooks/useUIAFlows'; import { useSupportedUIAFlows } from '../hooks/useUIAFlows';
import { UIAFlow } from '../utils/matrix-uia';
export function SupportedUIAFlowsLoader({ export function SupportedUIAFlowsLoader({
flows, flows,

View file

@ -4,22 +4,15 @@ import FocusTrap from 'focus-trap-react';
import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile'; import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile';
import { UserRoomProfile } from './user-profile'; import { UserRoomProfile } from './user-profile';
import { UserRoomProfileState } from '../state/userRoomProfile'; import { UserRoomProfileState } from '../state/userRoomProfile';
import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
import { stopPropagation } from '../utils/keyboard'; import { stopPropagation } from '../utils/keyboard';
import { SpaceProvider } from '../hooks/useSpace'; import { SpaceProvider } from '../hooks/useSpace';
import { RoomProvider } from '../hooks/useRoom'; import { RoomProvider } from '../hooks/useRoom';
function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) { function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) {
const { roomId, spaceId, userId, cords, position } = state; const { roomId, spaceId, userId, cords, position } = state;
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
const room = getRoom(roomId);
const space = spaceId ? getRoom(spaceId) : undefined;
const close = useCloseUserRoomProfile(); const close = useCloseUserRoomProfile();
if (!room) return null;
return ( return (
<PopOut <PopOut
anchor={cords} anchor={cords}
@ -35,8 +28,8 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState })
}} }}
> >
<Menu style={{ width: toRem(340) }}> <Menu style={{ width: toRem(340) }}>
<SpaceProvider value={space ?? null}> <SpaceProvider value={spaceId ?? null}>
<RoomProvider value={room}> <RoomProvider value={roomId}>
<UserRoomProfile userId={userId} /> <UserRoomProfile userId={userId} />
</RoomProvider> </RoomProvider>
</SpaceProvider> </SpaceProvider>

View file

@ -0,0 +1,50 @@
import React, { ReactNode } from 'react';
import { Box, Header, Scroll } from 'folds';
import classNames from 'classnames';
import * as css from './styles.css';
import * as PatternsCss from '../../styles/Patterns.css';
import CinnySVG from '../../../../public/res/svg/cinny.svg';
import { AuthFooter } from './AuthFooter';
export function AuthLogo({ large }: { large?: boolean }) {
return <img className={css.AuthLogo({ large })} src={CinnySVG} alt="Cinny Logo" />;
}
type AuthLayoutProps = {
center?: boolean;
children?: ReactNode;
header?: ReactNode;
};
export function AuthLayout({ center, header, children }: AuthLayoutProps) {
return (
<Scroll variant="Background" visibility="Hover" size="0" hideTrack>
<Box
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
direction="Column"
alignItems="Center"
gap="400"
>
<Box
style={{ width: '100%' }}
justifyContent={center ? 'Center' : undefined}
alignItems="Center"
direction="Column"
>
<Box direction="Column" className={css.AuthCard}>
{header && (
<Header className={css.AuthHeader} size="600" variant="Surface">
{header}
</Header>
)}
<Box className={css.AuthCardContent} direction="Column">
{children}
</Box>
</Box>
</Box>
<AuthFooter />
</Box>
</Scroll>
);
}

View file

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

View file

@ -0,0 +1,64 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
export const AuthLayout = style({
minHeight: '100%',
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
padding: config.space.S400,
paddingBottom: 0,
position: 'relative',
});
export const AuthCard = style({
marginTop: '1vh',
maxWidth: toRem(460),
width: '100%',
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
borderRadius: config.radii.R500,
boxShadow: config.shadow.E100,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
overflow: 'hidden',
});
export const AuthLogo = recipe({
base: [
DefaultReset,
{
width: toRem(26),
height: toRem(26),
borderRadius: '50%',
},
],
variants: {
large: {
true: {
width: toRem(48),
height: toRem(48),
},
},
},
});
export const AuthHeader = style({
paddingLeft: config.space.S400,
paddingRight: config.space.S300,
borderBottomWidth: config.borderWidth.B300,
});
export const AuthCardContent = style({
maxWidth: toRem(402),
width: '100%',
margin: 'auto',
padding: config.space.S400,
paddingTop: config.space.S700,
paddingBottom: toRem(44),
gap: toRem(44),
});
export const AuthFooter = style({
padding: config.space.S200,
});

View file

@ -27,19 +27,19 @@ import React, {
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix'; import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix';
import { useDirectUsers } from '../../hooks/useDirectUsers'; import { useDirectUsers } from '../../hooks/useDirectUsers';
import { SettingTile } from '../setting-tile'; import { SettingTile } from '../setting-tile';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser'; import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser';
import { useClient } from '../../hooks/useClient';
export const useAdditionalCreators = (defaultCreators?: string[]) => { export const useAdditionalCreators = (defaultCreators?: string[]) => {
const mx = useMatrixClient(); const ct = useClient();
const [additionalCreators, setAdditionalCreators] = useState<string[]>( const [additionalCreators, setAdditionalCreators] = useState<string[]>(
() => defaultCreators?.filter((id) => id !== mx.getSafeUserId()) ?? [] () => defaultCreators?.filter((id) => id !== ct.session.userId) ?? []
); );
const addAdditionalCreator = (userId: string) => { const addAdditionalCreator = (userId: string) => {
if (userId === mx.getSafeUserId()) return; if (userId === ct.session.userId) return;
setAdditionalCreators((creators) => { setAdditionalCreators((creators) => {
const creatorsSet = new Set(creators); const creatorsSet = new Set(creators);
@ -83,7 +83,7 @@ export function AdditionalCreatorInput({
onRemove, onRemove,
disabled, disabled,
}: AdditionalCreatorInputProps) { }: AdditionalCreatorInputProps) {
const mx = useMatrixClient(); const ct = useClient();
const [menuCords, setMenuCords] = useState<RectCords>(); const [menuCords, setMenuCords] = useState<RectCords>();
const directUsers = useDirectUsers(); const directUsers = useDirectUsers();
@ -156,7 +156,7 @@ export function AdditionalCreatorInput({
<Box shrink="No" direction="Column" gap="100"> <Box shrink="No" direction="Column" gap="100">
<Box gap="200" wrap="Wrap"> <Box gap="200" wrap="Wrap">
<Chip type="button" variant="Primary" radii="Pill" outlined> <Chip type="button" variant="Primary" radii="Pill" outlined>
<Text size="B300">{mx.getSafeUserId()}</Text> <Text size="B300">{ct.session.userId}</Text>
</Chip> </Chip>
{additionalCreators.map((creator) => ( {additionalCreators.map((creator) => (
<Chip <Chip

View file

@ -6,17 +6,18 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { MatrixError } from 'matrix-js-sdk';
import { Box, color, Icon, Icons, Input, Spinner, Text, toRem } from 'folds'; import { Box, color, Icon, Icons, Input, Spinner, Text, toRem } from 'folds';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request';
import { getRoom, GetRoomOK } from 'neurone/lib/api/matrix/client/v3/directory';
import { getMxIdServer } from '../../utils/matrix'; import { getMxIdServer } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { replaceSpaceWithDash } from '../../utils/common'; import { replaceSpaceWithDash } from '../../utils/common';
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback'; import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
import { useDebounce } from '../../hooks/useDebounce'; import { useDebounce } from '../../hooks/useDebounce';
import { useClient } from '../../hooks/useClient';
export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) { export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
const mx = useMatrixClient(); const ct = useClient();
const aliasInputRef = useRef<HTMLInputElement>(null); const aliasInputRef = useRef<HTMLInputElement>(null);
const [aliasAvail, setAliasAvail] = useState<AsyncState<boolean, Error>>({ const [aliasAvail, setAliasAvail] = useState<AsyncState<boolean, Error>>({
status: AsyncStatus.Idle, status: AsyncStatus.Idle,
@ -31,18 +32,20 @@ export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
const checkAliasAvail = useAsync( const checkAliasAvail = useAsync(
useCallback( useCallback(
async (aliasLocalPart: string) => { async (aliasLocalPart: string) => {
const roomAlias = `#${aliasLocalPart}:${getMxIdServer(mx.getSafeUserId())}`; const roomAlias = `#${aliasLocalPart}:${getMxIdServer(ct.session.userId)}`;
try { const response = await ct.requestAuthJSON(
const result = await mx.getRoomIdForAlias(roomAlias); getRoom({
return typeof result.room_id !== 'string'; pathParams: { roomAlias },
} catch (e) { })
if (e instanceof MatrixError && e.httpStatus === 404) { );
return true; const [err, result] = await parseMatrixResp<GetRoomOK>(response);
} if (err) {
throw e; if (err.status === 404) return true;
throw new Error(printMatrixError(err));
} }
return typeof result.room_id !== 'string';
}, },
[mx] [ct]
), ),
setAliasAvail setAliasAvail
); );
@ -94,7 +97,7 @@ export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
} }
after={ after={
<Text style={{ maxWidth: toRem(150) }} truncate> <Text style={{ maxWidth: toRem(150) }} truncate>
:{getMxIdServer(mx.getSafeUserId())} :{getMxIdServer(ct.session.userId)}
</Text> </Text>
} }
onKeyDown={handleAliasKeyDown} onKeyDown={handleAliasKeyDown}

View file

@ -1,18 +1,19 @@
import { import {
ICreateRoomOpts, CreateRoomStateEvent,
ICreateRoomStateEvent, postCreateRoom,
JoinRule, PostCreateRoomBody,
MatrixClient, PostCreateRoomOK,
RestrictedAllowType, } from 'neurone/lib/api/matrix/client/v3/createRoom';
Room, import { MatrixResponseResult, parseMatrixResp } from 'neurone/lib/matrix-request';
} from 'matrix-js-sdk'; import { putStateEvent } from 'neurone/lib/api/matrix/client/v3/rooms';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { JoinRulesContent, RoomType, StateEvent, StateEventType } from '../../../types/matrix/room';
import { RoomType, StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers';
import { getMxIdServer } from '../../utils/matrix'; import { getMxIdServer } from '../../utils/matrix';
import { ClientContext } from '../../../client/context';
import { getViaServers } from '../../plugins/via-servers';
import { getJoinRoomStateId } from '../../../client/storage/collections';
import { CreateRoomAccess } from './types'; import { CreateRoomAccess } from './types';
export const createRoomCreationContent = ( const createRoomCreationContent = (
type: RoomType | undefined, type: RoomType | undefined,
allowFederation: boolean, allowFederation: boolean,
additionalCreators: string[] | undefined additionalCreators: string[] | undefined
@ -31,28 +32,28 @@ export const createRoomCreationContent = (
return content; return content;
}; };
export const createRoomJoinRulesState = ( const createRoomJoinRulesState = (
access: CreateRoomAccess, access: CreateRoomAccess,
parent: Room | undefined, parentId: string | undefined,
knock: boolean knock: boolean
) => { ) => {
let content: RoomJoinRulesEventContent = { let content: JoinRulesContent = {
join_rule: knock ? JoinRule.Knock : JoinRule.Invite, join_rule: knock ? 'knock' : 'invite',
}; };
if (access === CreateRoomAccess.Public) { if (access === CreateRoomAccess.Public) {
content = { content = {
join_rule: JoinRule.Public, join_rule: 'public',
}; };
} }
if (access === CreateRoomAccess.Restricted && parent) { if (access === CreateRoomAccess.Restricted && parentId) {
content = { content = {
join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted, join_rule: knock ? 'knock_restricted' : 'restricted',
allow: [ allow: [
{ {
type: RestrictedAllowType.RoomMembership, type: 'm.room_membership',
room_id: parent.roomId, room_id: parentId,
}, },
], ],
}; };
@ -65,12 +66,12 @@ export const createRoomJoinRulesState = (
}; };
}; };
export const createRoomParentState = (parent: Room) => ({ const createRoomParentState = (parentId: string, via: string[]) => ({
type: StateEvent.SpaceParent, type: StateEvent.SpaceParent,
state_key: parent.roomId, state_key: parentId,
content: { content: {
canonical: true, canonical: true,
via: getViaServers(parent), via,
}, },
}); });
@ -101,7 +102,7 @@ export const createVoiceRoomPowerLevelsOverride = () => ({
export type CreateRoomData = { export type CreateRoomData = {
version: string; version: string;
type?: RoomType; type?: RoomType;
parent?: Room; parentId?: string;
access: CreateRoomAccess; access: CreateRoomAccess;
name: string; name: string;
topic?: string; topic?: string;
@ -111,24 +112,33 @@ export type CreateRoomData = {
allowFederation: boolean; allowFederation: boolean;
additionalCreators?: string[]; additionalCreators?: string[];
}; };
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => { export const createRoom = async (
const initialState: ICreateRoomStateEvent[] = []; ct: ClientContext,
data: CreateRoomData
): Promise<MatrixResponseResult<string>> => {
const initialState: CreateRoomStateEvent[] = [];
if (data.encryption) { if (data.encryption) {
initialState.push(createRoomEncryptionState()); initialState.push(createRoomEncryptionState());
} }
if (data.parent) { if (data.parentId) {
initialState.push(createRoomParentState(data.parent)); const creators = ct.roomToCreators.read.get(data.parentId);
const powerLevelsObj = await ct.storage.joinRoomState.read.get(
getJoinRoomStateId(data.parentId, StateEventType.RoomPowerLevels, '')
);
const powerLevelsContent = powerLevelsObj?.event.content;
const via = getViaServers(creators, powerLevelsContent);
initialState.push(createRoomParentState(data.parentId, via));
} }
if (data.type === RoomType.Call) { if (data.type === RoomType.Call) {
initialState.push(createRoomCallState()); initialState.push(createRoomCallState());
} }
initialState.push(createRoomJoinRulesState(data.access, data.parent, data.knock)); initialState.push(createRoomJoinRulesState(data.access, data.parentId, data.knock));
const options: ICreateRoomOpts = { const body: PostCreateRoomBody = {
room_version: data.version, room_version: data.version,
name: data.name, name: data.name,
topic: data.topic, topic: data.topic,
@ -144,23 +154,34 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
}; };
if (data.type === RoomType.Space) { if (data.type === RoomType.Space) {
options.power_level_content_override = createSpacePowerLevelsOverride(); body.power_level_content_override = createSpacePowerLevelsOverride();
} }
const result = await mx.createRoom(options); const response = await ct.requestAuthJSON(postCreateRoom({ body }));
if (data.parent) { const [err, result] = await parseMatrixResp<PostCreateRoomOK>(response);
await mx.sendStateEvent(
data.parent.roomId, if (err) {
StateEvent.SpaceChild as any, return [err, undefined];
{ }
auto_join: false,
suggested: false, if (data.parentId) {
via: [getMxIdServer(mx.getUserId() ?? '') ?? ''], const resp = await ct.requestAuthJSON(
}, putStateEvent({
result.room_id pathParams: {
roomId: data.parentId,
eventType: StateEventType.SpaceChild,
stateKey: result.room_id,
},
body: {
auto_join: false,
suggested: false,
via: [getMxIdServer(ct.session.userId) ?? ''],
},
})
); );
await parseMatrixResp(resp);
} }
return result.room_id; return [undefined, result.room_id];
}; };

View file

@ -5,10 +5,11 @@ import React, {
ReactNode, ReactNode,
forwardRef, forwardRef,
useCallback, useCallback,
useEffect,
useState, useState,
} from 'react'; } from 'react';
import { Box, Scroll, Text } from 'folds'; import { Box, Scroll, Text } from 'folds';
import { Descendant, Editor, createEditor } from 'slate'; import { Descendant, Editor, Transforms, createEditor } from 'slate';
import { import {
Slate, Slate,
Editable, Editable,
@ -20,17 +21,9 @@ import {
import { withHistory } from 'slate-history'; import { withHistory } from 'slate-history';
import { BlockType } from './types'; import { BlockType } from './types';
import { RenderElement, RenderLeaf } from './Elements'; import { RenderElement, RenderLeaf } from './Elements';
import { CustomElement } from './slate';
import * as css from './Editor.css'; import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard'; import { toggleKeyboardShortcut } from './keyboard';
const initialValue: CustomElement[] = [
{
type: BlockType.Paragraph,
children: [{ text: '' }],
},
];
const withInline = (editor: Editor): Editor => { const withInline = (editor: Editor): Editor => {
const { isInline } = editor; const { isInline } = editor;
@ -59,13 +52,13 @@ export const useEditor = (): Editor => {
export type EditorChangeHandler = (value: Descendant[]) => void; export type EditorChangeHandler = (value: Descendant[]) => void;
type CustomEditorProps = { type CustomEditorProps = {
editableName?: string;
top?: ReactNode; top?: ReactNode;
bottom?: ReactNode; bottom?: ReactNode;
before?: ReactNode; before?: ReactNode;
after?: ReactNode; after?: ReactNode;
maxHeight?: string; maxHeight?: string;
editor: Editor; editor: Editor;
initialValue?: Descendant[];
placeholder?: string; placeholder?: string;
onKeyDown?: KeyboardEventHandler; onKeyDown?: KeyboardEventHandler;
onKeyUp?: KeyboardEventHandler; onKeyUp?: KeyboardEventHandler;
@ -75,13 +68,13 @@ type CustomEditorProps = {
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>( export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
( (
{ {
editableName,
top, top,
bottom, bottom,
before, before,
after, after,
maxHeight = '50vh', maxHeight = '50vh',
editor, editor,
initialValue,
placeholder, placeholder,
onKeyDown, onKeyDown,
onKeyUp, onKeyUp,
@ -90,6 +83,19 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
}, },
ref ref
) => { ) => {
const [defaultValue] = useState<Descendant[]>(() => [
{
type: BlockType.Paragraph,
children: [{ text: '' }],
},
]);
useEffect(() => {
if (!initialValue?.length) return;
Transforms.select(editor, Editor.end(editor, []));
}, [editor, initialValue]);
const renderElement = useCallback( const renderElement = useCallback(
(props: RenderElementProps) => <RenderElement {...props} />, (props: RenderElementProps) => <RenderElement {...props} />,
[] []
@ -120,7 +126,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
return ( return (
<div className={css.Editor} ref={ref}> <div className={css.Editor} ref={ref}>
<Slate editor={editor} initialValue={initialValue} onChange={onChange}> <Slate editor={editor} initialValue={initialValue ?? defaultValue} onChange={onChange}>
{top} {top}
<Box alignItems="Start"> <Box alignItems="Start">
{before && ( {before && (
@ -137,7 +143,6 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
hideTrack hideTrack
> >
<Editable <Editable
data-editable-name={editableName}
className={css.EditorTextarea} className={css.EditorTextarea}
placeholder={placeholder} placeholder={placeholder}
renderPlaceholder={renderPlaceholder} renderPlaceholder={renderPlaceholder}

View file

@ -10,11 +10,11 @@ import {
import * as css from '../../styles/CustomHtml.css'; import * as css from '../../styles/CustomHtml.css';
import { CommandElement, EmoticonElement, LinkElement, MentionElement } from './slate'; import { CommandElement, EmoticonElement, LinkElement, MentionElement } from './slate';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getBeginCommand } from './utils'; import { getBeginCommand } from './utils';
import { BlockType } from './types'; import { BlockType } from './types';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useClient } from '../../hooks/useClient';
import { mxcToHttp } from '../../../client/mxcToHttp';
// Put this at the start and end of an inline component to work around this Chromium bug: // Put this at the start and end of an inline component to work around this Chromium bug:
// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 // https://bugs.chromium.org/p/chromium/issues/detail?id=1249405
@ -77,7 +77,7 @@ function RenderEmoticonElement({
element, element,
children, children,
}: { element: EmoticonElement } & RenderElementProps) { }: { element: EmoticonElement } & RenderElementProps) {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const selected = useSelected(); const selected = useSelected();
const focused = useFocused(); const focused = useFocused();
@ -93,7 +93,7 @@ function RenderEmoticonElement({
{element.key.startsWith('mxc://') ? ( {element.key.startsWith('mxc://') ? (
<img <img
className={css.EmoticonImg} className={css.EmoticonImg}
src={mxcUrlToHttp(mx, element.key, useAuthentication) ?? element.key} src={mxcToHttp(ct.session.baseUrl, element.key, useAuthentication).url() ?? element.key}
alt={element.shortcode} alt={element.shortcode}
/> />
) : ( ) : (

View file

@ -34,7 +34,6 @@ import { HeadingLevel } from './slate';
import { isMacOS } from '../../utils/user-agent'; import { isMacOS } from '../../utils/user-agent';
import { KeySymbol } from '../../utils/key-symbol'; import { KeySymbol } from '../../utils/key-symbol';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) { function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
@ -260,7 +259,7 @@ export function Toolbar() {
const canEscape = isBlockActive(editor, BlockType.Paragraph) const canEscape = isBlockActive(editor, BlockType.Paragraph)
? isAnyMarkActive(editor) ? isAnyMarkActive(editor)
: ReactEditor.isFocused(editor); : ReactEditor.isFocused(editor);
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [isMarkdown, setIsMarkdown] = useSetting('isMarkdown');
return ( return (
<Box className={css.EditorToolbarBase}> <Box className={css.EditorToolbarBase}>

View file

@ -1,11 +1,8 @@
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo } from 'react'; import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo } from 'react';
import { Editor } from 'slate'; import { Editor } from 'slate';
import { Box, MenuItem, Text, toRem } from 'folds'; import { Box, MenuItem, Text, toRem } from 'folds';
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 { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch'; import { 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';
@ -13,17 +10,18 @@ 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 { useKeyDown } from '../../../hooks/useKeyDown'; import { useKeyDown } from '../../../hooks/useKeyDown';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ImageUsage, PackImageReader } from '../../../plugins/custom-emoji'; import { ImageUsage, PackImageReader } from '../../../plugins/custom-emoji';
import { getEmoticonSearchStr } from '../../../plugins/utils'; import { getEmoticonSearchStr } from '../../../plugins/utils';
import { mxcToHttp } from '../../../../client/mxcToHttp';
import { useClient } from '../../../hooks/useClient';
type EmoticonCompleteHandler = (key: string, shortcode: string) => void; type EmoticonCompleteHandler = (key: string, shortcode: string) => void;
type EmoticonSearchItem = PackImageReader | IEmoji; type EmoticonSearchItem = PackImageReader | IEmoji;
type EmoticonAutocompleteProps = { type EmoticonAutocompleteProps = {
imagePackRooms: Room[]; imagePackRooms: string[];
editor: Editor; editor: Editor;
query: AutocompleteQuery<string>; query: AutocompleteQuery<string>;
requestClose: () => void; requestClose: () => void;
@ -41,11 +39,11 @@ export function EmoticonAutocomplete({
query, query,
requestClose, requestClose,
}: EmoticonAutocompleteProps) { }: EmoticonAutocompleteProps) {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms); const [imagePacks] = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
const recentEmoji = useRecentEmoji(mx, 20); const recentEmojiResult = useRecentEmoji(20);
const searchList = useMemo(() => { const searchList = useMemo(() => {
const list: Array<EmoticonSearchItem> = []; const list: Array<EmoticonSearchItem> = [];
@ -60,7 +58,9 @@ export function EmoticonAutocomplete({
getEmoticonSearchStr, getEmoticonSearchStr,
SEARCH_OPTIONS SEARCH_OPTIONS
); );
const autoCompleteEmoticon = result ? result.items.slice(0, 20) : recentEmoji; const autoCompleteEmoticon: EmoticonSearchItem[] = result
? result.items.slice(0, 20)
: recentEmojiResult.value ?? [];
useEffect(() => { useEffect(() => {
if (query.text) search(query.text); if (query.text) search(query.text);
@ -88,7 +88,6 @@ export function EmoticonAutocomplete({
{autoCompleteEmoticon.map((emoticon) => { {autoCompleteEmoticon.map((emoticon) => {
const isCustomEmoji = 'url' in emoticon; const isCustomEmoji = 'url' in emoticon;
const key = isCustomEmoji ? emoticon.url : emoticon.unicode; const key = isCustomEmoji ? emoticon.url : emoticon.unicode;
const customEmojiUrl = mxcUrlToHttp(mx, key, useAuthentication);
return ( return (
<MenuItem <MenuItem
@ -100,11 +99,11 @@ export function EmoticonAutocomplete({
} }
onClick={() => handleAutocomplete(key, emoticon.shortcode)} onClick={() => handleAutocomplete(key, emoticon.shortcode)}
before={ before={
isCustomEmoji && customEmojiUrl ? ( isCustomEmoji ? (
<Box <Box
shrink="No" shrink="No"
as="img" as="img"
src={customEmojiUrl} src={mxcToHttp(ct.session.baseUrl, key, useAuthentication).url() || key}
alt={emoticon.shortcode} alt={emoticon.shortcode}
style={{ width: toRem(24), height: toRem(24), objectFit: 'contain' }} style={{ width: toRem(24), height: toRem(24), objectFit: 'contain' }}
/> />

View file

@ -1,30 +1,34 @@
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect } from 'react'; import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
import { Editor } from 'slate'; import { Editor } from 'slate';
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds'; import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { JoinRule, MatrixClient } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils'; import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { getDirectRoomAvatarUrl } from '../../../utils/room';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AutocompleteQuery } from './autocompleteQuery'; import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu'; import { AutocompleteMenu } from './AutocompleteMenu';
import { getMxIdServer, isRoomAlias } from '../../../utils/matrix'; import { getMxIdServer, isRoomAlias, isRoomId } from '../../../utils/matrix';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch'; import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard'; import { onTabPress } from '../../../utils/keyboard';
import { useKeyDown } from '../../../hooks/useKeyDown'; import { useKeyDown } from '../../../hooks/useKeyDown';
import { mDirectAtom } from '../../../state/mDirectList'; import { mDirectAtom } from '../../../state/mDirectList';
import { allRoomsAtom } from '../../../state/room-list/roomList'; import { allRoomsAtom } from '../../../state/room-list/roomList';
import { factoryRoomIdByActivity } from '../../../utils/sort'; import { factoryRoomIdByAtoZ } from '../../../utils/sort';
import { RoomAvatar, RoomIcon } from '../../room-avatar'; import { RoomAvatar, RoomIcon } from '../../room-avatar';
import { getViaServers } from '../../../plugins/via-servers'; import { getViaServers } from '../../../plugins/via-servers';
import { useClient } from '../../../hooks/useClient';
import { useRoomHeroes } from '../../../hooks/storage/useRoomHeros';
import { useGetRoomName } from '../../../hooks/storage/useGetRoomName';
import { useGetRoomAvatar } from '../../../hooks/storage/useGetRoomAvatar';
import { mxcToHttp } from '../../../../client/mxcToHttp';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useSetting } from '../../../state/hooks/settings';
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void; type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
const roomAliasFromQueryText = (mx: MatrixClient, text: string) => const roomAliasFromQueryText = (myUserId: string, text: string) =>
isRoomAlias(`#${text}`) isRoomAlias(`#${text}`)
? `#${text}` ? `#${text}`
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; : `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(myUserId)}`;
function UnknownRoomMentionItem({ function UnknownRoomMentionItem({
query, query,
@ -33,8 +37,8 @@ function UnknownRoomMentionItem({
query: AutocompleteQuery<string>; query: AutocompleteQuery<string>;
handleAutocomplete: MentionAutoCompleteHandler; handleAutocomplete: MentionAutoCompleteHandler;
}) { }) {
const mx = useMatrixClient(); const ct = useClient();
const roomAlias: string = roomAliasFromQueryText(mx, query.text); const roomAlias: string = roomAliasFromQueryText(ct.session.userId, query.text);
const handleSelect = () => handleAutocomplete(roomAlias, roomAlias); const handleSelect = () => handleAutocomplete(roomAlias, roomAlias);
@ -76,22 +80,31 @@ export function RoomMentionAutocomplete({
query, query,
requestClose, requestClose,
}: RoomMentionAutocompleteProps) { }: RoomMentionAutocompleteProps) {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication();
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const rooms = useAtomValue(allRoomsAtom);
const [showAvatar] = useSetting('roomListShowAvatar');
const allRooms = useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx)); const getRoomHeroes = useRoomHeroes();
const getRoomName = useGetRoomName(getRoomHeroes);
const getRoomAvatar = useGetRoomAvatar(getRoomHeroes);
const allRooms = useMemo(
() => Array.from(rooms).sort(factoryRoomIdByAtoZ(getRoomName)),
[rooms, getRoomName]
);
const [result, search, resetSearch] = useAsyncSearch( const [result, search, resetSearch] = useAsyncSearch(
allRooms, allRooms,
useCallback( useCallback(
(rId) => { (rId) => {
const r = mx.getRoom(rId); const name = getRoomName(rId);
if (!r) return 'Unknown Room'; if (!name) return 'Unknown Room';
const alias = r.getCanonicalAlias(); const alias = ct.roomToAlias.read.get(rId);
if (alias) return [r.name, alias]; if (alias) return [name, alias];
return r.name; return name;
}, },
[mx] [ct, getRoomName]
), ),
SEARCH_OPTIONS SEARCH_OPTIONS
); );
@ -104,12 +117,14 @@ export function RoomMentionAutocomplete({
}, [query.text, search, resetSearch]); }, [query.text, search, resetSearch]);
const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => { const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => {
const mentionRoom = mx.getRoom(roomAliasOrId); const creators = isRoomId(roomAliasOrId)
const viaServers = mentionRoom ? getViaServers(mentionRoom) : undefined; ? ct.roomToCreators.read.get(roomAliasOrId)
: undefined;
const viaServers = creators ? getViaServers(creators) : undefined;
const mentionEl = createMentionElement( const mentionEl = createMentionElement(
roomAliasOrId, roomAliasOrId,
name.startsWith('#') ? name : `#${name}`, name.startsWith('#') ? name : `#${name}`,
roomId === roomAliasOrId || mx.getRoom(roomId)?.getCanonicalAlias() === roomAliasOrId, roomId === roomAliasOrId || ct.roomToAlias.read.get(roomId) === roomAliasOrId,
undefined, undefined,
viaServers viaServers
); );
@ -121,14 +136,13 @@ export function RoomMentionAutocomplete({
useKeyDown(window, (evt: KeyboardEvent) => { useKeyDown(window, (evt: KeyboardEvent) => {
onTabPress(evt, () => { onTabPress(evt, () => {
if (autoCompleteRoomIds.length === 0) { if (autoCompleteRoomIds.length === 0) {
const alias = roomAliasFromQueryText(mx, query.text); const alias = roomAliasFromQueryText(ct.session.userId, query.text);
handleAutocomplete(alias, alias); handleAutocomplete(alias, alias);
return; return;
} }
const rId = autoCompleteRoomIds[0]; const rId = autoCompleteRoomIds[0];
const r = mx.getRoom(rId); const name = getRoomName(rId) ?? 'Unknown';
const name = r?.name ?? rId; handleAutocomplete(ct.roomToAlias.read.get(rId) ?? rId, name);
handleAutocomplete(r?.getCanonicalAlias() ?? rId, name);
}); });
}); });
@ -138,11 +152,20 @@ export function RoomMentionAutocomplete({
<UnknownRoomMentionItem query={query} handleAutocomplete={handleAutocomplete} /> <UnknownRoomMentionItem query={query} handleAutocomplete={handleAutocomplete} />
) : ( ) : (
autoCompleteRoomIds.map((rId) => { autoCompleteRoomIds.map((rId) => {
const room = mx.getRoom(rId); const dm = mDirects.has(rId);
if (!room) return null; const isSpace = ct.spaceRooms.read.has(rId);
const dm = mDirects.has(room.roomId); const name = getRoomName(rId) ?? 'Unknown';
const alias = ct.roomToAlias.read.get(rId);
const avatarMxc = getRoomAvatar(rId);
const avatar = avatarMxc
? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).thumbnail({
width: 32,
height: 32,
method: 'crop',
})
: undefined;
const handleSelect = () => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name); const handleSelect = () => handleAutocomplete(alias ?? rId, name);
return ( return (
<MenuItem <MenuItem
@ -155,33 +178,37 @@ export function RoomMentionAutocomplete({
onClick={handleSelect} onClick={handleSelect}
after={ after={
<Text size="T200" priority="300" truncate> <Text size="T200" priority="300" truncate>
{room.getCanonicalAlias() ?? ''} {alias}
</Text> </Text>
} }
before={ before={
<Avatar size="200"> <Avatar size="200" radii={isSpace ? '300' : '400'}>
{dm ? ( {showAvatar || dm || isSpace ? (
<RoomAvatar <RoomAvatar
roomId={room.roomId} roomId={rId}
src={getDirectRoomAvatarUrl(mx, room)} src={avatar}
alt={room.name} alt={name}
renderFallback={() => ( renderFallback={() => (
<RoomIcon <RoomIcon
size="50" size="50"
joinRule={room.getJoinRule() ?? JoinRule.Restricted} joinRule={ct.roomToJoinRule.read.get(rId)}
roomType={room.getType()} roomType={ct.roomToType.read.get(rId)}
filled filled
/> />
)} )}
/> />
) : ( ) : (
<RoomIcon size="100" joinRule={room.getJoinRule()} roomType={room.getType()} /> <RoomIcon
size="100"
joinRule={ct.roomToJoinRule.read.get(rId)}
roomType={ct.roomToType.read.get(rId)}
/>
)} )}
</Avatar> </Avatar>
} }
> >
<Text style={{ flexGrow: 1 }} size="B400" truncate> <Text style={{ flexGrow: 1 }} size="B400" truncate>
{room.name} {name}
</Text> </Text>
</MenuItem> </MenuItem>
); );

View file

@ -1,32 +1,29 @@
import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react'; import React, { useEffect, KeyboardEvent as ReactKeyboardEvent, useCallback } from 'react';
import { Editor } from 'slate'; import { Editor } from 'slate';
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds'; import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { AutocompleteQuery } from './autocompleteQuery'; import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu'; import { AutocompleteMenu } from './AutocompleteMenu';
import { useRoomMembers } from '../../../hooks/useRoomMembers'; import { GetRoomMemberMembershipCallback, useRoomMembersAPI } from '../../../hooks/useRoomMembers';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
import {
SearchItemStrGetter,
UseAsyncSearchOptions,
useAsyncSearch,
} from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard'; import { onTabPress } from '../../../utils/keyboard';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils'; import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { useKeyDown } from '../../../hooks/useKeyDown'; import { useKeyDown } from '../../../hooks/useKeyDown';
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix'; import { getMxIdServer, isUserId } from '../../../utils/matrix';
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room'; import { 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'; import { useClient } from '../../../hooks/useClient';
import { JoinRoomMemberObject } from '../../../../client/storage/collections';
import { mxcToHttp } from '../../../../client/mxcToHttp';
import { nameInitials } from '../../../utils/common';
type MentionAutoCompleteHandler = (userId: string, name: string) => void; type MentionAutoCompleteHandler = (userId: string, name: string) => void;
const userIdFromQueryText = (mx: MatrixClient, text: string) => const userIdFromQueryText = (myUserId: string, text: string) =>
isUserId(`@${text}`) isUserId(`@${text}`)
? `@${text}` ? `@${text}`
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(myUserId)}`;
function UnknownMentionItem({ function UnknownMentionItem({
userId, userId,
@ -62,16 +59,18 @@ function UnknownMentionItem({
} }
type UserMentionAutocompleteProps = { type UserMentionAutocompleteProps = {
room: Room; roomId: string;
editor: Editor; editor: Editor;
query: AutocompleteQuery<string>; query: AutocompleteQuery<string>;
requestClose: () => void; requestClose: () => void;
}; };
const withAllowedMembership = (member: RoomMember): boolean => const withAllowedMembership =
member.membership === Membership.Join || (getMembership: GetRoomMemberMembershipCallback) =>
member.membership === Membership.Invite || (member: JoinRoomMemberObject): boolean =>
member.membership === Membership.Knock; getMembership(member.userId) === 'join' ||
getMembership(member.userId) === 'invite' ||
getMembership(member.userId) === 'knock';
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000, limit: 1000,
@ -80,25 +79,28 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = {
}, },
}; };
const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId; const EMPTY_MEMBER_LIST: JoinRoomMemberObject[] = [];
const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
getMemberSearchStr(m, query, mxIdToName);
export function UserMentionAutocomplete({ export function UserMentionAutocomplete({
room, roomId,
editor, editor,
query, query,
requestClose, requestClose,
}: UserMentionAutocompleteProps) { }: UserMentionAutocompleteProps) {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const roomId: string = room.roomId!; const roomAliasOrId = ct.roomToAlias.read.get(roomId) || roomId;
const roomAliasOrId = room.getCanonicalAlias() || roomId; const membersAPI = useRoomMembersAPI();
const members = useRoomMembers(mx, roomId); const members = membersAPI.memberEventsResult.value ?? EMPTY_MEMBER_LIST;
const [result, search, resetSearch] = useAsyncSearch(
members,
useCallback((m, q) => getMemberSearchStr(m, q, membersAPI.getName), [membersAPI.getName]),
SEARCH_OPTIONS
);
const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS);
const autoCompleteMembers = (result ? result.items.slice(0, 20) : members.slice(0, 20)).filter( const autoCompleteMembers = (result ? result.items.slice(0, 20) : members.slice(0, 20)).filter(
withAllowedMembership withAllowedMembership(membersAPI.getMembership)
); );
useEffect(() => { useEffect(() => {
@ -110,7 +112,7 @@ export function UserMentionAutocomplete({
const mentionEl = createMentionElement( const mentionEl = createMentionElement(
uId, uId,
name.startsWith('@') ? name : `@${name}`, name.startsWith('@') ? name : `@${name}`,
mx.getUserId() === uId || roomAliasOrId === uId uId === ct.session.userId || roomAliasOrId === uId
); );
replaceWithElement(editor, query.range, mentionEl); replaceWithElement(editor, query.range, mentionEl);
moveCursor(editor, true); moveCursor(editor, true);
@ -124,18 +126,15 @@ export function UserMentionAutocomplete({
return; return;
} }
if (autoCompleteMembers.length === 0) { if (autoCompleteMembers.length === 0) {
const userId = userIdFromQueryText(mx, query.text); const userId = userIdFromQueryText(ct.session.userId, query.text);
handleAutocomplete(userId, userId); handleAutocomplete(userId, userId);
return; return;
} }
const roomMember = autoCompleteMembers[0]; const roomMember = autoCompleteMembers[0];
handleAutocomplete(roomMember.userId, roomMember.name); handleAutocomplete(roomMember.userId, membersAPI.getName(roomMember.userId));
}); });
}); });
const getName = (member: RoomMember) =>
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
return ( return (
<AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}> <AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
{query.text === 'room' && ( {query.text === 'room' && (
@ -147,15 +146,20 @@ export function UserMentionAutocomplete({
)} )}
{autoCompleteMembers.length === 0 ? ( {autoCompleteMembers.length === 0 ? (
<UnknownMentionItem <UnknownMentionItem
userId={userIdFromQueryText(mx, query.text)} userId={userIdFromQueryText(ct.session.userId, query.text)}
name={userIdFromQueryText(mx, query.text)} name={userIdFromQueryText(ct.session.userId, query.text)}
handleAutocomplete={handleAutocomplete} handleAutocomplete={handleAutocomplete}
/> />
) : ( ) : (
autoCompleteMembers.map((roomMember) => { autoCompleteMembers.map((roomMember) => {
const avatarMxcUrl = roomMember.getMxcAvatarUrl(); const name = membersAPI.getName(roomMember.userId);
const avatarMxcUrl = membersAPI.getAvatar(roomMember.userId);
const avatarUrl = avatarMxcUrl const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication) ? mxcToHttp(ct.session.baseUrl, avatarMxcUrl, useAuthentication).thumbnail({
width: 32,
height: 32,
method: 'crop',
})
: undefined; : undefined;
return ( return (
<MenuItem <MenuItem
@ -163,9 +167,9 @@ export function UserMentionAutocomplete({
as="button" as="button"
radii="300" radii="300"
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) => onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, () => handleAutocomplete(roomMember.userId, getName(roomMember))) onTabPress(evt, () => handleAutocomplete(roomMember.userId, name))
} }
onClick={() => handleAutocomplete(roomMember.userId, getName(roomMember))} onClick={() => handleAutocomplete(roomMember.userId, name)}
after={ after={
<Text size="T200" priority="300" truncate> <Text size="T200" priority="300" truncate>
{roomMember.userId} {roomMember.userId}
@ -176,14 +180,18 @@ export function UserMentionAutocomplete({
<UserAvatar <UserAvatar
userId={roomMember.userId} userId={roomMember.userId}
src={avatarUrl ?? undefined} src={avatarUrl ?? undefined}
alt={getName(roomMember)} alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />} renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(name)}
</Text>
)}
/> />
</Avatar> </Avatar>
} }
> >
<Text style={{ flexGrow: 1 }} size="B400" truncate> <Text style={{ flexGrow: 1 }} size="B400" truncate>
{getName(roomMember)} {name}
</Text> </Text>
</MenuItem> </MenuItem>
); );

View file

@ -1,5 +1,4 @@
import { Descendant, Editor, 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';
@ -12,6 +11,7 @@ import {
import { findAndReplace } from '../../utils/findAndReplace'; import { findAndReplace } from '../../utils/findAndReplace';
import { sanitizeForRegex } from '../../utils/regex'; import { sanitizeForRegex } from '../../utils/regex';
import { isUserId } from '../../utils/matrix'; import { isUserId } from '../../utils/matrix';
import { ClientContext } from '../../../client/context';
export type OutputOptions = { export type OutputOptions = {
allowTextFormatting?: boolean; allowTextFormatting?: boolean;
@ -201,7 +201,7 @@ export type MentionsData = {
room: boolean; room: boolean;
users: Set<string>; users: Set<string>;
}; };
export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): MentionsData => { export const getMentions = (ct: ClientContext, editor: Editor): MentionsData => {
const mentionData: MentionsData = { const mentionData: MentionsData = {
room: false, room: false,
users: new Set(), users: new Set(),
@ -215,8 +215,7 @@ export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): M
if (node.name === '@room') { if (node.name === '@room') {
mentionData.room = true; mentionData.room = true;
} }
if (isUserId(node.id) && node.id !== ct.session.userId) {
if (isUserId(node.id) && node.id !== mx.getUserId()) {
mentionData.users.add(node.id); mentionData.users.add(node.id);
} }

View file

@ -12,7 +12,6 @@ import React, {
import { Box, config, Icons, Scroll } from 'folds'; import { Box, config, Icons, Scroll } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { Room } from 'matrix-js-sdk';
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai'; import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji'; import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji';
@ -20,9 +19,8 @@ import { useEmojiGroupLabels } from './useEmojiGroupLabels';
import { useEmojiGroupIcons } from './useEmojiGroupIcons'; import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard'; import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
import { useRelevantImagePacks } from '../../hooks/useImagePacks'; import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../hooks/useRecentEmoji';
import { isUserId, mxcUrlToHttp } from '../../utils/matrix'; import { isUserId } from '../../utils/matrix';
import { editableActiveElement, targetFromEvent } from '../../utils/dom'; import { editableActiveElement, targetFromEvent } from '../../utils/dom';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
import { useDebounce } from '../../hooks/useDebounce'; import { useDebounce } from '../../hooks/useDebounce';
@ -52,6 +50,10 @@ import {
} from './components'; } from './components';
import { EmojiBoardTab, EmojiType } from './types'; import { EmojiBoardTab, EmojiType } from './types';
import { VirtualTile } from '../virtualizer'; import { VirtualTile } from '../virtualizer';
import { useClient } from '../../hooks/useClient';
import { useRoomHeroes } from '../../hooks/storage/useRoomHeros';
import { GetRoomNameCallback, useGetRoomName } from '../../hooks/storage/useGetRoomName';
import { mxcToHttp } from '../../../client/mxcToHttp';
const RECENT_GROUP_ID = 'recent_group'; const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_group'; const SEARCH_GROUP_ID = 'search_group';
@ -69,26 +71,28 @@ type StickerGroupItem = {
const useGroups = ( const useGroups = (
tab: EmojiBoardTab, tab: EmojiBoardTab,
imagePacks: ImagePack[] imagePacks: ImagePack[],
getRoomName: GetRoomNameCallback
): [EmojiGroupItem[], StickerGroupItem[]] => { ): [EmojiGroupItem[], StickerGroupItem[]] => {
const mx = useMatrixClient(); const recentEmojisResult = useRecentEmoji(21);
const recentEmojis = useRecentEmoji(mx, 21);
const labels = useEmojiGroupLabels(); const labels = useEmojiGroupLabels();
const emojiGroupItems = useMemo(() => { const emojiGroupItems = useMemo(() => {
const g: EmojiGroupItem[] = []; const g: EmojiGroupItem[] = [];
if (tab !== EmojiBoardTab.Emoji) return g; if (tab !== EmojiBoardTab.Emoji) return g;
g.push({ const recentEmojis = recentEmojisResult.value;
id: RECENT_GROUP_ID, if (recentEmojis && recentEmojis.length > 0) {
name: 'Recent', g.push({
items: recentEmojis, id: RECENT_GROUP_ID,
}); name: 'Recent',
items: recentEmojis,
});
}
imagePacks.forEach((pack) => { imagePacks.forEach((pack) => {
let label = pack.meta.name; 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' : getRoomName(pack.id);
g.push({ g.push({
id: pack.id, id: pack.id,
@ -108,7 +112,7 @@ const useGroups = (
}); });
return g; return g;
}, [mx, recentEmojis, labels, imagePacks, tab]); }, [getRoomName, recentEmojisResult, labels, imagePacks, tab]);
const stickerGroupItems = useMemo(() => { const stickerGroupItems = useMemo(() => {
const g: StickerGroupItem[] = []; const g: StickerGroupItem[] = [];
@ -116,7 +120,7 @@ const useGroups = (
imagePacks.forEach((pack) => { imagePacks.forEach((pack) => {
let label = pack.meta.name; 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' : getRoomName(pack.id);
g.push({ g.push({
id: pack.id, id: pack.id,
@ -128,37 +132,24 @@ const useGroups = (
}); });
return g; return g;
}, [mx, imagePacks, tab]); }, [getRoomName, imagePacks, tab]);
return [emojiGroupItems, stickerGroupItems]; return [emojiGroupItems, stickerGroupItems];
}; };
const useItemRenderer = (tab: EmojiBoardTab) => { const useItemRenderer = (tab: EmojiBoardTab) => {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const renderItem = (emoji: IEmoji | PackImageReader, index: number) => { const renderItem = (emoji: IEmoji | PackImageReader, index: number) => {
if ('unicode' in emoji) { if ('unicode' in emoji) {
return <EmojiItem key={emoji.unicode + index} emoji={emoji} />; return <EmojiItem key={emoji.unicode + index} emoji={emoji} />;
} }
const url = mxcToHttp(ct.session.baseUrl, emoji.url, useAuthentication).url();
if (tab === EmojiBoardTab.Sticker) { if (tab === EmojiBoardTab.Sticker) {
return ( return <StickerItem key={emoji.shortcode + index} url={url} image={emoji} />;
<StickerItem
key={emoji.shortcode + index}
mx={mx}
useAuthentication={useAuthentication}
image={emoji}
/>
);
} }
return ( return <CustomEmojiItem key={emoji.shortcode + index} url={url} image={emoji} />;
<CustomEmojiItem
key={emoji.shortcode + index}
mx={mx}
useAuthentication={useAuthentication}
image={emoji}
/>
);
}; };
return renderItem; return renderItem;
@ -168,9 +159,10 @@ type EmojiSidebarProps = {
activeGroupAtom: PrimitiveAtom<string | undefined>; activeGroupAtom: PrimitiveAtom<string | undefined>;
packs: ImagePack[]; packs: ImagePack[];
onScrollToGroup: (groupId: string) => void; onScrollToGroup: (groupId: string) => void;
getRoomName: GetRoomNameCallback;
}; };
function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarProps) { function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup, getRoomName }: EmojiSidebarProps) {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom); const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
@ -199,10 +191,12 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
<SidebarDivider /> <SidebarDivider />
{packs.map((pack) => { {packs.map((pack) => {
let label = pack.meta.name; 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' : getRoomName(pack.id);
const url = const avatarMxc = pack.getAvatarUrl(usage);
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined; const url = avatarMxc
? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).url()
: undefined;
return ( return (
<ImageGroupIcon <ImageGroupIcon
@ -244,9 +238,15 @@ type StickerSidebarProps = {
activeGroupAtom: PrimitiveAtom<string | undefined>; activeGroupAtom: PrimitiveAtom<string | undefined>;
packs: ImagePack[]; packs: ImagePack[];
onScrollToGroup: (groupId: string) => void; onScrollToGroup: (groupId: string) => void;
getRoomName: GetRoomNameCallback;
}; };
function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSidebarProps) { function StickerSidebar({
const mx = useMatrixClient(); activeGroupAtom,
packs,
onScrollToGroup,
getRoomName,
}: StickerSidebarProps) {
const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom); const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
@ -262,10 +262,12 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
<SidebarStack> <SidebarStack>
{packs.map((pack) => { {packs.map((pack) => {
let label = pack.meta.name; 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' : getRoomName(pack.id);
const url = const avatarMxc = pack.getAvatarUrl(usage);
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined; const url = avatarMxc
? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).url()
: undefined;
return ( return (
<ImageGroupIcon <ImageGroupIcon
@ -354,7 +356,7 @@ const VIRTUAL_OVER_SCAN = 2;
type EmojiBoardProps = { type EmojiBoardProps = {
tab?: EmojiBoardTab; tab?: EmojiBoardTab;
onTabChange?: (tab: EmojiBoardTab) => void; onTabChange?: (tab: EmojiBoardTab) => void;
imagePackRooms: Room[]; imagePackRooms: string[];
requestClose: () => void; requestClose: () => void;
returnFocusOnDeactivate?: boolean; returnFocusOnDeactivate?: boolean;
onEmojiSelect?: (unicode: string, shortcode: string) => void; onEmojiSelect?: (unicode: string, shortcode: string) => void;
@ -376,7 +378,9 @@ export function EmojiBoard({
allowTextCustomEmoji, allowTextCustomEmoji,
addToRecentEmoji = true, addToRecentEmoji = true,
}: EmojiBoardProps) { }: EmojiBoardProps) {
const mx = useMatrixClient(); const ct = useClient();
const getRoomHeroes = useRoomHeroes();
const getRoomName = useGetRoomName(getRoomHeroes);
const emojiTab = tab === EmojiBoardTab.Emoji; const emojiTab = tab === EmojiBoardTab.Emoji;
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker; const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
@ -387,8 +391,8 @@ export function EmojiBoard({
); );
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []); const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
const setActiveGroupId = useSetAtom(activeGroupIdAtom); const setActiveGroupId = useSetAtom(activeGroupIdAtom);
const imagePacks = useRelevantImagePacks(usage, imagePackRooms); const [imagePacks, loading] = useRelevantImagePacks(usage, imagePackRooms);
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks); const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks, getRoomName);
const groups = emojiTab ? emojiGroupItems : stickerGroupItems; const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
const renderItem = useItemRenderer(tab); const renderItem = useItemRenderer(tab);
@ -437,7 +441,7 @@ export function EmojiBoard({
if (emojiInfo.type === EmojiType.Emoji) { if (emojiInfo.type === EmojiType.Emoji) {
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode); onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) { if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) {
addRecentEmoji(mx, emojiInfo.data); addRecentEmoji(ct, emojiInfo.data);
} }
} }
if (emojiInfo.type === EmojiType.CustomEmoji) { if (emojiInfo.type === EmojiType.CustomEmoji) {
@ -521,57 +525,61 @@ export function EmojiBoard({
activeGroupAtom={activeGroupIdAtom} activeGroupAtom={activeGroupIdAtom}
packs={imagePacks} packs={imagePacks}
onScrollToGroup={handleScrollToGroup} onScrollToGroup={handleScrollToGroup}
getRoomName={getRoomName}
/> />
) : ( ) : (
<StickerSidebar <StickerSidebar
activeGroupAtom={activeGroupIdAtom} activeGroupAtom={activeGroupIdAtom}
packs={imagePacks} packs={imagePacks}
onScrollToGroup={handleScrollToGroup} onScrollToGroup={handleScrollToGroup}
getRoomName={getRoomName}
/> />
) )
} }
> >
<Box grow="Yes"> <Box grow="Yes">
<EmojiGroupHolder {!loading && (
key={tab} <EmojiGroupHolder
contentScrollRef={contentScrollRef} key={tab}
previewAtom={previewAtom} contentScrollRef={contentScrollRef}
onGroupItemClick={handleGroupItemClick} previewAtom={previewAtom}
> onGroupItemClick={handleGroupItemClick}
{searchedItems && (
<EmojiGroup
id={SEARCH_GROUP_ID}
label={searchedItems.length ? 'Search Results' : 'No Results found'}
>
{searchedItems.map(renderItem)}
</EmojiGroup>
)}
<div
ref={virtualBaseRef}
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
> >
{vItems.map((vItem) => { {searchedItems && (
const group = groups[vItem.index]; <EmojiGroup
id={SEARCH_GROUP_ID}
label={searchedItems.length ? 'Search Results' : 'No Results found'}
>
{searchedItems.map(renderItem)}
</EmojiGroup>
)}
<div
ref={virtualBaseRef}
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{vItems.map((vItem) => {
const group = groups[vItem.index];
return ( return (
<VirtualTile <VirtualTile
virtualItem={vItem} virtualItem={vItem}
style={{ paddingTop: config.space.S200 }} style={{ paddingTop: config.space.S200 }}
ref={virtualizer.measureElement} ref={virtualizer.measureElement}
key={vItem.index} key={vItem.index}
> >
<EmojiGroup key={group.id} id={group.id} label={group.name}> <EmojiGroup key={group.id} id={group.id} label={group.name}>
{group.items.map(renderItem)} {group.items.map(renderItem)}
</EmojiGroup> </EmojiGroup>
</VirtualTile> </VirtualTile>
); );
})} })}
</div> </div>
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />} {tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
</EmojiGroupHolder> </EmojiGroupHolder>
)}
</Box> </Box>
<Preview previewAtom={previewAtom} /> <Preview previewAtom={previewAtom} />
</EmojiBoardLayout> </EmojiBoardLayout>

View file

@ -1,11 +1,9 @@
import React from 'react'; import React from 'react';
import { Box } from 'folds'; import { Box } from 'folds';
import { MatrixClient } from 'matrix-js-sdk';
import { EmojiItemInfo, EmojiType } from '../types'; import { EmojiItemInfo, EmojiType } from '../types';
import * as css from './styles.css'; import * as css from './styles.css';
import { PackImageReader } from '../../../plugins/custom-emoji'; import { PackImageReader } from '../../../plugins/custom-emoji';
import { IEmoji } from '../../../plugins/emoji'; import { IEmoji } from '../../../plugins/emoji';
import { mxcUrlToHttp } from '../../../utils/matrix';
export const getEmojiItemInfo = (element: Element): EmojiItemInfo | undefined => { export const getEmojiItemInfo = (element: Element): EmojiItemInfo | undefined => {
const label = element.getAttribute('title'); const label = element.getAttribute('title');
@ -46,11 +44,10 @@ export function EmojiItem({ emoji }: EmojiItemProps) {
} }
type CustomEmojiItemProps = { type CustomEmojiItemProps = {
mx: MatrixClient; url?: string;
useAuthentication?: boolean;
image: PackImageReader; image: PackImageReader;
}; };
export function CustomEmojiItem({ mx, useAuthentication, image }: CustomEmojiItemProps) { export function CustomEmojiItem({ url, image }: CustomEmojiItemProps) {
return ( return (
<Box <Box
as="button" as="button"
@ -68,19 +65,18 @@ export function CustomEmojiItem({ mx, useAuthentication, image }: CustomEmojiIte
loading="lazy" loading="lazy"
className={css.CustomEmojiImg} className={css.CustomEmojiImg}
alt={image.body || image.shortcode} alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''} src={url ?? image.url}
/> />
</Box> </Box>
); );
} }
type StickerItemProps = { type StickerItemProps = {
mx: MatrixClient; url?: string;
useAuthentication?: boolean;
image: PackImageReader; image: PackImageReader;
}; };
export function StickerItem({ mx, useAuthentication, image }: StickerItemProps) { export function StickerItem({ url, image }: StickerItemProps) {
return ( return (
<Box <Box
as="button" as="button"
@ -98,7 +94,7 @@ export function StickerItem({ mx, useAuthentication, image }: StickerItemProps)
loading="lazy" loading="lazy"
className={css.StickerImg} className={css.StickerImg}
alt={image.body || image.shortcode} alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''} src={url ?? image.url}
/> />
</Box> </Box>
); );

View file

@ -2,9 +2,9 @@ import { Box, Text } from 'folds';
import React from 'react'; import React from 'react';
import { Atom, atom, useAtomValue } from 'jotai'; import { Atom, atom, useAtomValue } from 'jotai';
import * as css from './styles.css'; import * as css from './styles.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../../../utils/matrix'; import { useClient } from '../../../hooks/useClient';
import { mxcToHttp } from '../../../../client/mxcToHttp';
export type PreviewData = { export type PreviewData = {
key: string; key: string;
@ -18,7 +18,7 @@ type PreviewProps = {
previewAtom: Atom<PreviewData | undefined>; previewAtom: Atom<PreviewData | undefined>;
}; };
export function Preview({ previewAtom }: PreviewProps) { export function Preview({ previewAtom }: PreviewProps) {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const { key, shortcode } = useAtomValue(previewAtom) ?? {}; const { key, shortcode } = useAtomValue(previewAtom) ?? {};
@ -37,7 +37,7 @@ export function Preview({ previewAtom }: PreviewProps) {
{key.startsWith('mxc://') ? ( {key.startsWith('mxc://') ? (
<img <img
className={css.PreviewImg} className={css.PreviewImg}
src={mxcUrlToHttp(mx, key, useAuthentication) ?? key} src={mxcToHttp(ct.session.baseUrl, key, useAuthentication).url() ?? key}
alt={shortcode} alt={shortcode}
/> />
) : ( ) : (

View file

@ -0,0 +1,117 @@
import React, { ReactNode, useMemo } from 'react';
import { as, Box, color, config, Icon, Icons, Text } from 'folds';
import { MessageEventWithoutRoomId } from 'neurone/lib/api/types';
import {
DecryptionManager,
EventDecryptionIdle,
EventDecryptionPending,
EventDecryptionSuccess,
} from '../../../client/decryption';
import { useObservableValue } from '../../hooks/useObservableValue';
export const MessageDecryptionIdle = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.ShieldLock} />
<i>Decrypt message event to view.</i>
</Box>
));
export const MessageDecryptionPending = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.Clock} />
<i>Waiting for message to be decrypted...</i>
</Box>
));
export const MessageDecryptionError = as<'div', { message: string; children?: never }>(
({ message, ...props }, ref) => (
<Box
as="span"
alignItems="Center"
gap="100"
style={{ color: color.Critical.Main, opacity: config.opacity.P300 }}
{...props}
ref={ref}
>
<Icon size="50" src={Icons.Warning} />
<i>{message}</i>
</Box>
)
);
type EncryptedMessageEventProps = {
decryptionManager: DecryptionManager;
roomId: string;
event: MessageEventWithoutRoomId;
reply?: boolean;
children: (decryptedEvent: MessageEventWithoutRoomId) => ReactNode;
};
export function EncryptedMessageEvent({
decryptionManager,
roomId,
event,
reply,
children,
}: EncryptedMessageEventProps) {
// Memoize the result because it can return new EncryptedEvent
// on every call if event content is missing sessionId.
// It can cause infinite re-renders in pair with `decryption`
const encryptedEvent = useMemo(
() => decryptionManager.decryptableMessageEvent(roomId, event),
[roomId, event, decryptionManager]
);
if (encryptedEvent.decryption.read instanceof EventDecryptionIdle) {
encryptedEvent.decrypt();
}
const decryption = useObservableValue(encryptedEvent.decryption);
if (decryption instanceof EventDecryptionIdle) {
return (
<Text size={reply ? 'T300' : 'T400'} truncate={reply}>
<MessageDecryptionIdle />
</Text>
);
}
if (decryption instanceof EventDecryptionPending) {
return (
<Text size={reply ? 'T300' : 'T400'} truncate={reply}>
<MessageDecryptionPending />
</Text>
);
}
if (decryption instanceof EventDecryptionSuccess) {
return children(decryption.event);
}
const { cause, error } = decryption;
if (cause) {
return (
<Text size={reply ? 'T300' : 'T400'} truncate={reply}>
<MessageDecryptionError
message={
cause instanceof Error
? cause.message
: 'Unknown error occurs while decrypting message.'
}
/>
</Text>
);
}
if (!error) {
return (
<Text>
<MessageDecryptionError message="Unknown error occurs while decrypting message." />
</Text>
);
}
return (
<Text>
<MessageDecryptionError message={error.description} />
</Text>
);
}

View file

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

View file

@ -1,4 +1,4 @@
import React from 'react'; import React, { useMemo } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
Avatar, Avatar,
@ -13,33 +13,41 @@ import {
as, as,
config, config,
} from 'folds'; } from 'folds';
import { Room } from 'matrix-js-sdk';
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import * as css from './EventReaders.css'; import * as css from './EventReaders.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UserAvatar } from '../user-avatar'; import { UserAvatar } from '../user-avatar';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace'; import { useSpaceOptionally } from '../../hooks/useSpace';
import { getMouseEventCords } from '../../utils/dom'; import { getMouseEventCords } from '../../utils/dom';
import { useClient } from '../../hooks/useClient';
import { useObservableValuePromise } from '../../hooks/useObservableValue';
import { useRoomMembersAPI } from '../../hooks/useRoomMembers';
import { mxcToHttp } from '../../../client/mxcToHttp';
import { useRoom } from '../../hooks/useRoom';
import { Time } from '../message';
import { useSettings } from '../../state/hooks/settings';
export type EventReadersProps = { export type EventReadersProps = {
room: Room;
eventId: string; eventId: string;
requestClose: () => void; requestClose: () => void;
}; };
export const EventReaders = as<'div', EventReadersProps>( export const EventReaders = as<'div', EventReadersProps>(
({ className, room, eventId, requestClose, ...props }, ref) => { ({ className, eventId, requestClose, ...props }, ref) => {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const roomId = useRoom();
const latestEventReaders = useRoomEventReaders(room, eventId); const membersAPI = useRoomMembersAPI();
const openProfile = useOpenUserRoomProfile(); const { hour24Clock, dateFormatString } = useSettings();
const space = useSpaceOptionally();
const getName = (userId: string) => const eventReceipt = useObservableValuePromise(
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; useMemo(
() => ct.storage.readReceipt.observableRead.queryIndex('eventId', eventId),
[ct, eventId]
)
);
const useAuthentication = useMediaAuthentication();
const openProfile = useOpenUserRoomProfile();
const spaceId = useSpaceOptionally();
return ( return (
<Box <Box
@ -59,52 +67,58 @@ export const EventReaders = as<'div', EventReadersProps>(
<Box grow="Yes"> <Box grow="Yes">
<Scroll visibility="Hover" hideTrack size="300"> <Scroll visibility="Hover" hideTrack size="300">
<Box className={css.Content} direction="Column"> <Box className={css.Content} direction="Column">
{latestEventReaders.map((readerId) => { {Array.from(eventReceipt.value ?? [])
const name = getName(readerId); .sort((a, b) => b.ts - a.ts)
const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl(); ?.map((receipt) => {
const avatarUrl = avatarMxcUrl const { userId, ts } = receipt;
? mx.mxcUrlToHttp( const name = membersAPI.getName(userId);
avatarMxcUrl, const avatarMxcUrl = membersAPI.getAvatar(userId);
100, const avatarUrl = avatarMxcUrl
100, ? mxcToHttp(ct.session.baseUrl, avatarMxcUrl, useAuthentication).thumbnail({
'crop', width: 96,
undefined, height: 96,
false, method: 'crop',
useAuthentication })
) : undefined;
: undefined;
return ( return (
<MenuItem <MenuItem
key={readerId} key={`${userId}${ts}`}
style={{ padding: `0 ${config.space.S200}` }} style={{ padding: `0 ${config.space.S200}` }}
radii="400" radii="400"
onClick={(event) => { onClick={(event) => {
openProfile( openProfile(
room.roomId, roomId,
space?.roomId, spaceId,
readerId, userId,
getMouseEventCords(event.nativeEvent), getMouseEventCords(event.nativeEvent),
'Bottom' 'Bottom'
); );
}} }}
before={ before={
<Avatar size="200"> <Avatar size="200">
<UserAvatar <UserAvatar
userId={readerId} userId={userId}
src={avatarUrl ?? undefined} src={avatarUrl ?? undefined}
alt={name} alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />} renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
after={
<Time
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
ts={ts}
/> />
</Avatar> }
} >
> <Text style={{ flexGrow: 1 }} size="T400" truncate>
<Text size="T400" truncate> {name}
{name} </Text>
</Text> </MenuItem>
</MenuItem> );
); })}
})}
</Box> </Box>
</Scroll> </Scroll>
</Box> </Box>

View file

@ -19,7 +19,7 @@ import * as css from './style.css';
import { useFilePicker } from '../../hooks/useFilePicker'; import { useFilePicker } from '../../hooks/useFilePicker';
import { CompactUploadCardRenderer } from '../upload-card'; import { CompactUploadCardRenderer } from '../upload-card';
import { UploadSuccess } from '../../state/upload'; import { UploadSuccess } from '../../state/upload';
import { getImageInfo, TUploadContent } from '../../utils/matrix'; import { getImageInfo, UploadContent } from '../../utils/matrix';
import { getImageFileUrl, loadImageElement, renameFile } from '../../utils/dom'; import { getImageFileUrl, loadImageElement, renameFile } from '../../utils/dom';
import { replaceSpaceWithDash, suffixRename } from '../../utils/common'; import { replaceSpaceWithDash, suffixRename } from '../../utils/common';
import { getFileNameWithoutExt } from '../../utils/mimeTypes'; import { getFileNameWithoutExt } from '../../utils/mimeTypes';
@ -110,7 +110,7 @@ export const ImagePackContent = as<'div', ImagePackContentProps>(
[imagePack.meta] [imagePack.meta]
); );
const handleUploadRemove = useCallback((file: TUploadContent) => { const handleUploadRemove = useCallback((file: UploadContent) => {
setFiles((fs) => fs.filter((f) => f !== file)); setFiles((fs) => fs.filter((f) => f !== file));
}, []); }, []);

View file

@ -2,17 +2,16 @@ import React from 'react';
import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds'; import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds';
import { PackAddress } from '../../plugins/custom-emoji'; import { PackAddress } from '../../plugins/custom-emoji';
import { Page, PageHeader, PageContent } from '../page'; import { Page, PageHeader, PageContent } from '../page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomImagePack } from './RoomImagePack'; import { RoomImagePack } from './RoomImagePack';
import { UserImagePack } from './UserImagePack'; import { UserImagePack } from './UserImagePack';
import { useClient } from '../../hooks/useClient';
type ImagePackViewProps = { type ImagePackViewProps = {
address: PackAddress | undefined; address: PackAddress | undefined;
requestClose: () => void; requestClose: () => void;
}; };
export function ImagePackView({ address, requestClose }: ImagePackViewProps) { export function ImagePackView({ address, requestClose }: ImagePackViewProps) {
const mx = useMatrixClient(); const ct = useClient();
const room = address && mx.getRoom(address.roomId);
return ( return (
<Page> <Page>
@ -38,8 +37,8 @@ export function ImagePackView({ address, requestClose }: ImagePackViewProps) {
<Box grow="Yes"> <Box grow="Yes">
<Scroll hideTrack visibility="Hover"> <Scroll hideTrack visibility="Hover">
<PageContent> <PageContent>
{room && address ? ( {address && ct.rooms.join.read.includes(address.roomId) ? (
<RoomImagePack room={room} stateKey={address.stateKey} /> <RoomImagePack roomId={address.roomId} stateKey={address.stateKey} />
) : ( ) : (
<UserImagePack /> <UserImagePack />
)} )}

View file

@ -1,14 +1,14 @@
import React, { FormEventHandler, ReactNode, useMemo, useState } from 'react'; import React, { FormEventHandler, ReactNode, useMemo, useState } from 'react';
import { Badge, Box, Button, Chip, Icon, Icons, Input, Text } from 'folds'; import { Badge, Box, Button, Chip, Icon, Icons, Input, Text } from 'folds';
import { UsageSwitcher, useUsageStr } from './UsageSwitcher'; import { UsageSwitcher, useUsageStr } from './UsageSwitcher';
import { mxcUrlToHttp } from '../../utils/matrix';
import * as css from './style.css'; import * as css from './style.css';
import { ImageUsage, imageUsageEqual, PackImageReader } from '../../plugins/custom-emoji'; import { ImageUsage, imageUsageEqual, PackImageReader } from '../../plugins/custom-emoji';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { SettingTile } from '../setting-tile'; import { SettingTile } from '../setting-tile';
import { useObjectURL } from '../../hooks/useObjectURL'; import { useObjectURL } from '../../hooks/useObjectURL';
import { createUploadAtom, TUploadAtom } from '../../state/upload'; import { createUploadAtom, UploadAtom } from '../../state/upload';
import { replaceSpaceWithDash } from '../../utils/common'; import { replaceSpaceWithDash } from '../../utils/common';
import { useClient } from '../../hooks/useClient';
import { mxcToHttp } from '../../../client/mxcToHttp';
type ImageTileProps = { type ImageTileProps = {
defaultShortcode: string; defaultShortcode: string;
@ -30,7 +30,7 @@ export function ImageTile({
onDeleteToggle, onDeleteToggle,
deleted, deleted,
}: ImageTileProps) { }: ImageTileProps) {
const mx = useMatrixClient(); const ct = useClient();
const getUsageStr = useUsageStr(); const getUsageStr = useUsageStr();
return ( return (
@ -38,7 +38,7 @@ export function ImageTile({
before={ before={
<img <img
className={css.ImagePackImage} className={css.ImagePackImage}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''} src={mxcToHttp(ct.session.baseUrl, image.url, useAuthentication).url() ?? ''}
alt={image.shortcode} alt={image.shortcode}
loading="lazy" loading="lazy"
/> />
@ -91,7 +91,7 @@ export function ImageTile({
type ImageTileUploadProps = { type ImageTileUploadProps = {
file: File; file: File;
children: (uploadAtom: TUploadAtom) => ReactNode; children: (uploadAtom: UploadAtom) => ReactNode;
}; };
export function ImageTileUpload({ file, children }: ImageTileUploadProps) { export function ImageTileUpload({ file, children }: ImageTileUploadProps) {
const url = useObjectURL(file); const url = useObjectURL(file);
@ -120,7 +120,7 @@ export function ImageTileEdit({
onCancel, onCancel,
onSave, onSave,
}: ImageTileEditProps) { }: ImageTileEditProps) {
const mx = useMatrixClient(); const ct = useClient();
const defaultUsage = image.usage ?? packUsage; const defaultUsage = image.usage ?? packUsage;
const [unsavedUsage, setUnsavedUsages] = useState(defaultUsage); const [unsavedUsage, setUnsavedUsages] = useState(defaultUsage);
@ -162,7 +162,7 @@ export function ImageTileEdit({
before={ before={
<img <img
className={css.ImagePackImage} className={css.ImagePackImage}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''} src={mxcToHttp(ct.session.baseUrl, image.url, useAuthentication).url() ?? ''}
alt={image.shortcode} alt={image.shortcode}
loading="lazy" loading="lazy"
/> />

View file

@ -13,8 +13,6 @@ import {
Chip, Chip,
} from 'folds'; } from 'folds';
import Linkify from 'linkify-react'; import Linkify from 'linkify-react';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { nameInitials } from '../../utils/common'; import { nameInitials } from '../../utils/common';
import { BreakWord } from '../../styles/Text.css'; import { BreakWord } from '../../styles/Text.css';
import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser'; import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser';
@ -25,6 +23,8 @@ import { createUploadAtom, UploadSuccess } from '../../state/upload';
import { CompactUploadCardRenderer } from '../upload-card'; import { CompactUploadCardRenderer } from '../upload-card';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { PackMetaReader } from '../../plugins/custom-emoji'; import { PackMetaReader } from '../../plugins/custom-emoji';
import { useClient } from '../../hooks/useClient';
import { mxcToHttp } from '../../../client/mxcToHttp';
type ImagePackAvatarProps = { type ImagePackAvatarProps = {
url?: string; url?: string;
@ -50,10 +50,10 @@ type ImagePackProfileProps = {
onEdit?: () => void; onEdit?: () => void;
}; };
export function ImagePackProfile({ meta, canEdit, onEdit }: ImagePackProfileProps) { export function ImagePackProfile({ meta, canEdit, onEdit }: ImagePackProfileProps) {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const avatarUrl = meta.avatar const avatarUrl = meta.avatar
? mxcUrlToHttp(mx, meta.avatar, useAuthentication) ?? undefined ? mxcToHttp(ct.session.baseUrl, meta.avatar, useAuthentication).url()
: undefined; : undefined;
return ( return (
@ -97,11 +97,13 @@ type ImagePackProfileEditProps = {
onSave: (meta: PackMetaReader) => void; onSave: (meta: PackMetaReader) => void;
}; };
export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfileEditProps) { export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfileEditProps) {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [avatar, setAvatar] = useState(meta.avatar); const [avatar, setAvatar] = useState(meta.avatar);
const avatarUrl = avatar ? mxcUrlToHttp(mx, avatar, useAuthentication) ?? undefined : undefined; const avatarUrl = avatar
? mxcToHttp(ct.session.baseUrl, avatar, useAuthentication).url()
: undefined;
const [imageFile, setImageFile] = useState<File>(); const [imageFile, setImageFile] = useState<File>();
const avatarFileUrl = useObjectURL(imageFile); const avatarFileUrl = useObjectURL(imageFile);

View file

@ -1,28 +1,30 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { Room } from 'matrix-js-sdk'; import { putStateEvent } from 'neurone/lib/api/matrix/client/v3/rooms';
import { usePowerLevels } from '../../hooks/usePowerLevels'; import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { usePowerLevels, usePowerLevelsState } from '../../hooks/usePowerLevels';
import { ImagePackContent } from './ImagePackContent'; import { ImagePackContent } from './ImagePackContent';
import { ImagePack, PackContent } from '../../plugins/custom-emoji'; import { ImagePack, PackContent } from '../../plugins/custom-emoji';
import { StateEvent } from '../../../types/matrix/room'; import { StateEventType } from '../../../types/matrix/room';
import { useRoomImagePack } from '../../hooks/useImagePacks'; import { useRoomImagePack } from '../../hooks/useImagePacks';
import { randomStr } from '../../utils/common'; import { randomStr } from '../../utils/common';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useClient } from '../../hooks/useClient';
type RoomImagePackProps = { type RoomImagePackProps = {
room: Room; roomId: string;
stateKey: string; stateKey: string;
}; };
export function RoomImagePack({ room, stateKey }: RoomImagePackProps) { export function RoomImagePack({ roomId, stateKey }: RoomImagePackProps) {
const mx = useMatrixClient(); const ct = useClient();
const userId = mx.getUserId()!; const { userId } = ct.session;
const powerLevels = usePowerLevels(room); const powerLevelsStateResult = usePowerLevelsState(roomId);
const creators = useRoomCreators(room); const powerLevels = usePowerLevels(powerLevelsStateResult.value);
const creators = useRoomCreators(roomId);
const permissions = useRoomPermissions(creators, powerLevels); const permissions = useRoomPermissions(creators, powerLevels);
const canEditImagePack = permissions.stateEvent(StateEvent.PoniesRoomEmotes, userId); const canEditImagePack = permissions.stateEvent(StateEventType.PoniesRoomEmotes, userId);
const fallbackPack = useMemo(() => { const fallbackPack = useMemo(() => {
const fakePackId = randomStr(4); const fakePackId = randomStr(4);
@ -30,26 +32,33 @@ export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
fakePackId, fakePackId,
{}, {},
{ {
roomId: room.roomId, roomId,
stateKey, stateKey,
} }
); );
}, [room.roomId, stateKey]); }, [roomId, stateKey]);
const imagePack = useRoomImagePack(room, stateKey) ?? fallbackPack; const imagePackResult = useRoomImagePack(roomId, stateKey);
const imagePack = imagePackResult.value ?? fallbackPack;
const handleUpdate = useCallback( const handleUpdate = useCallback(
async (packContent: PackContent) => { async (packContent: PackContent) => {
const { address } = imagePack; const { address } = imagePack;
if (!address) return; if (!address) return;
await mx.sendStateEvent( const resp = await ct.requestAuthJSON(
address.roomId, putStateEvent({
StateEvent.PoniesRoomEmotes, pathParams: {
packContent, roomId: address.roomId,
address.stateKey eventType: StateEventType.PoniesRoomEmotes,
stateKey: address.stateKey,
},
body: packContent,
})
); );
const [err] = await parseMatrixResp(resp);
if (err) throw new Error(err.data.error ?? err.data.errcode);
}, },
[mx, imagePack] [ct, imagePack]
); );
return ( return (

View file

@ -1,22 +1,32 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { ImagePackContent } from './ImagePackContent'; import { ImagePackContent } from './ImagePackContent';
import { ImagePack, PackContent } from '../../plugins/custom-emoji'; import { ImagePack, PackContent } from '../../plugins/custom-emoji';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { AccountDataType } from '../../../types/matrix/accountData';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { useUserImagePack } from '../../hooks/useImagePacks'; import { useUserImagePack } from '../../hooks/useImagePacks';
import { useClient } from '../../hooks/useClient';
export function UserImagePack() { export function UserImagePack() {
const mx = useMatrixClient(); const ct = useClient();
const defaultPack = useMemo(() => new ImagePack(mx.getUserId() ?? '', {}, undefined), [mx]); const defaultPack = useMemo(() => new ImagePack(ct.session.userId ?? '', {}, undefined), [ct]);
const imagePack = useUserImagePack(); const imagePackResult = useUserImagePack();
const imagePack = imagePackResult.value ?? defaultPack;
const handleUpdate = useCallback( const handleUpdate = useCallback(
async (packContent: PackContent) => { async (packContent: PackContent) => {
await mx.setAccountData(AccountDataEvent.PoniesUserEmotes, packContent); const resp = await ct.requestAuthJSON(
putAccountData({
pathParams: { type: AccountDataType.PoniesUserEmotes, userId: ct.session.userId },
body: packContent,
})
);
const [err] = await parseMatrixResp(resp);
if (err) throw new Error(err.data.error ?? err.data.errcode);
}, },
[mx] [ct]
); );
return <ImagePackContent imagePack={imagePack ?? defaultPack} canEdit onUpdate={handleUpdate} />; return <ImagePackContent imagePack={imagePack} canEdit onUpdate={handleUpdate} />;
} }

View file

@ -3,7 +3,6 @@ import React, {
FormEventHandler, FormEventHandler,
KeyboardEventHandler, KeyboardEventHandler,
useCallback, useCallback,
useMemo,
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
@ -29,19 +28,19 @@ import {
Scroll, Scroll,
MenuItem, MenuItem,
} from 'folds'; } from 'folds';
import { Room } from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { postInvite } from 'neurone/lib/api/matrix/client/v3/rooms';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { useDirectUsers } from '../../hooks/useDirectUsers'; import { useDirectUsers } from '../../hooks/useDirectUsers';
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix'; import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix';
import { Membership } from '../../../types/matrix/room';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser'; import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { BreakWord } from '../../styles/Text.css'; import { BreakWord } from '../../styles/Text.css';
import { useAlive } from '../../hooks/useAlive'; import { useAlive } from '../../hooks/useAlive';
import { useClient } from '../../hooks/useClient';
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000, limit: 1000,
@ -52,27 +51,19 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = {
const getUserIdString = (userId: string) => getMxIdLocalPart(userId) ?? userId; const getUserIdString = (userId: string) => getMxIdLocalPart(userId) ?? userId;
type InviteUserProps = { type InviteUserProps = {
room: Room; roomId: string;
requestClose: () => void; requestClose: () => void;
}; };
export function InviteUserPrompt({ room, requestClose }: InviteUserProps) { export function InviteUserPrompt({ roomId, requestClose }: InviteUserProps) {
const mx = useMatrixClient(); const ct = useClient();
const alive = useAlive(); const alive = useAlive();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const directUsers = useDirectUsers(); const directUsers = useDirectUsers();
const [validUserId, setValidUserId] = useState<string>(); const [validUserId, setValidUserId] = useState<string>();
const filteredUsers = useMemo(
() =>
directUsers.filter((userId) => {
const membership = room.getMember(userId)?.membership;
return membership !== Membership.Join;
}),
[directUsers, room]
);
const [result, search, resetSearch] = useAsyncSearch( const [result, search, resetSearch] = useAsyncSearch(
filteredUsers, directUsers,
getUserIdString, getUserIdString,
SEARCH_OPTIONS SEARCH_OPTIONS
); );
@ -83,9 +74,19 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
const [inviteState, invite] = useAsyncCallback<void, Error, [string, string | undefined]>( const [inviteState, invite] = useAsyncCallback<void, Error, [string, string | undefined]>(
useCallback( useCallback(
async (userId, reason) => { async (userId, reason) => {
await mx.invite(room.roomId, userId, reason); const resp = await ct.requestAuthJSON(
postInvite({
pathParams: { roomId },
body: {
user_id: userId,
reason,
},
})
);
const [err] = await parseMatrixResp(resp);
if (err) throw new Error(err.data.error ?? err.data.errcode);
}, },
[mx, room] [ct, roomId]
) )
); );

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback } from 'react';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { import {
Dialog, Dialog,
@ -16,10 +16,13 @@ import {
Button, Button,
Spinner, Spinner,
} from 'folds'; } from 'folds';
import { MatrixError } from 'matrix-js-sdk'; import { postLeave } from 'neurone/lib/api/matrix/client/v3/rooms';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { useClient } from '../../hooks/useClient';
import { deleteJoinRooms } from '../../../client/syncWriter/utils';
import { useAlive } from '../../hooks/useAlive';
type LeaveRoomPromptProps = { type LeaveRoomPromptProps = {
roomId: string; roomId: string;
@ -27,24 +30,26 @@ type LeaveRoomPromptProps = {
onCancel: () => void; onCancel: () => void;
}; };
export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptProps) { export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptProps) {
const mx = useMatrixClient(); const ct = useClient();
const alive = useAlive();
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>( const [leaveState, leaveRoom] = useAsyncCallback<void, Error, []>(
useCallback(async () => { useCallback(async () => {
mx.leave(roomId); const response = await ct.requestAuthJSON(postLeave({ pathParams: { roomId } }));
}, [mx, roomId]) deleteJoinRooms(ct, [roomId]);
const [err] = await parseMatrixResp(response);
if (err) throw new Error(err.data.error ?? err.data.errcode);
}, [ct, roomId])
); );
const handleLeave = () => { const handleLeave = () => {
leaveRoom(); leaveRoom().then(() => {
if (alive()) {
onDone();
}
});
}; };
useEffect(() => {
if (leaveState.status === AsyncStatus.Success) {
onDone();
}
}, [leaveState, onDone]);
return ( return (
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter> <OverlayCenter>

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback } from 'react';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { import {
Dialog, Dialog,
@ -16,10 +16,13 @@ import {
Button, Button,
Spinner, Spinner,
} from 'folds'; } from 'folds';
import { MatrixError } from 'matrix-js-sdk'; import { postLeave } from 'neurone/lib/api/matrix/client/v3/rooms';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { useClient } from '../../hooks/useClient';
import { deleteJoinRooms } from '../../../client/syncWriter/utils';
import { useAlive } from '../../hooks/useAlive';
type LeaveSpacePromptProps = { type LeaveSpacePromptProps = {
roomId: string; roomId: string;
@ -27,24 +30,26 @@ type LeaveSpacePromptProps = {
onCancel: () => void; onCancel: () => void;
}; };
export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) { export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) {
const mx = useMatrixClient(); const ct = useClient();
const alive = useAlive();
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>( const [leaveState, leaveRoom] = useAsyncCallback<void, Error, []>(
useCallback(async () => { useCallback(async () => {
mx.leave(roomId); const response = await ct.requestAuthJSON(postLeave({ pathParams: { roomId } }));
}, [mx, roomId]) deleteJoinRooms(ct, [roomId]);
const [err] = await parseMatrixResp(response);
if (err) throw new Error(err.data.error ?? err.data.errcode);
}, [ct, roomId])
); );
const handleLeave = () => { const handleLeave = () => {
leaveRoom(); leaveRoom().then(() => {
if (alive()) {
onDone();
}
});
}; };
useEffect(() => {
if (leaveState.status === AsyncStatus.Success) {
onDone();
}
}, [leaveState, onDone]);
return ( return (
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter> <OverlayCenter>

View file

@ -1,48 +1,73 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { as, Avatar, Box, Icon, Icons, Text } from 'folds'; import { as, Avatar, Box, Icon, Icons, Text } from 'folds';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { UserAvatar } from '../user-avatar'; import { UserAvatar } from '../user-avatar';
import * as css from './style.css'; import * as css from './style.css';
import { ClientContext } from '../../../client/context';
const getName = (room: Room, member: RoomMember) => import { JoinRoomMemberObject } from '../../../client/storage/collections';
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId; import { GetRoomMemberNameCallback, GetRoomMemberAvatarCallback } from '../../hooks/useRoomMembers';
import { useUserPresence } from '../../hooks/useUserPresence';
import { mxcToHttp } from '../../../client/mxcToHttp';
import { getMxIdLocalPart } from '../../utils/matrix';
import { PresenceBadge, AvatarPresence } from '../presence';
type MemberTileProps = { type MemberTileProps = {
mx: MatrixClient; ct: ClientContext;
room: Room; member: JoinRoomMemberObject;
member: RoomMember; getName: GetRoomMemberNameCallback;
getAvatar: GetRoomMemberAvatarCallback;
useAuthentication: boolean; useAuthentication: boolean;
after?: ReactNode; after?: ReactNode;
}; };
export const MemberTile = as<'button', MemberTileProps>( export const MemberTile = as<'button', MemberTileProps>(
({ as: AsMemberTile = 'button', mx, room, member, useAuthentication, after, ...props }, ref) => { (
const name = getName(room, member); {
as: AsMemberTile = 'button',
ct,
member,
getName,
getAvatar,
useAuthentication,
after,
...props
},
ref
) => {
const presence = useUserPresence(member.userId);
const username = getMxIdLocalPart(member.userId); const username = getMxIdLocalPart(member.userId);
const avatarMxcUrl = member.getMxcAvatarUrl(); const name = getName(member.userId);
const avatarMxcUrl = getAvatar(member.userId);
const avatarUrl = avatarMxcUrl const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication) ? mxcToHttp(ct.session.baseUrl, avatarMxcUrl, useAuthentication).thumbnail({
width: 96,
height: 96,
method: 'crop',
})
: undefined; : undefined;
return ( return (
<AsMemberTile className={css.MemberTile} {...props} ref={ref}> <AsMemberTile className={css.MemberTile} {...props} ref={ref}>
<Avatar size="300" radii="400"> <AvatarPresence
<UserAvatar variant="Surface"
userId={member.userId} badge={presence && <PresenceBadge size="300" presence={presence.presence} />}
src={avatarUrl ?? undefined} >
alt={name} <Avatar size="300" radii="400">
renderFallback={() => <Icon size="300" src={Icons.User} filled />} <UserAvatar
/> userId={member.userId}
</Avatar> src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="300" src={Icons.User} filled />}
/>
</Avatar>
</AvatarPresence>
<Box grow="Yes" as="span" direction="Column"> <Box grow="Yes" as="span" direction="Column">
<Text as="span" size="T300" truncate> <Text as="span" size="T300" truncate>
<b>{name}</b> <b>{name}</b>
</Text> </Text>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="100"> <Box alignItems="Center" justifyContent="SpaceBetween" gap="100">
<Text as="span" size="T200" priority="300" truncate> <Text as="span" size="T200" priority="300" truncate>
{username} @{username}
</Text> </Text>
</Box> </Box>
</Box> </Box>

View file

@ -3,15 +3,11 @@ import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import { mimeTypeToExt } from '../../utils/mimeTypes'; import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { import { decryptFile, downloadEncryptedMedia, downloadMedia } from '../../utils/matrix';
decryptFile, import { useClient } from '../../hooks/useClient';
downloadEncryptedMedia, import { mxcToHttp } from '../../../client/mxcToHttp';
downloadMedia,
mxcUrlToHttp,
} from '../../utils/matrix';
const badgeStyles = { maxWidth: toRem(100) }; const badgeStyles = { maxWidth: toRem(100) };
@ -19,24 +15,25 @@ type FileDownloadButtonProps = {
filename: string; filename: string;
url: string; url: string;
mimeType: string; mimeType: string;
encInfo?: EncryptedAttachmentInfo; encInfo?: object;
}; };
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) { export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [downloadState, download] = useAsyncCallback( const [downloadState, download] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url();
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
)
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent); const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, filename); FileSaver.saveAs(fileURL, filename);
return fileURL; return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, filename]) }, [ct, url, useAuthentication, mimeType, encInfo, filename])
); );
const downloading = downloadState.status === AsyncStatus.Loading; const downloading = downloadState.status === AsyncStatus.Loading;

View file

@ -1,88 +1,131 @@
import React, { CSSProperties, ReactNode } from 'react'; import React, { CSSProperties } from 'react';
import { Box, Chip, Icon, Icons, Text, toRem } from 'folds'; import { Box, Chip, Icon, Icons, Text, toRem } from 'folds';
import { IContent } from 'matrix-js-sdk'; import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex'; import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
import { trimReplyFromBody } from '../../utils/room'; import { trimReplyFromBody } from '../../utils/room';
import { MessageTextBody } from './layout'; import { MessageTextBody } from './layout';
import { import {
AudioContent,
FileContent,
ImageContent,
MessageBadEncryptedContent, MessageBadEncryptedContent,
MessageBrokenContent, MessageBrokenContent,
MessageDeletedContent, MessageDeletedContent,
MessageEditedContent, MessageEditedContent,
UnsupportedMessageEventType,
MessageUnsupportedContent, MessageUnsupportedContent,
VideoContent,
MessageEmptyContent,
} from './content'; } from './content';
import {
IAudioContent,
IAudioInfo,
IEncryptedFile,
IFileContent,
IFileInfo,
IImageContent,
IImageInfo,
IThumbnailContent,
IVideoContent,
IVideoInfo,
MATRIX_SPOILER_PROPERTY_NAME,
MATRIX_SPOILER_REASON_PROPERTY_NAME,
} 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';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment'; import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader'; import { FileHeader, FileDownloadButton } from './FileHeader';
import { RenderBody } from './RenderBody';
import { rContent, rEncryptedFile, rInfo } from '../../../client/reader';
import { UrlPreviewCard, UrlPreviewHolder } from '../url-preview';
import { testMatrixTo } from '../../plugins/matrix-to';
export function MBadEncrypted() { type TextProps = {
size?: 'T400' | 'T300';
};
export function MBadEncrypted({ size }: TextProps) {
return ( return (
<Text> <Text size={size}>
<MessageBadEncryptedContent /> <MessageBadEncryptedContent />
</Text> </Text>
); );
} }
type RedactedContentProps = { type RedactedContentProps = TextProps & {
reason?: string; reason?: string;
}; };
export function RedactedContent({ reason }: RedactedContentProps) {
export function RedactedContent({ size, reason }: RedactedContentProps) {
return ( return (
<Text> <Text size={size}>
<MessageDeletedContent reason={reason} /> <MessageDeletedContent reason={reason} />
</Text> </Text>
); );
} }
export function UnsupportedContent() { export function UnsupportedContent({ size, eventType }: TextProps & { eventType?: string }) {
return ( return (
<Text> <Text size={size}>
<MessageUnsupportedContent /> {eventType ? (
<UnsupportedMessageEventType eventType={eventType} />
) : (
<MessageUnsupportedContent />
)}
</Text> </Text>
); );
} }
export function BrokenContent() { export function BrokenContent({ size }: TextProps) {
return ( return (
<Text> <Text size={size}>
<MessageBrokenContent /> <MessageBrokenContent />
</Text> </Text>
); );
} }
type RenderBodyProps = { export function EmptyContent({ size }: TextProps) {
return (
<Text size={size}>
<MessageEmptyContent />
</Text>
);
}
type UrlPreviewProps = {
body: string; body: string;
customBody?: string; ts: number;
}; };
function UrlPreview({ body, ts }: UrlPreviewProps) {
const urlsMatch = body.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
if (!urls) return null;
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
if (filteredUrls.length === 0) return null;
return (
<UrlPreviewHolder>
{filteredUrls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={ts} />
))}
</UrlPreviewHolder>
);
}
type MTextProps = { type MTextProps = {
edited?: boolean; edited?: boolean;
content: Record<string, unknown>; content: object;
renderBody: (props: RenderBodyProps) => ReactNode; urlPreview?: boolean;
renderUrlsPreview?: (urls: string[]) => ReactNode; ts: number;
style?: CSSProperties; style?: CSSProperties;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
}; };
export function MText({ edited, content, renderBody, renderUrlsPreview, style }: MTextProps) { export function MText({
const { body, formatted_body: customBody } = content; edited,
content,
urlPreview,
ts,
style,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
}: MTextProps) {
const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
const trimmedBody = trimReplyFromBody(body ?? '');
if (typeof body !== 'string') return <BrokenContent />; if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
return ( return (
<> <>
@ -91,13 +134,16 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)} jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
style={style} style={style}
> >
{renderBody({ <RenderBody
body: trimmedBody, body={trimmedBody}
customBody: typeof customBody === 'string' ? customBody : undefined, customBody={customBody}
})} highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{edited && <MessageEditedContent />} {edited && <MessageEditedContent />}
</MessageTextBody> </MessageTextBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)} {urlPreview && <UrlPreview body={trimmedBody} ts={ts} />}
</> </>
); );
} }
@ -105,23 +151,28 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
type MEmoteProps = { type MEmoteProps = {
displayName: string; displayName: string;
edited?: boolean; edited?: boolean;
content: Record<string, unknown>; content: object;
renderBody: (props: RenderBodyProps) => ReactNode; urlPreview?: boolean;
renderUrlsPreview?: (urls: string[]) => ReactNode; ts: number;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
}; };
export function MEmote({ export function MEmote({
displayName, displayName,
edited, edited,
content, content,
renderBody, urlPreview,
renderUrlsPreview, ts,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
}: MEmoteProps) { }: MEmoteProps) {
const { body, formatted_body: customBody } = content; const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
if (typeof body !== 'string') return <BrokenContent />; if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body); const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
return ( return (
<> <>
@ -131,30 +182,43 @@ export function MEmote({
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)} jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
> >
<b>{`${displayName} `}</b> <b>{`${displayName} `}</b>
{renderBody({ <RenderBody
body: trimmedBody, body={trimmedBody}
customBody: typeof customBody === 'string' ? customBody : undefined, customBody={customBody}
})} highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{edited && <MessageEditedContent />} {edited && <MessageEditedContent />}
</MessageTextBody> </MessageTextBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)} {urlPreview && <UrlPreview body={trimmedBody} ts={ts} />}
</> </>
); );
} }
type MNoticeProps = { type MNoticeProps = {
edited?: boolean; edited?: boolean;
content: Record<string, unknown>; content: object;
renderBody: (props: RenderBodyProps) => ReactNode; urlPreview?: boolean;
renderUrlsPreview?: (urls: string[]) => ReactNode; ts: number;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
}; };
export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNoticeProps) { export function MNotice({
const { body, formatted_body: customBody } = content; edited,
content,
urlPreview,
ts,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
}: MNoticeProps) {
const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
if (typeof body !== 'string') return <BrokenContent />; if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body); const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
return ( return (
<> <>
@ -163,39 +227,34 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
preWrap={typeof customBody !== 'string'} preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)} jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
> >
{renderBody({ <RenderBody
body: trimmedBody, body={trimmedBody}
customBody: typeof customBody === 'string' ? customBody : undefined, customBody={customBody}
})} highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{edited && <MessageEditedContent />} {edited && <MessageEditedContent />}
</MessageTextBody> </MessageTextBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)} {urlPreview && <UrlPreview body={trimmedBody} ts={ts} />}
</> </>
); );
} }
type RenderImageContentProps = {
body: string;
filename?: string;
info?: IImageInfo & IThumbnailContent;
mimeType?: string;
url: string;
encInfo?: IEncryptedFile;
markedAsSpoiler?: boolean;
spoilerReason?: string;
};
type MImageProps = { type MImageProps = {
content: IImageContent; content: object;
renderImageContent: (props: RenderImageContentProps) => ReactNode; mediaAutoLoad?: boolean;
outlined?: boolean; outlined?: boolean;
}; };
export function MImage({ content, renderImageContent, outlined }: MImageProps) { export function MImage({ content, mediaAutoLoad, outlined }: MImageProps) {
const imgInfo = content?.info; const info = rContent.info(content);
const mxcUrl = content.file?.url ?? content.url; const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
if (typeof mxcUrl !== 'string') { if (typeof mxcUrl !== 'string') {
return <BrokenContent />; return <BrokenContent />;
} }
const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
const height = scaleYDimension(rInfo.w(info) || 400, 400, rInfo.h(info) || 400);
return ( return (
<Attachment outlined={outlined}> <Attachment outlined={outlined}>
@ -204,178 +263,154 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
height: toRem(height < 48 ? 48 : height), height: toRem(height < 48 ? 48 : height),
}} }}
> >
{renderImageContent({ <ImageContent
body: content.body || 'Image', body={rContent.body(content) ?? 'Image'}
info: imgInfo, info={info}
mimeType: imgInfo?.mimetype, mimeType={rInfo.mimetype(info)}
url: mxcUrl, url={mxcUrl}
encInfo: content.file, encInfo={rContent.file(content)}
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME], autoPlay={mediaAutoLoad}
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME], markedAsSpoiler={rContent.spoiler(content)}
})} spoilerReason={rContent.spoilerReason(content)}
/>
</AttachmentBox> </AttachmentBox>
</Attachment> </Attachment>
); );
} }
type RenderVideoContentProps = { type MFileProps = {
body: string; content: object;
info: IVideoInfo & IThumbnailContent;
mimeType: string;
url: string;
encInfo?: IEncryptedFile;
markedAsSpoiler?: boolean;
spoilerReason?: string;
};
type MVideoProps = {
content: IVideoContent;
renderAsFile: () => ReactNode;
renderVideoContent: (props: RenderVideoContentProps) => ReactNode;
outlined?: boolean; outlined?: boolean;
}; };
export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: MVideoProps) { export function MFile({ content, outlined }: MFileProps) {
const videoInfo = content?.info; const fileInfo = rContent.info(content);
const mxcUrl = content.file?.url ?? content.url; const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
const safeMimeType = getBlobSafeMimeType(videoInfo?.mimetype ?? '');
if (!videoInfo || !safeMimeType.startsWith('video') || typeof mxcUrl !== 'string') { if (typeof mxcUrl !== 'string') {
if (mxcUrl) {
return renderAsFile();
}
return <BrokenContent />; return <BrokenContent />;
} }
const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400); const body = rContent.filename(content) ?? rContent.body(content) ?? 'Unnamed File';
const mimeType = rInfo.mimetype(fileInfo) ?? FALLBACK_MIMETYPE;
const filename = content.filename ?? content.body ?? 'Video';
return ( return (
<Attachment outlined={outlined}> <Attachment outlined={outlined}>
<AttachmentHeader> <AttachmentHeader>
<FileHeader <FileHeader body={body} mimeType={mimeType} />
body={filename}
mimeType={safeMimeType}
after={
<FileDownloadButton
filename={filename}
url={mxcUrl}
mimeType={safeMimeType}
encInfo={content.file}
/>
}
/>
</AttachmentHeader>
<AttachmentBox
style={{
height: toRem(height < 48 ? 48 : height),
}}
>
{renderVideoContent({
body: content.body || 'Video',
info: videoInfo,
mimeType: safeMimeType,
url: mxcUrl,
encInfo: content.file,
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})}
</AttachmentBox>
</Attachment>
);
}
type RenderAudioContentProps = {
info: IAudioInfo;
mimeType: string;
url: string;
encInfo?: IEncryptedFile;
};
type MAudioProps = {
content: IAudioContent;
renderAsFile: () => ReactNode;
renderAudioContent: (props: RenderAudioContentProps) => ReactNode;
outlined?: boolean;
};
export function MAudio({ content, renderAsFile, renderAudioContent, outlined }: MAudioProps) {
const audioInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
const safeMimeType = getBlobSafeMimeType(audioInfo?.mimetype ?? '');
if (!audioInfo || !safeMimeType.startsWith('audio') || typeof mxcUrl !== 'string') {
if (mxcUrl) {
return renderAsFile();
}
return <BrokenContent />;
}
const filename = content.filename ?? content.body ?? 'Audio';
return (
<Attachment outlined={outlined}>
<AttachmentHeader>
<FileHeader
body={filename}
mimeType={safeMimeType}
after={
<FileDownloadButton
filename={filename}
url={mxcUrl}
mimeType={safeMimeType}
encInfo={content.file}
/>
}
/>
</AttachmentHeader> </AttachmentHeader>
<AttachmentBox> <AttachmentBox>
<AttachmentContent> <AttachmentContent>
{renderAudioContent({ <FileContent
info: audioInfo, body={body}
mimeType: safeMimeType, mimeType={mimeType}
url: mxcUrl, info={fileInfo ?? {}}
encInfo: content.file, url={mxcUrl}
})} encInfo={rContent.file(content)}
/>
</AttachmentContent> </AttachmentContent>
</AttachmentBox> </AttachmentBox>
</Attachment> </Attachment>
); );
} }
type RenderFileContentProps = { type MVideoProps = {
body: string; content: object;
info: IFileInfo & IThumbnailContent; mediaAutoLoad?: boolean;
mimeType: string;
url: string;
encInfo?: IEncryptedFile;
};
type MFileProps = {
content: IFileContent;
renderFileContent: (props: RenderFileContentProps) => ReactNode;
outlined?: boolean; outlined?: boolean;
}; };
export function MFile({ content, renderFileContent, outlined }: MFileProps) { export function MVideo({ content, mediaAutoLoad, outlined }: MVideoProps) {
const fileInfo = content?.info; const videoInfo = rContent.info(content);
const mxcUrl = content.file?.url ?? content.url; const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
const safeMimeType = getBlobSafeMimeType(rInfo.mimetype(videoInfo) ?? '');
if (typeof mxcUrl !== 'string') { if (!videoInfo || !safeMimeType.startsWith('video') || typeof mxcUrl !== 'string') {
if (mxcUrl) {
return <MFile content={content} outlined={outlined} />;
}
return <BrokenContent />; return <BrokenContent />;
} }
const height = scaleYDimension(rInfo.w(videoInfo) || 400, 400, rInfo.h(videoInfo) || 400);
const filename = rContent.filename(content) ?? rContent.body(content) ?? 'Video';
return ( return (
<Attachment outlined={outlined}> <Attachment outlined={outlined}>
<AttachmentHeader> <AttachmentHeader>
<FileHeader <FileHeader
body={content.filename ?? content.body ?? 'Unnamed File'} body={filename}
mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE} mimeType={safeMimeType}
after={
<FileDownloadButton
filename={filename}
url={mxcUrl}
mimeType={safeMimeType}
encInfo={rContent.file(content)}
/>
}
/>
</AttachmentHeader>
<AttachmentBox
style={{
height: toRem(height < 48 ? 48 : height),
}}
>
<VideoContent
body={rContent.body(content) || 'Video'}
info={videoInfo}
mimeType={safeMimeType}
url={mxcUrl}
encInfo={rContent.file(content)}
mediaAutoLoad={mediaAutoLoad}
markedAsSpoiler={rContent.spoiler(content)}
spoilerReason={rContent.spoilerReason(content)}
/>
</AttachmentBox>
</Attachment>
);
}
type MAudioProps = {
content: object;
outlined?: boolean;
};
export function MAudio({ content, outlined }: MAudioProps) {
const audioInfo = rContent.info(content);
const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
const safeMimeType = getBlobSafeMimeType(rInfo.mimetype(audioInfo) ?? '');
if (!audioInfo || !safeMimeType.startsWith('audio') || typeof mxcUrl !== 'string') {
if (mxcUrl) {
return <MFile content={content} outlined={outlined} />;
}
return <BrokenContent />;
}
const filename = rContent.filename(content) ?? rContent.body(content) ?? 'Audio';
return (
<Attachment outlined={outlined}>
<AttachmentHeader>
<FileHeader
body={filename}
mimeType={safeMimeType}
after={
<FileDownloadButton
filename={filename}
url={mxcUrl}
mimeType={safeMimeType}
encInfo={rContent.file(content)}
/>
}
/> />
</AttachmentHeader> </AttachmentHeader>
<AttachmentBox> <AttachmentBox>
<AttachmentContent> <AttachmentContent>
{renderFileContent({ <AudioContent
body: content.filename ?? content.body ?? 'File', info={audioInfo}
info: fileInfo ?? {}, mimeType={safeMimeType}
mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE, url={mxcUrl}
url: mxcUrl, encInfo={rContent.file(content)}
encInfo: content.file, />
})}
</AttachmentContent> </AttachmentContent>
</AttachmentBox> </AttachmentBox>
</Attachment> </Attachment>
@ -383,11 +418,11 @@ export function MFile({ content, renderFileContent, outlined }: MFileProps) {
} }
type MLocationProps = { type MLocationProps = {
content: IContent; content: object;
}; };
export function MLocation({ content }: MLocationProps) { export function MLocation({ content }: MLocationProps) {
const geoUri = content.geo_uri; const geoUri = rContent.geoUri(content);
if (typeof geoUri !== 'string') return <BrokenContent />; if (!geoUri) return <BrokenContent />;
const location = parseGeoUri(geoUri); const location = parseGeoUri(geoUri);
if (!location) return <BrokenContent />; if (!location) return <BrokenContent />;
@ -411,16 +446,18 @@ export function MLocation({ content }: MLocationProps) {
} }
type MStickerProps = { type MStickerProps = {
content: IImageContent; content: object;
renderImageContent: (props: RenderImageContentProps) => ReactNode; mediaAutoLoad?: boolean;
outlineAttachment?: boolean;
}; };
export function MSticker({ content, renderImageContent }: MStickerProps) { export function MSticker({ content, mediaAutoLoad, outlineAttachment }: MStickerProps) {
const imgInfo = content?.info; const imgInfo = rContent.info(content);
const mxcUrl = content.file?.url ?? content.url; const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
if (typeof mxcUrl !== 'string') { if (typeof mxcUrl !== 'string') {
return <MessageBrokenContent />; return <MessageBrokenContent />;
} }
const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152); const height = scaleYDimension(rInfo.w(imgInfo) || 152, 152, rInfo.h(imgInfo) || 152);
return ( return (
<AttachmentBox <AttachmentBox
@ -429,13 +466,7 @@ export function MSticker({ content, renderImageContent }: MStickerProps) {
width: toRem(152), width: toRem(152),
}} }}
> >
{renderImageContent({ <MImage content={content} mediaAutoLoad={mediaAutoLoad} outlined={outlineAttachment} />
body: content.body || 'Sticker',
info: imgInfo,
mimeType: imgInfo?.mimetype,
url: mxcUrl,
encInfo: content.file,
})}
</AttachmentBox> </AttachmentBox>
); );
} }

View file

@ -0,0 +1,130 @@
import React, { CSSProperties } from 'react';
import { Text } from 'folds';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { trimReplyFromBody } from '../../utils/room';
import { MessageTextBody } from './layout';
import { MessageEditedContent } from './content';
import { RenderBody } from './RenderBody';
import { rContent } from '../../../client/reader';
import { parseGeoUri } from '../../utils/common';
import { BrokenContent } from './MsgTypeRenderers';
type ReplyMTextProps = {
edited?: boolean;
content: object;
style?: CSSProperties;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function ReplyMText({
edited,
content,
style,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
}: ReplyMTextProps) {
const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
const trimmedBody = trimReplyFromBody(body ?? '');
if (typeof body !== 'string') return <BrokenContent />;
return (
<MessageTextBody size="T300" style={style} truncate>
<RenderBody
body={trimmedBody}
customBody={customBody}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{edited && <MessageEditedContent />}
</MessageTextBody>
);
}
type ReplyMEmoteProps = {
displayName: string;
edited?: boolean;
content: object;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function ReplyMEmote({
displayName,
edited,
content,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
}: ReplyMEmoteProps) {
const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
return (
<MessageTextBody size="T300" emote preWrap={typeof customBody !== 'string'} truncate>
<b>{`${displayName} `}</b>
<RenderBody
body={trimmedBody}
customBody={customBody}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{edited && <MessageEditedContent />}
</MessageTextBody>
);
}
type ReplyMNoticeProps = {
edited?: boolean;
content: object;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function ReplyMNotice({
edited,
content,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
}: ReplyMNoticeProps) {
const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
return (
<MessageTextBody size="T300" notice preWrap={typeof customBody !== 'string'} truncate>
<RenderBody
body={trimmedBody}
customBody={customBody}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{edited && <MessageEditedContent />}
</MessageTextBody>
);
}
type ReplyMLocationProps = {
content: object;
};
export function ReplyMLocation({ content }: ReplyMLocationProps) {
const geoUri = rContent.geoUri(content);
if (!geoUri) return <BrokenContent />;
const location = parseGeoUri(geoUri);
if (!location) return <BrokenContent />;
return <Text size="T300">{geoUri}</Text>;
}

View file

@ -1,4 +1,4 @@
import { createVar, style } from '@vanilla-extract/css'; import { createVar, keyframes, style } from '@vanilla-extract/css';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds'; import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
const Container = createVar(); const Container = createVar();
@ -48,6 +48,7 @@ export const Reaction = style([
}, },
'&[aria-disabled=true], &:disabled': { '&[aria-disabled=true], &:disabled': {
cursor: 'not-allowed', cursor: 'not-allowed',
backgroundColor: `${Container} !important`,
}, },
}, },
}, },
@ -64,6 +65,36 @@ export const ReactionText = style([
}, },
]); ]);
const scaleAnimation = keyframes({
'0%': {
transform: 'scale(1)',
},
'50%': {
transform: 'scale(1.2)',
},
'100%': {
transform: 'scale(1)',
},
});
export const ReactionChangeAnimation = style({
animation: `${scaleAnimation} 700ms ease-in-out infinite`,
});
const dropAnimation = keyframes({
'0%': {
transform: 'translateY(0)',
},
'50%': {
transform: 'translateY(0.1em)',
},
'100%': {
transform: 'translateY(0)',
},
});
export const ReactionDropAnimation = style({
animation: `${dropAnimation} 700ms ease-in-out infinite`,
});
export const ReactionImg = style([ export const ReactionImg = style([
DefaultReset, DefaultReset,
{ {

View file

@ -1,68 +1,69 @@
import React from 'react'; import React from 'react';
import { Box, Text, as } from 'folds'; import { Box, Text, as } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import * as css from './Reaction.css'; import * as css from './Reaction.css';
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji'; import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
import { getMemberDisplayName } from '../../utils/room'; import { useRoomMembersAPI } from '../../hooks/useRoomMembers';
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { mxcToHttp } from '../../../client/mxcToHttp';
import { useClient } from '../../hooks/useClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
export const Reaction = as< export const Reaction = as<
'button', 'button',
{ {
mx: MatrixClient;
count: number; count: number;
adding?: boolean;
removing?: boolean;
reaction: string; reaction: string;
useAuthentication?: boolean;
} }
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => ( >(({ className, count, adding, removing, reaction, ...props }, ref) => {
<Box const ct = useClient();
as="button" const useAuthentication = useMediaAuthentication();
className={classNames(css.Reaction, className)}
alignItems="Center" return (
shrink="No" <Box
gap="200" as="button"
{...props} className={classNames(css.Reaction, className)}
ref={ref} alignItems="Center"
> shrink="No"
<Text className={css.ReactionText} as="span" size="T400"> gap="200"
{reaction.startsWith('mxc://') ? ( {...props}
<img ref={ref}
className={css.ReactionImg} >
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction <Text
} className={classNames(css.ReactionText, { [css.ReactionChangeAnimation]: adding })}
alt={reaction} as="span"
/> size="T400"
) : ( >
<Text as="span" size="Inherit" truncate> {reaction.startsWith('mxc://') ? (
{reaction} <img
</Text> className={css.ReactionImg}
)} src={mxcToHttp(ct.session.baseUrl, reaction, useAuthentication).url() ?? reaction}
</Text> alt={reaction}
<Text as="span" size="T300"> />
{count} ) : (
</Text> <Text as="span" size="Inherit" truncate>
</Box> {reaction}
)); </Text>
)}
</Text>
<Text className={classNames({ [css.ReactionDropAnimation]: removing })} as="span" size="T300">
{count}
</Text>
</Box>
);
});
type ReactionTooltipMsgProps = { type ReactionTooltipMsgProps = {
room: Room;
reaction: string; reaction: string;
events: MatrixEvent[]; shortcode?: string;
users: string[];
}; };
export function ReactionTooltipMsg({ room, reaction, events }: ReactionTooltipMsgProps) { export function ReactionTooltipMsg({ reaction, shortcode, users }: ReactionTooltipMsgProps) {
const shortCodeEvt = events.find(eventWithShortcode); const memberAPI = useRoomMembersAPI();
const shortcode = const label = shortcode ?? getShortcodeFor(getHexcodeForEmoji(reaction)) ?? reaction;
shortCodeEvt?.getContent().shortcode ?? const names = users.map((userId) => memberAPI.getName(userId));
getShortcodeFor(getHexcodeForEmoji(reaction)) ??
reaction;
const names = events.map(
(ev: MatrixEvent) =>
getMemberDisplayName(room, ev.getSender() ?? 'Unknown') ??
getMxIdLocalPart(ev.getSender() ?? 'Unknown') ??
'Unknown'
);
return ( return (
<> <>
@ -107,9 +108,9 @@ export function ReactionTooltipMsg({ room, reaction, events }: ReactionTooltipMs
</> </>
)} )}
<Text as="span" size="Inherit" priority="300"> <Text as="span" size="Inherit" priority="300">
{' reacted with '} {names.length > 0 ? ' reacted with ' : 'Reacting with '}
</Text> </Text>
:<b>{shortcode}</b>: :<b>{label}</b>:
</> </>
); );
} }

View file

@ -6,10 +6,11 @@ import { MessageEmptyContent } from './content';
import { sanitizeCustomHtml } from '../../utils/sanitize'; import { sanitizeCustomHtml } from '../../utils/sanitize';
import { highlightText, scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; import { highlightText, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
const NEXT_LINE_ONLY_REGEX = /^\n+$/;
type RenderBodyProps = { type RenderBodyProps = {
body: string; body: string;
customBody?: string; customBody?: string;
highlightRegex?: RegExp; highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions; htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts; linkifyOpts: Opts;
@ -21,9 +22,9 @@ export function RenderBody({
htmlReactParserOptions, htmlReactParserOptions,
linkifyOpts, linkifyOpts,
}: RenderBodyProps) { }: RenderBodyProps) {
if (body === '') <MessageEmptyContent />; if (body === '' || NEXT_LINE_ONLY_REGEX.test(body)) return <MessageEmptyContent />;
if (customBody) { if (typeof customBody === 'string') {
if (customBody === '') <MessageEmptyContent />; if (customBody === '') return <MessageEmptyContent />;
return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions); return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
} }
return ( return (

View file

@ -1,41 +0,0 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
export const ReplyBend = style({
flexShrink: 0,
});
export const ThreadIndicator = style({
opacity: config.opacity.P300,
selectors: {
'button&': {
cursor: 'pointer',
},
':hover&': {
opacity: config.opacity.P500,
},
},
});
export const Reply = style({
marginBottom: toRem(1),
minWidth: 0,
maxWidth: '100%',
minHeight: config.lineHeight.T300,
selectors: {
'button&': {
cursor: 'pointer',
},
},
});
export const ReplyContent = style({
opacity: config.opacity.P300,
selectors: {
[`${Reply}:hover &`]: {
opacity: config.opacity.P500,
},
},
});

View file

@ -1,138 +1,196 @@
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds'; import { Box, Icon, Icons, Text, color, config, toRem } from 'folds';
import { EventTimelineSet, Room } from 'matrix-js-sdk';
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react'; import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
import classNames from 'classnames'; import { ClientEventWithoutRoomId } from 'neurone/lib/api/types';
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room'; import { HTMLReactParserOptions } from 'html-react-parser';
import { getMxIdLocalPart } from '../../utils/matrix'; import { Opts } from 'linkifyjs';
import { LinePlaceholder } from './placeholder'; import { LinePlaceholder } from './placeholder';
import { randomNumberBetween } from '../../utils/common'; import { randomNumberBetween } from '../../utils/common';
import * as css from './Reply.css'; import { MessageFailedContent } from './content';
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { useRoomEvent } from '../../hooks/useRoomEvent'; import { useRoomEvent } from '../../hooks/useRoomEvent';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { GetRoomMemberNameCallback } from '../../hooks/useRoomMembers';
import { rContent, rUnsigned } from '../../../client/reader';
import { TimelineView } from '../../../client/room';
import { RenderReplyContent } from '../RenderReplyContent';
import { RedactedContent } from './MsgTypeRenderers';
import { HoverUnderline } from '../../styles/Text.css';
import { useObservableValueOptionally } from '../../hooks/useObservableValue';
import { getLatestEditEvent } from '../../utils/room';
import { MessageContent } from '../MessageContent';
import { useClient } from '../../hooks/useClient';
type ReplyLayoutProps = { type ReplyMessageProps = {
userColor?: string;
username?: ReactNode; username?: ReactNode;
usernameColor?: string;
children: ReactNode;
onClick?: () => void;
}; };
export const ReplyLayout = as<'div', ReplyLayoutProps>( export function ReplyMessage({ username, usernameColor, onClick, children }: ReplyMessageProps) {
({ username, userColor, className, children, ...props }, ref) => ( const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
<Box if (!onClick) return;
className={classNames(css.Reply, className)}
alignItems="Center" if (event.key === 'Enter' || event.key === ' ') {
gap="100" event.preventDefault();
{...props} onClick();
ref={ref} }
> };
<Box style={{ color: userColor, maxWidth: toRem(200) }} alignItems="Center" shrink="No">
<Icon size="100" src={Icons.ReplyArrow} /> const handleClick: MouseEventHandler = (evt) => {
const target = evt.target as HTMLElement;
if (target.closest('a')) {
evt.stopPropagation();
return;
}
onClick?.();
};
const commonProps = onClick
? {
role: 'button' as const,
tabIndex: 0,
onClick: handleClick,
onKeyDown: handleKeyDown,
className: HoverUnderline,
style: { cursor: 'pointer' },
}
: {};
return (
<Box alignItems="Center" gap="100">
<Box shrink="No" gap="0" alignItems="Center" style={{ maxWidth: toRem(120) }}>
<Icon style={{ color: usernameColor }} size="100" src={Icons.ReplyArrow} filled />
{username} {username}
</Box> </Box>
<Box grow="Yes" className={css.ReplyContent}> <Box {...commonProps}>{children}</Box>
{children}
</Box>
</Box> </Box>
) );
); }
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
<Box
shrink="No"
className={css.ThreadIndicator}
alignItems="Center"
gap="100"
{...props}
ref={ref}
>
<Icon size="50" src={Icons.Thread} />
<Text size="L400">Thread</Text>
</Box>
));
type ReplyProps = { type ReplyProps = {
room: Room; roomId: string;
timelineSet?: EventTimelineSet | undefined; eventId: string;
replyEventId: string; onClick?: (replyId: string) => void;
threadRootId?: string | undefined; onUserClick?: MouseEventHandler<HTMLButtonElement>;
onClick?: MouseEventHandler | undefined;
getMemberPowerTag?: GetMemberPowerTag; getMemberPowerTag?: GetMemberPowerTag;
accessibleTagColors?: Map<string, string>; accessibleTagColors?: Map<string, string>;
legacyUsernameColor?: boolean; legacyUsernameColor?: boolean;
getMemberName: GetRoomMemberNameCallback;
timeline?: TimelineView;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
}; };
export const Reply = as<'div', ReplyProps>( export function Reply({
( roomId,
{ eventId,
room, onClick,
timelineSet, onUserClick,
replyEventId, getMemberPowerTag,
threadRootId, accessibleTagColors,
onClick, legacyUsernameColor,
getMemberPowerTag, getMemberName,
accessibleTagColors, timeline,
legacyUsernameColor, htmlReactParserOptions,
...props linkifyOpts,
}, }: ReplyProps) {
ref const ct = useClient();
) => { const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
const getFromLocalTimeline = useCallback(
() => timelineSet?.findEventById(replyEventId),
[timelineSet, replyEventId]
);
const replyEvent = useRoomEvent(room, replyEventId, getFromLocalTimeline);
const { body } = replyEvent?.getContent() ?? {}; const redactedEvents = useObservableValueOptionally(timeline?.relations.redacted);
const sender = replyEvent?.getSender(); const edits = useObservableValueOptionally(timeline?.relations.edits.eventToRelations);
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
const usernameColor = legacyUsernameColor ? colorMXID(sender ?? replyEventId) : tagColor; const replyEvent = useRoomEvent(
roomId,
eventId,
useCallback(() => timeline?.findEvent(eventId), [timeline, eventId])
);
const fallbackBody = replyEvent?.isRedacted() ? ( const sender = replyEvent?.sender;
<MessageDeletedContent /> const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
) : ( const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
<MessageFailedContent />
);
const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted'; const usernameColor = legacyUsernameColor ? colorMXID(sender ?? eventId) : tagColor;
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
const redacted =
redactedEvents?.has(eventId) || !!rUnsigned.redactedBecause(replyEvent?.unsigned);
const renderEventContent = (event: ClientEventWithoutRoomId) => {
const editEvents = edits?.get(eventId);
const editedEvent = getLatestEditEvent(event, editEvents, redactedEvents);
return ( return (
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}> <Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }} as="div">
{threadRootId && ( {redacted ? (
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} /> <RedactedContent size="T300" />
) : (
<MessageContent
event={event}
editedEvent={editedEvent}
roomId={roomId}
decryptionManager={ct.decryption}
reply
>
{(contentEvent) => (
<RenderReplyContent
displayName={getMemberName(event.sender)}
eventType={contentEvent.type}
edited={!!editedEvent}
content={rContent.newContent(contentEvent.content) ?? contentEvent.content}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
</MessageContent>
)} )}
<ReplyLayout
as="button"
userColor={usernameColor}
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> </Box>
); );
} };
);
const replyClick = onClick ? () => onClick(eventId) : undefined;
return (
<ReplyMessage
onClick={replyClick}
usernameColor={usernameColor}
username={
sender && (
<Text
as="button"
onClick={replyClick}
onContextMenu={onUserClick}
className={HoverUnderline}
style={{ color: usernameColor, cursor: 'pointer' }}
data-user-id={sender}
size="T300"
truncate
>
<b>{getMemberName(sender)}</b>
</Text>
)
}
data-event-id={eventId}
>
{replyEvent && renderEventContent(replyEvent)}
{replyEvent === null && (
<Text size="T300">
<MessageFailedContent />
</Text>
)}
{replyEvent === undefined && (
<LinePlaceholder
style={{
backgroundColor: color.SurfaceVariant.ContainerActive,
width: toRem(placeholderWidth),
maxWidth: '100%',
margin: `${toRem(2)} 0`,
height: `calc(${config.lineHeight.T300} - ${toRem(4)})`,
}}
/>
)}
</ReplyMessage>
);
}

View file

@ -1,6 +1,12 @@
import React, { ComponentProps } from 'react'; import React, { ComponentProps } from 'react';
import { Text, as } from 'folds'; import { Text, as } from 'folds';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time'; import {
timeDayMonthYear,
timeDayMonYear,
timeHourMinute,
today,
yesterday,
} from '../../utils/time';
export type TimeProps = { export type TimeProps = {
compact?: boolean; compact?: boolean;
@ -43,3 +49,9 @@ export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
); );
} }
); );
export function DividerTime({ ts }: { ts: number }) {
if (today(ts)) return 'Today';
if (yesterday(ts)) return 'Yesterday';
return timeDayMonthYear(ts);
}

View file

@ -1,11 +1,9 @@
/* eslint-disable jsx-a11y/media-has-caption */ /* eslint-disable jsx-a11y/media-has-caption */
import React, { ReactNode, useCallback, useRef, useState } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds'; import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { Range } from 'react-range'; import { Range } from 'react-range';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IAudioInfo } from '../../../../types/matrix/common';
import { import {
PlayTimeCallback, PlayTimeCallback,
useMediaLoading, useMediaLoading,
@ -16,58 +14,45 @@ import {
} from '../../../hooks/media'; } from '../../../hooks/media';
import { useThrottle } from '../../../hooks/useThrottle'; import { useThrottle } from '../../../hooks/useThrottle';
import { secondsToMinutesAndSeconds } from '../../../utils/common'; import { secondsToMinutesAndSeconds } from '../../../utils/common';
import { import { decryptFile, downloadEncryptedMedia, downloadMedia } from '../../../utils/matrix';
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useClient } from '../../../hooks/useClient';
import { mxcToHttp } from '../../../../client/mxcToHttp';
import { rInfo } from '../../../../client/reader';
import { MediaControl } from '../../media';
const PLAY_TIME_THROTTLE_OPS = { const PLAY_TIME_THROTTLE_OPS = {
wait: 500, wait: 500,
immediate: true, immediate: true,
}; };
type RenderMediaControlProps = {
after: ReactNode;
leftControl: ReactNode;
rightControl: ReactNode;
children: ReactNode;
};
export type AudioContentProps = { export type AudioContentProps = {
mimeType: string; mimeType: string;
url: string; url: string;
info: IAudioInfo; info: object;
encInfo?: EncryptedAttachmentInfo; encInfo?: object;
renderMediaControl: (props: RenderMediaControlProps) => ReactNode;
}; };
export function AudioContent({ export function AudioContent({ mimeType, url, info, encInfo }: AudioContentProps) {
mimeType, const ct = useClient();
url,
info,
encInfo,
renderMediaControl,
}: AudioContentProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url();
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
)
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);
return URL.createObjectURL(fileContent); return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo]) }, [ct, url, useAuthentication, mimeType, encInfo])
); );
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
// duration in seconds. (NOTE: info.duration is in milliseconds) // duration in seconds. (NOTE: info.duration is in milliseconds)
const infoDuration = info.duration ?? 0; const infoDuration = rInfo.duration(info) ?? 0;
const [duration, setDuration] = useState((infoDuration >= 0 ? infoDuration : 0) / 1000); const [duration, setDuration] = useState((infoDuration >= 0 ? infoDuration : 0) / 1000);
const getAudioRef = useCallback(() => audioRef.current, []); const getAudioRef = useCallback(() => audioRef.current, []);
@ -75,9 +60,9 @@ export function AudioContent({
const { playing, setPlaying } = useMediaPlay(getAudioRef); const { playing, setPlaying } = useMediaPlay(getAudioRef);
const { seek } = useMediaSeek(getAudioRef); const { seek } = useMediaSeek(getAudioRef);
const { volume, mute, setMute, setVolume } = useMediaVolume(getAudioRef); const { volume, mute, setMute, setVolume } = useMediaVolume(getAudioRef);
const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => { const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, cTime) => {
setDuration(d); setDuration(d);
setCurrentTime(ct); setCurrentTime(cTime);
}, []); }, []);
useMediaPlayTimeCallback( useMediaPlayTimeCallback(
getAudioRef, getAudioRef,
@ -91,95 +76,25 @@ export function AudioContent({
loadSrc(); loadSrc();
} }
}; };
return (
return renderMediaControl({ <MediaControl
after: ( after={
<Range
step={1}
min={0}
max={duration || 1}
values={[currentTime]}
onChange={(values) => seek(values[0])}
renderTrack={(params) => (
<div {...params.props}>
{params.children}
<ProgressBar
as="div"
variant="Secondary"
size="300"
min={0}
max={duration}
value={currentTime}
radii="300"
/>
</div>
)}
renderThumb={(params) => (
<Badge
size="300"
variant="Secondary"
fill="Solid"
radii="Pill"
outlined
{...params.props}
style={{
...params.props.style,
zIndex: 0,
}}
/>
)}
/>
),
leftControl: (
<>
<Chip
onClick={handlePlay}
variant="Secondary"
radii="300"
disabled={srcState.status === AsyncStatus.Loading}
before={
srcState.status === AsyncStatus.Loading || loading ? (
<Spinner variant="Secondary" size="50" />
) : (
<Icon src={playing ? Icons.Pause : Icons.Play} size="50" filled={playing} />
)
}
>
<Text size="B300">{playing ? 'Pause' : 'Play'}</Text>
</Chip>
<Text size="T200">{`${secondsToMinutesAndSeconds(
currentTime
)} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
</>
),
rightControl: (
<>
<IconButton
variant="SurfaceVariant"
size="300"
radii="Pill"
onClick={() => setMute(!mute)}
aria-pressed={mute}
>
<Icon src={mute ? Icons.VolumeMute : Icons.VolumeHigh} size="50" />
</IconButton>
<Range <Range
step={0.1} step={1}
min={0} min={0}
max={1} max={duration || 1}
values={[volume]} values={[currentTime]}
onChange={(values) => setVolume(values[0])} onChange={(values) => seek(values[0])}
renderTrack={(params) => ( renderTrack={(params) => (
<div {...params.props}> <div {...params.props}>
{params.children} {params.children}
<ProgressBar <ProgressBar
style={{ width: toRem(48) }} as="div"
variant="Secondary" variant="Secondary"
size="300" size="300"
min={0} min={0}
max={1} max={duration}
value={volume} value={currentTime}
radii="300" radii="300"
/> />
</div> </div>
@ -199,12 +114,82 @@ export function AudioContent({
/> />
)} )}
/> />
</> }
), leftControl={
children: ( <>
<Chip
onClick={handlePlay}
variant="Secondary"
radii="300"
disabled={srcState.status === AsyncStatus.Loading}
before={
srcState.status === AsyncStatus.Loading || loading ? (
<Spinner variant="Secondary" size="50" />
) : (
<Icon src={playing ? Icons.Pause : Icons.Play} size="50" filled={playing} />
)
}
>
<Text size="B300">{playing ? 'Pause' : 'Play'}</Text>
</Chip>
<Text size="T200">{`${secondsToMinutesAndSeconds(
currentTime
)} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
</>
}
rightControl={
<>
<IconButton
variant="SurfaceVariant"
size="300"
radii="Pill"
onClick={() => setMute(!mute)}
aria-pressed={mute}
>
<Icon src={mute ? Icons.VolumeMute : Icons.VolumeHigh} size="50" />
</IconButton>
<Range
step={0.1}
min={0}
max={1}
values={[volume]}
onChange={(values) => setVolume(values[0])}
renderTrack={(params) => (
<div {...params.props}>
{params.children}
<ProgressBar
style={{ width: toRem(48) }}
variant="Secondary"
size="300"
min={0}
max={1}
value={volume}
radii="300"
/>
</div>
)}
renderThumb={(params) => (
<Badge
size="300"
variant="Secondary"
fill="Solid"
radii="Pill"
outlined
{...params.props}
style={{
...params.props.style,
zIndex: 0,
}}
/>
)}
/>
</>
}
>
<audio controls={false} autoPlay ref={audioRef}> <audio controls={false} autoPlay ref={audioRef}>
{srcState.status === AsyncStatus.Success && <source src={srcState.data} type={mimeType} />} {srcState.status === AsyncStatus.Success && <source src={srcState.data} type={mimeType} />}
</audio> </audio>
), </MediaControl>
}); );
} }

View file

@ -18,7 +18,7 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
alignItems="Center" alignItems="Center"
justifyContent="Center" justifyContent="Center"
> >
<Icon style={{ opacity: 0.6 }} size="50" src={iconSrc} /> <Icon size="50" src={iconSrc} />
</Box> </Box>
</Box> </Box>
); );

View file

@ -1,5 +1,6 @@
import { Box, Icon, Icons, Text, as, color, config } from 'folds'; import { Box, Icon, Icons, Text, as, color, config } from 'folds';
import React from 'react'; import React from 'react';
import { Code } from '../../../styles/CustomHtml.css';
const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 }; const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 };
const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 }; const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 };
@ -24,6 +25,15 @@ export const MessageUnsupportedContent = as<'div', { children?: never }>(({ ...p
</Box> </Box>
)); ));
export const UnsupportedMessageEventType = as<'div', { eventType: string; children?: never }>(
({ eventType, ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.Code} />
Sent <code className={Code}>{eventType}</code> event
</Box>
)
);
export const MessageFailedContent = as<'div', { children?: never }>(({ ...props }, ref) => ( export const MessageFailedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}> <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Warning} /> <Icon size="50" src={Icons.Warning} />
@ -64,3 +74,38 @@ export const MessageEditedContent = as<'span', { children?: never }>(({ ...props
{' (edited)'} {' (edited)'}
</Text> </Text>
)); ));
export const MessageStickerContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.Sticker} filled />
<i>Sticker</i>
</Box>
));
export const MessageImageContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.Photo} filled />
<i>Image</i>
</Box>
));
export const MessageVideoContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.Vlc} filled />
<i>Video</i>
</Box>
));
export const MessageAudioContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.Play} filled />
<i>Audio</i>
</Box>
));
export const MessageFileContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.File} filled />
<i>File</i>
</Box>
));

View file

@ -1,4 +1,4 @@
import React, { ReactNode, useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { import {
Box, Box,
Button, Button,
@ -12,14 +12,12 @@ import {
Text, Text,
Tooltip, Tooltip,
TooltipProvider, TooltipProvider,
as,
} from 'folds'; } from 'folds';
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { IFileInfo } from '../../../../types/matrix/common'; import { IFileInfo } from '../../../../types/matrix/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { bytesToSize } from '../../../utils/common'; import { bytesToSize } from '../../../utils/common';
import { import {
READABLE_EXT_TO_MIME_TYPE, READABLE_EXT_TO_MIME_TYPE,
@ -28,14 +26,13 @@ import {
mimeTypeToExt, mimeTypeToExt,
} from '../../../utils/mimeTypes'; } from '../../../utils/mimeTypes';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { import { decryptFile, downloadEncryptedMedia, downloadMedia } from '../../../utils/matrix';
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css'; import { ModalWide } from '../../../styles/Modal.css';
import { useClient } from '../../../hooks/useClient';
import { mxcToHttp } from '../../../../client/mxcToHttp';
import { TextViewer } from '../../text-viewer';
import { PdfViewer } from '../../Pdf-viewer';
const renderErrorButton = (retry: () => void, text: string) => ( const renderErrorButton = (retry: () => void, text: string) => (
<TooltipProvider <TooltipProvider
@ -66,36 +63,30 @@ const renderErrorButton = (retry: () => void, text: string) => (
</TooltipProvider> </TooltipProvider>
); );
type RenderTextViewerProps = {
name: string;
text: string;
langName: string;
requestClose: () => void;
};
type ReadTextFileProps = { type ReadTextFileProps = {
body: string; body: string;
mimeType: string; mimeType: string;
url: string; url: string;
encInfo?: EncryptedAttachmentInfo; encInfo?: object;
renderViewer: (props: RenderTextViewerProps) => ReactNode;
}; };
export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: ReadTextFileProps) { export function ReadTextFile({ body, mimeType, url, encInfo }: ReadTextFileProps) {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [textViewer, setTextViewer] = useState(false); const [textViewer, setTextViewer] = useState(false);
const [textState, loadText] = useAsyncCallback( const [textState, loadText] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url();
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
)
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);
const text = fileContent.text(); const text = fileContent.text();
setTextViewer(true); setTextViewer(true);
return text; return text;
}, [mx, useAuthentication, mimeType, encInfo, url]) }, [ct, useAuthentication, mimeType, encInfo, url])
); );
return ( return (
@ -116,14 +107,16 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
size="500" size="500"
onContextMenu={(evt: any) => evt.stopPropagation()} onContextMenu={(evt: any) => evt.stopPropagation()}
> >
{renderViewer({ <TextViewer
name: body, name={body}
text: textState.data, text={textState.data}
langName: READABLE_TEXT_MIME_TYPES.includes(mimeType) langName={
? mimeTypeToExt(mimeType) READABLE_TEXT_MIME_TYPES.includes(mimeType)
: mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType), ? mimeTypeToExt(mimeType)
requestClose: () => setTextViewer(false), : mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType)
})} }
requestClose={() => setTextViewer(false)}
/>
</Modal> </Modal>
</FocusTrap> </FocusTrap>
</OverlayCenter> </OverlayCenter>
@ -158,33 +151,28 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
); );
} }
type RenderPdfViewerProps = {
name: string;
src: string;
requestClose: () => void;
};
export type ReadPdfFileProps = { export type ReadPdfFileProps = {
body: string; body: string;
mimeType: string; mimeType: string;
url: string; url: string;
encInfo?: EncryptedAttachmentInfo; encInfo?: object;
renderViewer: (props: RenderPdfViewerProps) => ReactNode;
}; };
export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: ReadPdfFileProps) { export function ReadPdfFile({ body, mimeType, url, encInfo }: ReadPdfFileProps) {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [pdfViewer, setPdfViewer] = useState(false); const [pdfViewer, setPdfViewer] = useState(false);
const [pdfState, loadPdf] = useAsyncCallback( const [pdfState, loadPdf] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url();
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
)
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);
setPdfViewer(true); setPdfViewer(true);
return URL.createObjectURL(fileContent); return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo]) }, [ct, url, useAuthentication, mimeType, encInfo])
); );
return ( return (
@ -205,11 +193,11 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
size="500" size="500"
onContextMenu={(evt: any) => evt.stopPropagation()} onContextMenu={(evt: any) => evt.stopPropagation()}
> >
{renderViewer({ <PdfViewer
name: body, name={body}
src: pdfState.data, src={pdfState.data}
requestClose: () => setPdfViewer(false), requestClose={() => setPdfViewer(false)}
})} />
</Modal> </Modal>
</FocusTrap> </FocusTrap>
</OverlayCenter> </OverlayCenter>
@ -247,24 +235,25 @@ export type DownloadFileProps = {
mimeType: string; mimeType: string;
url: string; url: string;
info: IFileInfo; info: IFileInfo;
encInfo?: EncryptedAttachmentInfo; encInfo?: object;
}; };
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) { export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [downloadState, download] = useAsyncCallback( const [downloadState, download] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url();
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
)
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent); const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, body); FileSaver.saveAs(fileURL, body);
return fileURL; return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, body]) }, [ct, url, useAuthentication, mimeType, encInfo, body])
); );
return downloadState.status === AsyncStatus.Error ? ( return downloadState.status === AsyncStatus.Error ? (
@ -297,17 +286,21 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
type FileContentProps = { type FileContentProps = {
body: string; body: string;
mimeType: string; mimeType: string;
renderAsTextFile: () => ReactNode; info: object;
renderAsPdfFile: () => ReactNode; url: string;
encInfo?: object;
}; };
export const FileContent = as<'div', FileContentProps>( export function FileContent({ body, mimeType, info, url, encInfo }: FileContentProps) {
({ body, mimeType, renderAsTextFile, renderAsPdfFile, children, ...props }, ref) => ( return (
<Box direction="Column" gap="300" {...props} ref={ref}> <Box direction="Column" gap="300">
{(READABLE_TEXT_MIME_TYPES.includes(mimeType) || {(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) && READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) && (
renderAsTextFile()} <ReadTextFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
{mimeType === 'application/pdf' && renderAsPdfFile()} )}
{children} {mimeType === 'application/pdf' && (
<ReadPdfFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
)}
<DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} />
</Box> </Box>
) );
); }

View file

@ -1,4 +1,4 @@
import React, { ReactNode, useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { import {
Badge, Badge,
Box, Box,
@ -22,41 +22,28 @@ import FocusTrap from 'focus-trap-react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common'; import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import * as css from './style.css'; import * as css from './style.css';
import { bytesToSize } from '../../../utils/common'; import { bytesToSize } from '../../../utils/common';
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes'; 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 } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css'; import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash'; import { validBlurHash } from '../../../utils/blurHash';
import { useClient } from '../../../hooks/useClient';
import { mxcToHttp } from '../../../../client/mxcToHttp';
import { Image } from '../../media';
import { ImageViewer } from '../../image-viewer';
type RenderViewerProps = {
src: string;
alt: string;
requestClose: () => void;
};
type RenderImageProps = {
alt: string;
title: string;
src: string;
onLoad: () => void;
onError: () => void;
onClick: () => void;
tabIndex: number;
};
export type ImageContentProps = { export type ImageContentProps = {
body: string; body: string;
mimeType?: string; mimeType?: string;
url: string; url: string;
info?: IImageInfo; info?: IImageInfo;
encInfo?: EncryptedAttachmentInfo; encInfo?: object;
autoPlay?: boolean; autoPlay?: boolean;
markedAsSpoiler?: boolean; markedAsSpoiler?: boolean;
spoilerReason?: string; spoilerReason?: string;
renderViewer: (props: RenderViewerProps) => ReactNode;
renderImage: (props: RenderImageProps) => ReactNode;
}; };
export const ImageContent = as<'div', ImageContentProps>( export const ImageContent = as<'div', ImageContentProps>(
( (
@ -70,13 +57,11 @@ export const ImageContent = as<'div', ImageContentProps>(
autoPlay, autoPlay,
markedAsSpoiler, markedAsSpoiler,
spoilerReason, spoilerReason,
renderViewer,
renderImage,
...props ...props
}, },
ref ref
) => { ) => {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
@ -87,16 +72,15 @@ export const ImageContent = as<'div', ImageContentProps>(
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url();
if (!mediaUrl) throw new Error('Invalid media URL');
if (encInfo) { if (encInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo) decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo as EncryptedAttachmentInfo)
); );
return URL.createObjectURL(fileContent); return URL.createObjectURL(fileContent);
} }
return mediaUrl; return mediaUrl;
}, [mx, url, useAuthentication, mimeType, encInfo]) }, [ct, url, useAuthentication, mimeType, encInfo])
); );
const handleLoad = () => { const handleLoad = () => {
@ -134,11 +118,11 @@ export const ImageContent = as<'div', ImageContentProps>(
size="500" size="500"
onContextMenu={(evt: any) => evt.stopPropagation()} onContextMenu={(evt: any) => evt.stopPropagation()}
> >
{renderViewer({ <ImageViewer
src: srcState.data, src={srcState.data}
alt: body, alt={body}
requestClose: () => setViewer(false), requestClose={() => setViewer(false)}
})} />
</Modal> </Modal>
</FocusTrap> </FocusTrap>
</OverlayCenter> </OverlayCenter>
@ -169,15 +153,16 @@ export const ImageContent = as<'div', ImageContentProps>(
)} )}
{srcState.status === AsyncStatus.Success && ( {srcState.status === AsyncStatus.Success && (
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}> <Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
{renderImage({ <Image
alt: body, alt={body}
title: body, title={body}
src: srcState.data, src={srcState.data}
onLoad: handleLoad, onLoad={handleLoad}
onError: handleError, onError={handleError}
onClick: () => setViewer(true), onClick={() => setViewer(true)}
tabIndex: 0, tabIndex={0}
})} loading="lazy"
/>
</Box> </Box>
)} )}
{blurred && !error && srcState.status !== AsyncStatus.Error && ( {blurred && !error && srcState.status !== AsyncStatus.Error && (

View file

@ -1,44 +1,49 @@
import { ReactNode, useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { IThumbnailContent } from '../../../../types/matrix/common'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix'; import { decryptFile, downloadEncryptedMedia } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes'; import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
import { rEncryptedFile, rInfo, rThumbnailInfo } from '../../../../client/reader';
import { Image } from '../../media';
import { useClient } from '../../../hooks/useClient';
import { mxcToHttp } from '../../../../client/mxcToHttp';
export type ThumbnailContentProps = { export type ThumbnailContentProps = {
info: IThumbnailContent; info: object;
renderImage: (src: string) => ReactNode;
}; };
export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) { export function ThumbnailContent({ info }: ThumbnailContentProps) {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [thumbSrcState, loadThumbSrc] = useAsyncCallback( const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const thumbInfo = info.thumbnail_info; const thumbInfo = rInfo.thumbnailInfo(info);
const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url; const encInfo = rInfo.thumbnailFile(info);
const encInfo = info.thumbnail_file; const thumbMxcUrl = rEncryptedFile.url(encInfo) ?? rInfo.thumbnailUrl(info);
if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') { const mimeType = rThumbnailInfo.mimetype(thumbInfo);
if (typeof thumbMxcUrl !== 'string' || typeof mimeType !== 'string') {
throw new Error('Failed to load thumbnail'); throw new Error('Failed to load thumbnail');
} }
const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication); const mediaUrl = mxcToHttp(ct.session.baseUrl, thumbMxcUrl, useAuthentication).url();
if (!mediaUrl) throw new Error('Invalid media URL');
if (encInfo) { if (encInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo) decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo as EncryptedAttachmentInfo)
); );
return URL.createObjectURL(fileContent); return URL.createObjectURL(fileContent);
} }
return mediaUrl; return mediaUrl;
}, [mx, info, useAuthentication]) }, [ct, info, useAuthentication])
); );
useEffect(() => { useEffect(() => {
loadThumbSrc(); loadThumbSrc();
}, [loadThumbSrc]); }, [loadThumbSrc]);
return thumbSrcState.status === AsyncStatus.Success ? renderImage(thumbSrcState.data) : null; return thumbSrcState.status === AsyncStatus.Success ? (
<Image alt="Thumbnail" title="Thumbnail" src={thumbSrcState.data} loading="lazy" />
) : null;
} }

View file

@ -1,4 +1,4 @@
import React, { ReactNode, useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { import {
Badge, Badge,
Box, Box,
@ -15,43 +15,28 @@ import {
import classNames from 'classnames'; import classNames from 'classnames';
import { BlurhashCanvas } from 'react-blurhash'; import { BlurhashCanvas } from 'react-blurhash';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import {
IThumbnailContent,
IVideoInfo,
MATRIX_BLUR_HASH_PROPERTY_NAME,
} from '../../../../types/matrix/common';
import * as css from './style.css'; import * as css from './style.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { bytesToSize, millisecondsToMinutesAndSeconds } from '../../../utils/common'; import { bytesToSize, millisecondsToMinutesAndSeconds } from '../../../utils/common';
import { import { decryptFile, downloadEncryptedMedia, downloadMedia } from '../../../utils/matrix';
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash'; import { validBlurHash } from '../../../utils/blurHash';
import { useClient } from '../../../hooks/useClient';
import { mxcToHttp } from '../../../../client/mxcToHttp';
import { Video } from '../../media';
import { rInfo, rThumbnailInfo } from '../../../../client/reader';
import { ThumbnailContent } from './ThumbnailContent';
type RenderVideoProps = {
title: string;
src: string;
onLoadedMetadata: () => void;
onError: () => void;
autoPlay: boolean;
controls: boolean;
};
type VideoContentProps = { type VideoContentProps = {
body: string; body: string;
mimeType: string; mimeType: string;
url: string; url: string;
info: IVideoInfo & IThumbnailContent; info: object;
encInfo?: EncryptedAttachmentInfo; encInfo?: object;
autoPlay?: boolean; autoPlay?: boolean;
mediaAutoLoad?: boolean;
markedAsSpoiler?: boolean; markedAsSpoiler?: boolean;
spoilerReason?: string; spoilerReason?: string;
renderThumbnail?: () => ReactNode;
renderVideo: (props: RenderVideoProps) => ReactNode;
}; };
export const VideoContent = as<'div', VideoContentProps>( export const VideoContent = as<'div', VideoContentProps>(
( (
@ -63,17 +48,20 @@ export const VideoContent = as<'div', VideoContentProps>(
info, info,
encInfo, encInfo,
autoPlay, autoPlay,
mediaAutoLoad,
markedAsSpoiler, markedAsSpoiler,
spoilerReason, spoilerReason,
renderThumbnail,
renderVideo,
...props ...props
}, },
ref ref
) => { ) => {
const mx = useMatrixClient(); const ct = useClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); const thumbnailInfo = rInfo.thumbnailInfo(info);
const blurHash = validBlurHash(rThumbnailInfo.blurhash(thumbnailInfo));
const size = rInfo.size(info);
const duration = rInfo.duration(info);
const [load, setLoad] = useState(false); const [load, setLoad] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
@ -81,15 +69,14 @@ export const VideoContent = as<'div', VideoContentProps>(
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url();
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => ? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo) decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
) )
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);
return URL.createObjectURL(fileContent); return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo]) }, [ct, url, useAuthentication, mimeType, encInfo])
); );
const handleLoad = () => { const handleLoad = () => {
@ -120,13 +107,13 @@ export const VideoContent = as<'div', VideoContentProps>(
punch={1} punch={1}
/> />
)} )}
{renderThumbnail && !load && ( {mediaAutoLoad && !load && (
<Box <Box
className={classNames(css.AbsoluteContainer, blurred && css.Blur)} className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
alignItems="Center" alignItems="Center"
justifyContent="Center" justifyContent="Center"
> >
{renderThumbnail()} <ThumbnailContent info={info} />
</Box> </Box>
)} )}
{!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && ( {!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
@ -145,14 +132,14 @@ export const VideoContent = as<'div', VideoContentProps>(
)} )}
{srcState.status === AsyncStatus.Success && ( {srcState.status === AsyncStatus.Success && (
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}> <Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
{renderVideo({ <Video
title: body, title={body}
src: srcState.data, src={srcState.data}
onLoadedMetadata: handleLoad, onLoadedMetadata={handleLoad}
onError: handleError, onError={handleError}
autoPlay: true, autoPlay
controls: true, controls
})} />
</Box> </Box>
)} )}
{blurred && !error && srcState.status !== AsyncStatus.Error && ( {blurred && !error && srcState.status !== AsyncStatus.Error && (
@ -220,7 +207,7 @@ export const VideoContent = as<'div', VideoContentProps>(
</TooltipProvider> </TooltipProvider>
</Box> </Box>
)} )}
{!load && typeof info.size === 'number' && ( {!load && typeof size === 'number' && (
<Box <Box
className={css.AbsoluteFooter} className={css.AbsoluteFooter}
justifyContent="SpaceBetween" justifyContent="SpaceBetween"
@ -228,10 +215,10 @@ export const VideoContent = as<'div', VideoContentProps>(
gap="200" gap="200"
> >
<Badge variant="Secondary" fill="Soft"> <Badge variant="Secondary" fill="Soft">
<Text size="L400">{millisecondsToMinutesAndSeconds(info.duration ?? 0)}</Text> <Text size="L400">{millisecondsToMinutesAndSeconds(duration ?? 0)}</Text>
</Badge> </Badge>
<Badge variant="Secondary" fill="Soft"> <Badge variant="Secondary" fill="Soft">
<Text size="L400">{bytesToSize(info.size)}</Text> <Text size="L400">{bytesToSize(size)}</Text>
</Badge> </Badge>
</Box> </Box>
)} )}

View file

@ -6,5 +6,6 @@ export * from './Reply';
export * from './content'; export * from './content';
export * from './Time'; export * from './Time';
export * from './MsgTypeRenderers'; export * from './MsgTypeRenderers';
export * from './MsgTypeReplyRenderers';
export * from './FileHeader'; export * from './FileHeader';
export * from './RenderBody'; export * from './RenderBody';

View file

@ -28,13 +28,20 @@ export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...p
<AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} /> <AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} />
)); ));
export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?: boolean }>( export const MessageTextBody = as<
({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => ( 'div',
css.MessageTextBodyVariants & { size?: 'T400' | 'T300'; notice?: boolean; truncate?: boolean }
>(
(
{ as: asComp = 'div', className, size, preWrap, jumboEmoji, emote, notice, truncate, ...props },
ref
) => (
<Text <Text
as={asComp} as={asComp}
size="T400" size={size ?? 'T400'}
priority={notice ? '300' : '400'} priority={notice ? '300' : '400'}
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)} className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
truncate={truncate}
{...props} {...props}
ref={ref} ref={ref}
/> />

View file

@ -112,8 +112,7 @@ export const CompactHeader = style([
DefaultReset, DefaultReset,
StickySection, StickySection,
{ {
maxWidth: toRem(170), width: toRem(170),
width: '100%',
}, },
]); ]);
@ -182,6 +181,7 @@ export const UsernameBold = style({
export const MessageTextBody = recipe({ export const MessageTextBody = recipe({
base: { base: {
wordBreak: 'break-word', wordBreak: 'break-word',
position: 'relative',
}, },
variants: { variants: {
preWrap: { preWrap: {

View file

@ -11,21 +11,21 @@ import {
toRem, toRem,
} from 'folds'; } from 'folds';
import React, { ReactNode, useId } from 'react'; import React, { ReactNode, useId } from 'react';
import { Presence } from 'neurone/lib/api/types';
import * as css from './styles.css'; import * as css from './styles.css';
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence'; import { usePresenceLabel } from '../../hooks/useUserPresence';
const PresenceToColor: Record<Presence, MainColor> = { const PresenceToColor: Record<Presence, MainColor> = {
[Presence.Online]: 'Success', online: 'Success',
[Presence.Unavailable]: 'Warning', unavailable: 'Critical',
[Presence.Offline]: 'Secondary', offline: 'Secondary',
}; };
type PresenceBadgeProps = { type PresenceBadgeProps = {
presence: Presence; presence: Presence;
status?: string;
size?: '200' | '300' | '400' | '500'; size?: '200' | '300' | '400' | '500';
}; };
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) { export function PresenceBadge({ presence, size }: PresenceBadgeProps) {
const label = usePresenceLabel(); const label = usePresenceLabel();
const badgeLabelId = useId(); const badgeLabelId = useId();
@ -39,8 +39,6 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
<Tooltip id={badgeLabelId}> <Tooltip id={badgeLabelId}>
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100"> <Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
<Text size="L400">{label[presence]}</Text> <Text size="L400">{label[presence]}</Text>
{status && <Text size="T200">•</Text>}
{status && <Text size="T200">{status}</Text>}
</Box> </Box>
</Tooltip> </Tooltip>
} }
@ -51,7 +49,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
ref={triggerRef} ref={triggerRef}
size={size} size={size}
variant={PresenceToColor[presence]} variant={PresenceToColor[presence]}
fill={presence === Presence.Offline ? 'Soft' : 'Solid'} fill={presence === 'offline' ? 'Soft' : 'Solid'}
radii="Pill" radii="Pill"
/> />
)} )}

View file

@ -15,7 +15,7 @@ export const AvatarPresenceBadge = style({
zIndex: 1, zIndex: 1,
display: 'flex', display: 'flex',
padding: config.borderWidth.B600, padding: '5%',
backgroundColor: 'inherit', backgroundColor: 'inherit',
borderRadius: config.radii.Pill, borderRadius: config.radii.Pill,
overflow: 'hidden', overflow: 'hidden',

View file

@ -12,3 +12,7 @@ export const RoomAvatar = style({
}, },
}, },
}); });
export const RoomAvatarImg = style({
fontSize: 1,
});

View file

@ -1,9 +1,45 @@
import { JoinRule } from 'matrix-js-sdk';
import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds'; import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds';
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react'; import React, {
ComponentProps,
ReactEventHandler,
ReactNode,
forwardRef,
useCallback,
useState,
} from 'react';
import classNames from 'classnames';
import * as css from './RoomAvatar.css'; import * as css from './RoomAvatar.css';
import { getRoomIconSrc } from '../../utils/room'; import { getRoomIconSrc } from '../../utils/room';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import { JoinRule } from '../../../types/matrix/room';
import { useDocumentVisibilityChange } from '../../hooks/useDocumentVisibilityChange';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
type RoomAvatarErrorProps = {
roomId: string;
setError: (error: boolean) => void;
children: ReactNode;
};
function RoomAvatarError({ roomId, setError, children }: RoomAvatarErrorProps) {
const handleVisibilityChange = useCallback(
(visible: boolean) => {
if (visible) setError(false);
},
[setError]
);
useDocumentVisibilityChange(handleVisibilityChange);
useDocumentFocusChange(handleVisibilityChange);
return (
<AvatarFallback
style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
className={css.RoomAvatar}
>
{children}
</AvatarFallback>
);
}
type RoomAvatarProps = { type RoomAvatarProps = {
roomId: string; roomId: string;
@ -18,7 +54,7 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
evt.currentTarget.setAttribute('data-image-loaded', 'true'); evt.currentTarget.setAttribute('data-image-loaded', 'true');
}; };
if (!src || error) { if (!src) {
return ( return (
<AvatarFallback <AvatarFallback
style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }} style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
@ -29,9 +65,17 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
); );
} }
if (error) {
return (
<RoomAvatarError roomId={roomId} setError={setError}>
{renderFallback()}
</RoomAvatarError>
);
}
return ( return (
<AvatarImage <AvatarImage
className={css.RoomAvatar} className={classNames(css.RoomAvatar, css.RoomAvatarImg)}
src={src} src={src}
alt={alt} alt={alt}
onError={() => setError(true)} onError={() => setError(true)}

View file

@ -1,5 +1,4 @@
import React, { ReactNode, useCallback, useRef, useState } from 'react'; import React, { ReactNode, useCallback, useRef, useState } from 'react';
import { MatrixError, Room } from 'matrix-js-sdk';
import { import {
Avatar, Avatar,
Badge, Badge,
@ -19,20 +18,22 @@ import {
} from 'folds'; } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { postJoin, PostJoinOK } from 'neurone/lib/api/matrix/client/v3/join';
import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request';
import * as css from './style.css'; import * as css from './style.css';
import { RoomAvatar } from '../room-avatar'; import { RoomAvatar } from '../room-avatar';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { getMxIdLocalPart } from '../../utils/matrix';
import { nameInitials } from '../../utils/common'; import { nameInitials } from '../../utils/common';
import { millify } from '../../plugins/millify'; import { millify } from '../../plugins/millify';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard'; import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard';
import { RoomType, StateEvent } from '../../../types/matrix/room'; import { RoomType } from '../../../types/matrix/room';
import { useJoinedRoomId } from '../../hooks/useJoinedRoomId'; import { useJoinedRoomId } from '../../hooks/useJoinedRoomId';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { getRoomAvatarUrl, getStateEvent } from '../../utils/room';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useLocalRoomSummary } from '../../hooks/useLocalRoomSummary';
import { mxcToHttp } from '../../../client/mxcToHttp';
import { useClient } from '../../hooks/useClient';
type GridColumnCount = '1' | '2' | '3'; type GridColumnCount = '1' | '2' | '3';
const getGridColumnCount = (gridWidth: number): GridColumnCount => { const getGridColumnCount = (gridWidth: number): GridColumnCount => {
@ -161,44 +162,40 @@ export const RoomCard = as<'div', RoomCardProps>(
}, },
ref ref
) => { ) => {
const mx = useMatrixClient(); const ct = useClient();
const { baseUrl } = ct.session;
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const joinedRoomId = useJoinedRoomId(allRooms, roomIdOrAlias); const joinedRoomId = useJoinedRoomId(allRooms, roomIdOrAlias);
const joinedRoom = mx.getRoom(joinedRoomId); const localSummary = useLocalRoomSummary(joinedRoomId ?? '');
const [topicEvent, setTopicEvent] = useState(() =>
joinedRoom ? getStateEvent(joinedRoom, StateEvent.RoomTopic) : undefined
);
const fallbackName = getMxIdLocalPart(roomIdOrAlias) ?? roomIdOrAlias; const fallbackName = getMxIdLocalPart(roomIdOrAlias) ?? roomIdOrAlias;
const fallbackTopic = roomIdOrAlias; const fallbackTopic = roomIdOrAlias;
const avatar = joinedRoom const avatarMxc = localSummary.avatarUrl ?? avatarUrl;
? getRoomAvatarUrl(mx, joinedRoom, 96, useAuthentication) const avatarHttpUrl =
: avatarUrl && mxcUrlToHttp(mx, avatarUrl, useAuthentication, 96, 96, 'crop'); avatarMxc &&
mxcToHttp(baseUrl, avatarMxc, useAuthentication).thumbnail({
width: 96,
height: 96,
method: 'crop',
});
const roomName = joinedRoom?.name || name || fallbackName; const roomName = localSummary?.name || name || fallbackName;
const roomTopic = const roomTopic = localSummary.topic || topic || fallbackTopic;
(topicEvent?.getContent().topic as string) || undefined || topic || fallbackTopic; const joinedMemberCount = localSummary.memberCount ?? memberCount;
const joinedMemberCount = joinedRoom?.getJoinedMemberCount() ?? memberCount;
useStateEventCallback( const [joinState, join] = useAsyncCallback<PostJoinOK, Error, []>(
mx, useCallback(async () => {
useCallback( const response = await ct.requestAuthJSON(
(event) => { postJoin({
if ( pathParams: { roomIdOrAlias },
joinedRoom && searchParams: { server_name: viaServers, via: viaServers },
event.getRoomId() === joinedRoom.roomId && })
event.getType() === StateEvent.RoomTopic );
) { const [err, result] = await parseMatrixResp<PostJoinOK>(response);
setTopicEvent(getStateEvent(joinedRoom, StateEvent.RoomTopic)); if (err) throw new Error(printMatrixError(err));
} return result;
}, }, [ct, roomIdOrAlias, viaServers])
[joinedRoom]
)
);
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers])
); );
const joining = const joining =
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success; joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
@ -213,7 +210,7 @@ export const RoomCard = as<'div', RoomCardProps>(
<Avatar size="500"> <Avatar size="500">
<RoomAvatar <RoomAvatar
roomId={roomIdOrAlias} roomId={roomIdOrAlias}
src={avatar ?? undefined} src={avatarHttpUrl}
alt={roomIdOrAlias} alt={roomIdOrAlias}
renderFallback={() => ( renderFallback={() => (
<Text as="span" size="H3"> <Text as="span" size="H3">
@ -222,7 +219,7 @@ export const RoomCard = as<'div', RoomCardProps>(
)} )}
/> />
</Avatar> </Avatar>
{(roomType === RoomType.Space || joinedRoom?.isSpaceRoom()) && ( {(roomType === RoomType.Space || localSummary.roomType === RoomType.Space) && (
<Badge variant="Secondary" fill="Soft" outlined> <Badge variant="Secondary" fill="Soft" outlined>
<Text size="L400">Space</Text> <Text size="L400">Space</Text>
</Badge> </Badge>
@ -273,7 +270,7 @@ export const RoomCard = as<'div', RoomCardProps>(
variant="Secondary" variant="Secondary"
size="300" size="300"
disabled={joining} disabled={joining}
before={joining && <Spinner size="50" variant="Secondary" fill="Soft" />} before={joining && <Spinner size="50" variant="Secondary" fill="Solid" />}
> >
<Text size="B300" truncate> <Text size="B300" truncate>
{joining ? 'Joining' : 'Join'} {joining ? 'Joining' : 'Join'}

View file

@ -1,59 +1,63 @@
import React, { useCallback, useState } from 'react'; import React, { useState } from 'react';
import { Avatar, Box, Button, Spinner, Text, as } from 'folds'; import { Avatar, Box, Button, Text, as } from 'folds';
import { Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
import { getMemberDisplayName, getStateEvent } from '../../utils/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { timeDayMonthYear, timeHourMinute } from '../../utils/time'; import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { RoomAvatar } from '../room-avatar'; import { RoomAvatar } from '../room-avatar';
import { nameInitials } from '../../utils/common'; import { nameInitials } from '../../utils/common';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
import { mDirectAtom } from '../../state/mDirectList';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { InviteUserPrompt } from '../invite-user-prompt'; import { InviteUserPrompt } from '../invite-user-prompt';
import { useClient } from '../../hooks/useClient';
import { useRoom } from '../../hooks/useRoom';
import { mxcToHttp } from '../../../client/mxcToHttp';
import { useRoomTopic } from '../../hooks/storage/useRoomTopic';
import { useRoomStateEvent } from '../../hooks/storage/useRoomStateEvent';
import { StateEventType } from '../../../types/matrix/room';
import { useRoomMembersAPI } from '../../hooks/useRoomMembers';
export type RoomIntroProps = { export const RoomIntro = as<
room: Room; 'div',
}; {
name: string;
export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => { avatar?: string;
const mx = useMatrixClient(); }
>(({ name, avatar, ...props }, ref) => {
const ct = useClient();
const roomId = useRoom();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const mDirects = useAtomValue(mDirectAtom);
const [invitePrompt, setInvitePrompt] = useState(false); const [invitePrompt, setInvitePrompt] = useState(false);
const { getName } = useRoomMembersAPI();
const createEvent = getStateEvent(room, StateEvent.RoomCreate); const topic = useRoomTopic(roomId);
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId)); const avatarHttpUrl = avatar
const name = useRoomName(room); ? mxcToHttp(ct.session.baseUrl, avatar, useAuthentication).url()
const topic = useRoomTopic(room); : undefined;
const avatarHttpUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
const createContent = createEvent?.getContent<IRoomCreateContent>(); const createResult = useRoomStateEvent(roomId, StateEventType.RoomCreate, '');
const ts = createEvent?.getTs(); const event = createResult.value?.event;
const creatorId = createEvent?.getSender(); const content = event?.content;
const creatorName =
creatorId && (getMemberDisplayName(room, creatorId) ?? getMxIdLocalPart(creatorId));
const prevRoomId = createContent?.predecessor?.room_id;
const [prevRoomState, joinPrevRoom] = useAsyncCallback( const ts = event?.origin_server_ts;
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]) const creatorId = event?.sender;
); const creatorName = creatorId && getName(creatorId);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const prevRoomId =
content &&
'predecessor' in event.content &&
typeof event.content.predecessor === 'object' &&
event.content.predecessor &&
'room_id' in event.content.predecessor &&
event.content.predecessor.room_id;
const [hour24Clock] = useSetting('hour24Clock');
return ( return (
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}> <Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
<Box> <Box>
<Avatar size="500"> <Avatar size="500">
<RoomAvatar <RoomAvatar
roomId={room.roomId} roomId={roomId}
src={avatarHttpUrl ?? undefined} src={avatarHttpUrl ?? undefined}
alt={name} alt={name}
renderFallback={() => <Text size="H2">{nameInitials(name)}</Text>} renderFallback={() => <Text size="H2">{nameInitials(name)}</Text>}
@ -82,36 +86,19 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
</Button> </Button>
{invitePrompt && ( {invitePrompt && (
<InviteUserPrompt room={room} requestClose={() => setInvitePrompt(false)} /> <InviteUserPrompt roomId={roomId} requestClose={() => setInvitePrompt(false)} />
)}
{typeof prevRoomId === 'string' && (
<Button
onClick={() => navigateRoom(prevRoomId)}
variant="Secondary"
size="300"
fill="Soft"
radii="300"
>
<Text size="B300">Open Old Room</Text>
</Button>
)} )}
{typeof prevRoomId === 'string' &&
(mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
<Button
onClick={() => navigateRoom(prevRoomId, createContent?.predecessor?.event_id)}
variant="Success"
size="300"
fill="Soft"
radii="300"
>
<Text size="B300">Open Old Room</Text>
</Button>
) : (
<Button
onClick={() => joinPrevRoom(prevRoomId)}
variant="Secondary"
size="300"
fill="Soft"
radii="300"
disabled={prevRoomState.status === AsyncStatus.Loading}
after={
prevRoomState.status === AsyncStatus.Loading ? (
<Spinner size="50" variant="Secondary" fill="Soft" />
) : undefined
}
>
<Text size="B300">Join Old Room</Text>
</Button>
))}
</Box> </Box>
</Box> </Box>
</Box> </Box>

View file

@ -1,5 +1,5 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { color, config } from 'folds'; import { color, config, toRem } from 'folds';
export const SplashScreen = style({ export const SplashScreen = style({
minHeight: '100%', minHeight: '100%',
@ -7,6 +7,11 @@ export const SplashScreen = style({
color: color.Background.OnContainer, color: color.Background.OnContainer,
}); });
export const SplashLogo = style({
width: toRem(42),
height: toRem(42),
});
export const SplashScreenFooter = style({ export const SplashScreenFooter = style({
padding: config.space.S400, padding: config.space.S400,
}); });

View file

@ -3,26 +3,33 @@ import React, { ReactNode } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import * as patternsCSS from '../../styles/Patterns.css'; import * as patternsCSS from '../../styles/Patterns.css';
import * as css from './SplashScreen.css'; import * as css from './SplashScreen.css';
import CinnySVG from '../../../../public/res/svg/cinny.svg';
type SplashScreenProps = { type SplashScreenProps = {
children: ReactNode; footer?: ReactNode;
children?: ReactNode;
}; };
export function SplashScreen({ children }: SplashScreenProps) { export function SplashScreen({ footer, children }: SplashScreenProps) {
return ( return (
<Box <Box
className={classNames(css.SplashScreen, patternsCSS.BackgroundDotPattern)} className={classNames(css.SplashScreen, patternsCSS.BackgroundDotPattern)}
direction="Column" direction="Column"
> >
{children} {children || (
<Box grow="Yes" direction="Column" alignItems="Center" justifyContent="Center" gap="400">
<img className={css.SplashLogo} src={CinnySVG} alt="Cinny Logo" />
<Text size="H2" align="Center">
Cinny
</Text>
</Box>
)}
<Box <Box
className={css.SplashScreenFooter} className={css.SplashScreenFooter}
shrink="No" shrink="No"
alignItems="Center" alignItems="Center"
justifyContent="Center" justifyContent="Center"
> >
<Text size="H2" align="Center"> {footer}
Cinny
</Text>
</Box> </Box>
</Box> </Box>
); );

View file

@ -5,7 +5,6 @@ import * as css from './styles.css';
import { PickerColumn } from './PickerColumn'; import { PickerColumn } from './PickerColumn';
import { hour12to24, hour24to12, hoursToMs, inSameDay, minutesToMs } from '../../utils/time'; import { hour12to24, hour24to12, hoursToMs, inSameDay, minutesToMs } from '../../utils/time';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
type TimePickerProps = { type TimePickerProps = {
min: number; min: number;
@ -15,7 +14,7 @@ type TimePickerProps = {
}; };
export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>( export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
({ min, max, value, onChange }, ref) => { ({ min, max, value, onChange }, ref) => {
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const [hour24Clock] = useSetting('hour24Clock');
const hour24 = dayjs(value).hour(); const hour24 = dayjs(value).hour();

View file

@ -1,7 +1,7 @@
import React, { useEffect, useCallback } from 'react'; import React, { useEffect, useCallback } from 'react';
import { Dialog, Text, Box, Button, config } from 'folds'; import { Dialog, Text, Box, Button, config } from 'folds';
import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types'; import { StageComponentProps } from './types';
import { AuthType } from '../../utils/matrix-uia';
function DummyErrorDialog({ function DummyErrorDialog({
title, title,

View file

@ -1,9 +1,10 @@
import React, { useEffect, useCallback, FormEventHandler } from 'react'; import React, { useEffect, useCallback, FormEventHandler } from 'react';
import { Dialog, Text, Box, Button, config, Input, color, Spinner } from 'folds'; import { Dialog, Text, Box, Button, config, Input, color, Spinner } from 'folds';
import { AuthType, MatrixError } from 'matrix-js-sdk'; import { printMatrixError } from 'neurone/lib/matrix-request';
import { StageComponentProps } from './types'; import { StageComponentProps } from './types';
import { AsyncState, AsyncStatus } from '../../hooks/useAsyncCallback'; import { AsyncState, AsyncStatus } from '../../hooks/useAsyncCallback';
import { RequestEmailTokenCallback, RequestEmailTokenResponse } from '../../hooks/types'; import { RequestEmailTokenCallback, RequestEmailTokenResult } from '../../hooks/types';
import { AuthType } from '../../utils/matrix-uia';
function EmailErrorDialog({ function EmailErrorDialog({
title, title,
@ -77,10 +78,14 @@ export function EmailStageDialog({
}: StageComponentProps & { }: StageComponentProps & {
email?: string; email?: string;
clientSecret: string; clientSecret: string;
emailTokenState: AsyncState<RequestEmailTokenResponse, MatrixError>; emailTokenState: AsyncState<RequestEmailTokenResult, Error>;
requestEmailToken: RequestEmailTokenCallback; requestEmailToken: RequestEmailTokenCallback;
}) { }) {
const { errorCode, error, session } = stageData; const { errorCode, error, session } = stageData;
const [matrixError, result] =
emailTokenState.status === AsyncStatus.Success ? emailTokenState.data : [];
const requestError =
emailTokenState.status === AsyncStatus.Error ? emailTokenState.error : undefined;
const handleSubmit = useCallback( const handleSubmit = useCallback(
(sessionId: string) => { (sessionId: string) => {
@ -120,14 +125,23 @@ export function EmailStageDialog({
); );
} }
if (emailTokenState.status === AsyncStatus.Error) { if (requestError) {
return ( return (
<EmailErrorDialog <EmailErrorDialog
title={emailTokenState.error.errcode ?? 'Verify Email'} title={requestError.name ?? 'Verify Email'}
message={requestError.message ?? 'Failed to send verification Email request.'}
onRetry={handleEmailSubmit}
onCancel={onCancel}
/>
);
}
if (matrixError) {
return (
<EmailErrorDialog
title={matrixError?.data.errcode ?? 'Verify Email'}
message={ message={
emailTokenState.error?.data?.error ?? matrixError ? printMatrixError(matrixError) : 'Failed to send verification Email request.'
emailTokenState.error.message ??
'Failed to send verification Email request.'
} }
onRetry={handleEmailSubmit} onRetry={handleEmailSubmit}
onCancel={onCancel} onCancel={onCancel}
@ -135,19 +149,19 @@ export function EmailStageDialog({
); );
} }
if (emailTokenState.status === AsyncStatus.Success) { if (result) {
return ( return (
<Dialog> <Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400"> <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="H4">Verification Request Sent</Text> <Text size="H4">Verification Request Sent</Text>
<Text>{`Please check your email "${emailTokenState.data.email}" and validate before continuing further.`}</Text> <Text>{`Please check your email "${result.email}" and validate before continuing further.`}</Text>
{errorCode && ( {errorCode && (
<Text style={{ color: color.Critical.Main }}>{`${errorCode}: ${error}`}</Text> <Text style={{ color: color.Critical.Main }}>{`${errorCode}: ${error}`}</Text>
)} )}
</Box> </Box>
<Button variant="Primary" onClick={() => handleSubmit(emailTokenState.data.result.sid)}> <Button variant="Primary" onClick={() => handleSubmit(result.result.sid)}>
<Text as="span" size="B400"> <Text as="span" size="B400">
Continue Continue
</Text> </Text>

View file

@ -0,0 +1,78 @@
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
import React, { useCallback } from 'react';
import { ObjectReader } from 'neurone/lib/utils';
import { StageComponentProps } from './types';
export function OAuthStage({ stageData, submitAuthDict, onCancel }: StageComponentProps) {
const { errorCode, error, session, info } = stageData;
const url = ObjectReader.string(info, 'url');
const handleSubmit = useCallback(() => {
submitAuthDict({
session,
});
}, [submitAuthDict, session]);
return (
<Dialog>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Reset encryption identity</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 reset your end-to-end encryption cross-signing identity, you need to approve this
request from your account dashboard.
</Text>
<Box direction="Column" gap="100">
<Text size="T200">
<b>1.</b> Open your account dashboard and approve the request.
</Text>
<Text size="T200">
<b>2.</b> Return here after approving and Continue.
</Text>
</Box>
{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>
)}
<Box direction="Column" gap="300">
<Button
variant="Secondary"
onClick={() => window.open(url, '_blank', 'noopener,noreferrer')}
>
<Text as="span" size="B400">
Open account dashboard
</Text>
</Button>
<Button variant="Primary" onClick={handleSubmit}>
<Text as="span" size="B400">
Continue
</Text>
</Button>
</Box>
</Box>
</Dialog>
);
}

View file

@ -1,9 +1,9 @@
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds'; import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
import React, { FormEventHandler } from 'react'; import React, { FormEventHandler } from 'react';
import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types'; import { StageComponentProps } from './types';
import { ErrorCode } from '../../cs-errorcode'; import { ErrorCode } from '../../cs-errorcode';
import { PasswordInput } from '../password-input'; import { PasswordInput } from '../password-input';
import { AuthType } from '../../utils/matrix-uia';
export function PasswordStage({ export function PasswordStage({
stageData, stageData,

View file

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { Dialog, Text, Box, Button, config } from 'folds'; import { Dialog, Text, Box, Button, config } from 'folds';
import { AuthType } from 'matrix-js-sdk';
import ReCAPTCHA from 'react-google-recaptcha'; import ReCAPTCHA from 'react-google-recaptcha';
import { StageComponentProps } from './types'; import { StageComponentProps } from './types';
import { AuthType } from '../../utils/matrix-uia';
function ReCaptchaErrorDialog({ function ReCaptchaErrorDialog({
title, title,

View file

@ -1,7 +1,7 @@
import React, { useEffect, useCallback, FormEventHandler } from 'react'; import React, { useEffect, useCallback, FormEventHandler } from 'react';
import { Dialog, Text, Box, Button, config, Input } from 'folds'; import { Dialog, Text, Box, Button, config, Input } from 'folds';
import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types'; import { StageComponentProps } from './types';
import { AuthType } from '../../utils/matrix-uia';
function RegistrationTokenErrorDialog({ function RegistrationTokenErrorDialog({
title, title,

View file

@ -1,7 +1,7 @@
import React, { useEffect, useCallback } from 'react'; import React, { useEffect, useCallback } from 'react';
import { Dialog, Text, Box, Button, config } from 'folds'; import { Dialog, Text, Box, Button, config } from 'folds';
import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types'; import { StageComponentProps } from './types';
import { AuthType } from '../../utils/matrix-uia';
function TermsErrorDialog({ function TermsErrorDialog({
title, title,

View file

@ -6,3 +6,4 @@ export * from './ReCaptchaStage';
export * from './RegistrationTokenStage'; export * from './RegistrationTokenStage';
export * from './SSOStage'; export * from './SSOStage';
export * from './TermsStage'; export * from './TermsStage';
export * from './OAuthStage';

View file

@ -1,8 +1,7 @@
import { AuthDict } from 'matrix-js-sdk';
import { AuthStageData } from '../../hooks/useUIAFlows'; import { AuthStageData } from '../../hooks/useUIAFlows';
export type StageComponentProps = { export type StageComponentProps = {
stageData: AuthStageData; stageData: AuthStageData;
submitAuthDict: (authDict: AuthDict) => void; submitAuthDict: (authDict: object) => void;
onCancel: () => void; onCancel: () => void;
}; };

View file

@ -4,7 +4,8 @@ import classNames from 'classnames';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import * as css from './UploadBoard.css'; import * as css from './UploadBoard.css';
import { TUploadFamilyObserverAtom, Upload, UploadStatus, UploadSuccess } from '../../state/upload'; import { UploadsAtom, Upload, UploadStatus, UploadSuccess } from '../../state/upload';
import { UploadItem } from '../../state/room/roomInputDrafts';
type UploadBoardProps = { type UploadBoardProps = {
header: ReactNode; header: ReactNode;
@ -29,22 +30,24 @@ export type UploadBoardImperativeHandlers = { handleSend: () => Promise<void> };
type UploadBoardHeaderProps = { type UploadBoardHeaderProps = {
open: boolean; open: boolean;
onToggle: () => void; onToggle: () => void;
uploadFamilyObserverAtom: TUploadFamilyObserverAtom; uploadsAtom: UploadsAtom;
uploadItems: UploadItem[];
onCancel: (uploads: Upload[]) => void; onCancel: (uploads: Upload[]) => void;
onSend: (uploads: UploadSuccess[]) => Promise<void>; onSend: (uploadItems: UploadItem[], uploads: UploadSuccess[]) => Promise<void>;
imperativeHandlerRef: MutableRefObject<UploadBoardImperativeHandlers | undefined>; imperativeHandlerRef: MutableRefObject<UploadBoardImperativeHandlers | undefined>;
}; };
export function UploadBoardHeader({ export function UploadBoardHeader({
open, open,
onToggle, onToggle,
uploadFamilyObserverAtom, uploadsAtom,
uploadItems,
onCancel, onCancel,
onSend, onSend,
imperativeHandlerRef, imperativeHandlerRef,
}: UploadBoardHeaderProps) { }: UploadBoardHeaderProps) {
const sendingRef = useRef(false); const sendingRef = useRef(false);
const uploads = useAtomValue(uploadFamilyObserverAtom); const uploads = useAtomValue(uploadsAtom);
const isSuccess = uploads.every((upload) => upload.status === UploadStatus.Success); const isSuccess = uploads.every((upload) => upload.status === UploadStatus.Success);
const isError = uploads.some((upload) => upload.status === UploadStatus.Error); const isError = uploads.some((upload) => upload.status === UploadStatus.Error);
@ -66,6 +69,7 @@ export function UploadBoardHeader({
if (sendingRef.current) return; if (sendingRef.current) return;
sendingRef.current = true; sendingRef.current = true;
await onSend( await onSend(
uploadItems,
uploads.filter((upload) => upload.status === UploadStatus.Success) as UploadSuccess[] uploads.filter((upload) => upload.status === UploadStatus.Success) as UploadSuccess[]
); );
sendingRef.current = false; sendingRef.current = false;

View file

@ -1,16 +1,16 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Chip, Icon, IconButton, Icons, Text, color } from 'folds'; import { Chip, Icon, IconButton, Icons, Text, color } from 'folds';
import { printMatrixError } from 'neurone/lib/matrix-request';
import { UploadCard, UploadCardError, CompactUploadCardProgress } from './UploadCard'; import { UploadCard, UploadCardError, CompactUploadCardProgress } from './UploadCard';
import { TUploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload'; import { UploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { UploadContent } from '../../utils/matrix';
import { TUploadContent } from '../../utils/matrix';
import { bytesToSize, getFileTypeIcon } from '../../utils/common'; import { bytesToSize, getFileTypeIcon } from '../../utils/common';
import { useMediaConfig } from '../../hooks/useMediaConfig'; import { useMediaConfig } from '../../hooks/useMediaConfig';
type CompactUploadCardRendererProps = { type CompactUploadCardRendererProps = {
isEncrypted?: boolean; isEncrypted?: boolean;
uploadAtom: TUploadAtom; uploadAtom: UploadAtom;
onRemove: (file: TUploadContent) => void; onRemove: (file: UploadContent) => void;
onComplete?: (upload: UploadSuccess) => void; onComplete?: (upload: UploadSuccess) => void;
}; };
export function CompactUploadCardRenderer({ export function CompactUploadCardRenderer({
@ -19,11 +19,10 @@ export function CompactUploadCardRenderer({
onRemove, onRemove,
onComplete, onComplete,
}: CompactUploadCardRendererProps) { }: CompactUploadCardRendererProps) {
const mx = useMatrixClient();
const mediaConfig = useMediaConfig(); const mediaConfig = useMediaConfig();
const allowSize = mediaConfig['m.upload.size'] || Infinity; const allowSize = mediaConfig['m.upload.size'] || Infinity;
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted); const { upload, startUpload, cancelUpload } = useBindUploadAtom(uploadAtom, isEncrypted);
const { file } = upload; const { file } = upload;
const fileSizeExceeded = file.size >= allowSize; const fileSizeExceeded = file.size >= allowSize;
@ -91,7 +90,11 @@ export function CompactUploadCardRenderer({
)} )}
{upload.status === UploadStatus.Error && ( {upload.status === UploadStatus.Error && (
<UploadCardError> <UploadCardError>
<Text size="T200">{upload.error.message}</Text> <Text size="T200">
{upload.error instanceof Error
? upload.error.message
: printMatrixError(upload.error)}
</Text>
</UploadCardError> </UploadCardError>
)} )}
{upload.status === UploadStatus.Idle && fileSizeExceeded && ( {upload.status === UploadStatus.Idle && fileSizeExceeded && (

View file

@ -1,20 +1,20 @@
import React, { ReactNode, useEffect } from 'react'; import React, { ReactNode, useEffect } from 'react';
import { printMatrixError } from 'neurone/lib/matrix-request';
import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } 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 { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload'; import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { UploadContent } from '../../utils/matrix';
import { TUploadContent } from '../../utils/matrix';
import { bytesToSize, getFileTypeIcon } from '../../utils/common'; import { bytesToSize, getFileTypeIcon } from '../../utils/common';
import { import {
roomUploadAtomFamily, roomUploadContentToUploadAtom,
TUploadItem, UploadItem,
TUploadMetadata, UploadMetadata,
} from '../../state/room/roomInputDrafts'; } from '../../state/room/roomInputDrafts';
import { useObjectURL } from '../../hooks/useObjectURL'; import { useObjectURL } from '../../hooks/useObjectURL';
import { useMediaConfig } from '../../hooks/useMediaConfig'; import { useMediaConfig } from '../../hooks/useMediaConfig';
type PreviewImageProps = { type PreviewImageProps = {
fileItem: TUploadItem; fileItem: UploadItem;
}; };
function PreviewImage({ fileItem }: PreviewImageProps) { function PreviewImage({ fileItem }: PreviewImageProps) {
const { originalFile, metadata } = fileItem; const { originalFile, metadata } = fileItem;
@ -35,7 +35,7 @@ function PreviewImage({ fileItem }: PreviewImageProps) {
} }
type PreviewVideoProps = { type PreviewVideoProps = {
fileItem: TUploadItem; fileItem: UploadItem;
}; };
function PreviewVideo({ fileItem }: PreviewVideoProps) { function PreviewVideo({ fileItem }: PreviewVideoProps) {
const { originalFile, metadata } = fileItem; const { originalFile, metadata } = fileItem;
@ -56,7 +56,7 @@ function PreviewVideo({ fileItem }: PreviewVideoProps) {
} }
type MediaPreviewProps = { type MediaPreviewProps = {
fileItem: TUploadItem; fileItem: UploadItem;
onSpoiler: (marked: boolean) => void; onSpoiler: (marked: boolean) => void;
children: ReactNode; children: ReactNode;
}; };
@ -100,9 +100,9 @@ function MediaPreview({ fileItem, onSpoiler, children }: MediaPreviewProps) {
type UploadCardRendererProps = { type UploadCardRendererProps = {
isEncrypted?: boolean; isEncrypted?: boolean;
fileItem: TUploadItem; fileItem: UploadItem;
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void; setMetadata: (fileItem: UploadItem, metadata: UploadMetadata) => void;
onRemove: (file: TUploadContent) => void; onRemove: (file: UploadContent) => void;
onComplete?: (upload: UploadSuccess) => void; onComplete?: (upload: UploadSuccess) => void;
}; };
export function UploadCardRenderer({ export function UploadCardRenderer({
@ -112,13 +112,12 @@ export function UploadCardRenderer({
onRemove, onRemove,
onComplete, onComplete,
}: UploadCardRendererProps) { }: UploadCardRendererProps) {
const mx = useMatrixClient();
const mediaConfig = useMediaConfig(); const mediaConfig = useMediaConfig();
const allowSize = mediaConfig['m.upload.size'] || Infinity; const allowSize = mediaConfig['m.upload.size'] || Infinity;
const uploadAtom = roomUploadAtomFamily(fileItem.file); const uploadAtom = roomUploadContentToUploadAtom(fileItem.file);
const { metadata } = fileItem; const { metadata } = fileItem;
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted); const { upload, startUpload, cancelUpload } = useBindUploadAtom(uploadAtom, isEncrypted);
const { file } = upload; const { file } = upload;
const fileSizeExceeded = file.size >= allowSize; const fileSizeExceeded = file.size >= allowSize;
@ -190,7 +189,11 @@ export function UploadCardRenderer({
)} )}
{upload.status === UploadStatus.Error && ( {upload.status === UploadStatus.Error && (
<UploadCardError> <UploadCardError>
<Text size="T200">{upload.error.message}</Text> <Text size="T200">
{upload.error instanceof Error
? upload.error.message
: printMatrixError(upload.error)}
</Text>
</UploadCardError> </UploadCardError>
)} )}
{upload.status === UploadStatus.Idle && fileSizeExceeded && ( {upload.status === UploadStatus.Idle && fileSizeExceeded && (

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