Compare commits

..
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
563 changed files with 30860 additions and 19376 deletions

View file

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

View file

@ -2,5 +2,4 @@ dist
node_modules
package.json
package-lock.json
LICENSE
harness
LICENSE

7
.zed/settings.json Normal file
View file

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

View file

@ -1,3 +1,3 @@
export default {
base: process.env.CINNY_BASE ?? '/',
base: '/',
};

1
call.config.json Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -5,7 +5,17 @@
"featuredCommunities": {
"openAsDefault": false,
"spaces": ["#gamecult:gamecult.org"],
"spaces": [
"#cinny:matrix.org",
"#community:matrix.org",
"#space:unredacted.org",
"#librewolf-community:matrix.org",
"#stickers-and-emojis:tastytea.de",
"#videogames:waywardinn.com",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
],
"rooms": [
"#tuwunel:grin.hu",
"#freesoftware:matrix.org",

View file

@ -1,352 +0,0 @@
# Ratatoskr: target and prose map
Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge
`GameCult/cinny` (to be renamed `GameCult/Ratatoskr`; see the cut sequence).
Void stewards it. Campaign `ratatoskr` lives in the eureka mind for now, beside
the rulings it rests on. Under `eureka-body:ruling:ruling-home-steward-mind` it
is handed to Void's mind once, by procedure, as soon as that hand-off procedure
exists (`hand_off` is parked); after that every new ruling on it is admitted in
Void's mind. Nothing here is written into Void's mind before then.
The mind holds the questions, rulings, specs, reports and verdicts. This file
holds what no document kind carries: the design truths, body facts, the model
page, rationale, the reconstructed collaboration plan, and the cut sequence.
All times are UTC.
## Why
GameCult is the only server this client talks to. A general Matrix client
treats it as one community among thousands, so the operator meets GameCult's
structure three clicks deep in an invite list of 50+ rooms (operator, `#swarm`
10-31 05:14: "The biggest pain is navigation. GameCult has a structure."). The
client should open on GameCult's estate as data: its spaces, its project rooms
grouped by steward, its Minds, and the person's own place in it. It should
also be a pleasant place to be in its own right: emotes, sound, and weather in
the background.
## Design truths (from rulings in force)
- **We own the interface, not the engine.** The fork keeps Cinny's engine
(matrix-js-sdk, Rust crypto, sync) and replaces the whole UI; every
user-facing string and its translation is ours, and upstream locale files
are not the source (`gamecult-chat:ruling:we-own-the-ui-and-translation`).
- **Mobile first.** On phones, inactive elements hide, vertical space is
cheaper than horizontal, and text never gets squeezed into narrow columns
(`eureka-body:ruling:chat-client-base-cinny-mobile-first`).
- **The first spec** (`eureka-body:ruling:chat-client-first-spec`): an emote
slot on every bubble (a pose id carried on the event); room and participant
lists tiered (category, then items) and reached from the header menu, not
side columns; participants grouped by estate (Minds, Members, Users,
Patrons); rooms grouped by kind (general, estates, projects, work rooms);
Minds indexed by stewardship, read from Matrix state projected from the
stewardship records in the Minds' own minds (the minds stay the truth); one
room per project, a Space child, with the steward joined.
- **Rooms are visible without a join dance.** No invite screen in the way; a
room the server lets you join shows in the same list as joined ones; opening
one shows its name and a one-tap Join; joining happens on that tap or a first
post, never silently; no peeking (operator, `#swarm` 10-31 04:08, 04:12
"Peeking sounds like YAGNI to me", 04:14 "One tap is a reasonable cost").
Rooms you may not enter are refused by the server, not hidden by the client
(Heimdall 04:10, accepted by Void 04:11 and 04:15).
- **Language.** A user's language is the BCP 47 tag in profile field
`org.gamecult.language`, owned by the user through the client's language
picker (`gamecult-chat:ruling:user-language-profile-field`). The client ships
localized; people talk with stewards in their own language; rooms are made
for a language, with no per-language mirrors
(`gamecult-chat:ruling:multilingual-client-stewards-language-rooms`). One
host Mind sits in a language room, Weksa for `#pt-br`
(`gamecult-chat:ruling:language-room-one-host-weksa`). The first person this
must work for is a non-English, non-technical patron (placeholder
`patron-pt1`) whose account we set up and walk through login
(`gamecult-chat:ruling:patron-in-his-own-language`).
- **Push.** `gamecult-chat:ruling:chat-push` rules out native mobile push for
now; Web Push to the installed web app is a different thing and is open
(question `ratatoskr:question:pwa-web-push`).
- **Collaboration is recorded, not chatted.** Work spanning Minds belongs in
Atlas (`eureka-body:ruling:atlas-records-collaborative-work`). Atlas is not
deployed, so the plan below is its interim home; Atlas imports it later.
## Body facts
Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`.
1. **Branch.** `void/gamecult-theme-and-project-nav` is `b8e696c8` over main
`1e6412ed` (v4.12.8), 2 commits, 14 files, +1153/-32 (686 of it lockfile):
a GameCult theme as default, member drawer hidden by default, bubble
layout, and steward grouping in `src/app/pages/client/space/Space.tsx`
(`useStewardGroups` at 429-437, group headers at 505-524) over
`useSpaceJoinedHierarchy` (409). Read by `git diff`.
2. **Live estate shape** (Synapse on Yggdrasil, read as `admin` over the
client API, counts only). Root Space `#gamecult:gamecult.org`, room version
12, has 9 children: 8 rooms and the `Projects` sub-Space. `Projects` has 52
children; 50 readable rooms, all room version 12. Server `/hierarchy` from
the root returns 59 rooms, 2 of them spaces. **Every room's join rule is
`invite`**; none is world-readable. 50 rooms carry `org.gamecult.steward`
with content `{mind, repo, source, steward: <mxid>}`. The root carries
`org.gamecult.stewardships`: Mind localpart to list of `Org/Repo`.
3. **Consequence of 1 and 2.** Project rooms are invite-only and Members are
invited, not joined (gamecult-ops `runbooks/gamecult-chat-host-yggdrasil.md`
146-158: "Members and every Mind are invited; the steward alone joins").
So a Member's project rooms are pending invites. Void's grouping reads
`useSpaceJoinedHierarchy` and a per-room state event that an invitee cannot
read, so for a typical Member it groups nothing and the rooms stay in the
invite list. This is the operator's "three clicks deep" complaint, seen from
the data.
4. **Provisioning contract.** gamecult-ops
`scripts/synapse-accounts-hand-managed.py` writes the rooms and both state
events from the Minds' stewardship documents; each project room's alias is
`#<repo lowercased>` (runbook 146-150). This is the only place the client
can learn a not-yet-joined room's steward today: hierarchy `canonical_alias`
joined to the root `org.gamecult.stewardships`. Follow-up
`ratatoskr:follow_up:stewardships-carry-room-ids` asks the projection to
name room ids so the client stops leaning on the alias convention.
5. **Sync and storage** (`src/client/initMatrix.ts`). The client uses
matrix-js-sdk 41.7.0 with `IndexedDBStore` (`web-sync-store`, 15-19) and the
Rust crypto store, and `startClient({ lazyLoadMembers: true })` (43-47): v2
`/sync`, no sliding sync, no custom filter or `initialSyncLimit`. A cold
start is therefore an IndexedDB load plus an incremental sync from the
stored token, not a full initial sync.
6. **Server sync support.** `/_matrix/client/versions` on Synapse 1.164.1
(127.0.0.1:8834) advertises `org.matrix.simplified_msc3575: true`:
simplified sliding sync (MSC4186) is available. Cinny 4.12.8 does not use it.
7. **Shell delivery.** `https://chat.gamecult.org/assets/index-DWIvG3QT.js` is
served at 3,736,520 bytes with no `Content-Encoding` even when gzip and br
are offered, and with no `Cache-Control` (only `ETag`/`Last-Modified`). The
service worker (`src/sw.ts`, 158 lines) handles only `install`, `activate`,
a session `message`, and authenticated media `fetch` (79-158); the
`vite-plugin-pwa` config sets `injectionPoint: undefined`
(`vite.config.ts` 101-113), so nothing is precached.
8. **No push path.** `src/sw.ts` has no `push` or `notificationclick` handler;
no code subscribes through `PushManager`; the only pusher the client sets is
the email pusher (`src/app/features/settings/notifications/SystemNotification.tsx`
22-40). Notifications and sounds fire only from a live page:
`src/app/pages/client/ClientNonUIFeatures.tsx` 79-129 (invites) and 131-258
(messages: `RoomEvent.Timeline` at 181-235, `new window.Notification` at
156, `notification.ogg` played at 175-178). Nothing in `src/` listens for
`visibilitychange` or calls `retryImmediately`.
9. **Sygnal WebPush** (matrix-org/sygnal `docs/applications.md`, read
2026-10-31; the page itself is marked partly out of date): a `webpush` app
takes `vapid_private_key`, `vapid_contact_email`, optional
`allowed_endpoints` and `ttl`; the client's pusher `data` carries
`endpoint`, `auth`, `p256dh` and optional `default_payload`; the push
carries `room_id`, `room_name`, `sender_display_name`, `content`, unread
counts and so on; browsers need a visible notification for each push
(`events_only` avoids event-less pushes).
10. **Baseline at `dd88424e`** (Yggdrasil, `ygg-verify.sh`, image
`node:22-bookworm`, `npm ci --ignore-scripts`): `vitest run` passes (1
file, 5 tests, Void's steward grouping); `vite build` succeeds, `dist/` 61
MB with source maps, main chunk 3,739,716 bytes, Rust crypto wasm
5,573,870 bytes, `dist/sw.mjs` 1.66 kB. `tsc --noEmit` fails at base with
793 errors in 246 files (455 of them TS2614, named imports from
`matrix-js-sdk` 41.7.0 not resolved), so typecheck is not a usable gate as
is; a cut gates on zero errors in the files it adds and no rise in the
total.
Not probed: phone frame times, the real resume timeline on the operator's
Android Chrome install (needs her device or remote DevTools), the Rust crypto
wasm size, and the router base under `/preview/` (its `config.json` sets
`hashRouter.basename` to `/`).
## Why the PWA resumes slowly (diagnosis, moderate confidence)
Android freezes a backgrounded installed web app and later discards it. On a
**discard**, returning is a full cold boot: 3.7 MB of uncompressed JS over a
phone link with no precache (fact 7), Rust crypto wasm init, the IndexedDB
store load, then a catch-up `/sync` over every room since the stored token
(fact 5). On a **freeze without discard**, the in-flight long-poll is dead, and
the SDK only notices on its own timers because the client never nudges it on
`visibilitychange` (fact 8). The catch-up sync grows with the number of rooms
and the swarm's chat volume, which is exactly where sliding sync helps
(fact 6), but the first two costs are cheaper to remove and should be measured
first. Without Web Push, a frozen page cannot notify at all, which is the
second half of the operator's report.
## Model page: the estate
| Kind | Named by | Over time | Who decides |
|---|---|---|---|
| Stewardship | `<instance>:stewardship:<repo>.n<seq>` in each Mind's mind | sequence advances on hand-over | the operator and the Minds; the minds are the truth |
| Estate projection | `org.gamecult.stewardships` on the root Space; `org.gamecult.steward` per project room | re-PUT on every provisioning run | gamecult-ops provisioning script (Eureka's slice) |
| Estate room list | the server's `/hierarchy` of the root Space for this user | changes with Space children and the user's invites and joins | Synapse (membership and join rules); the client derives |
| Room membership | the user's `m.room.member` | join on tap or first post | the user |
| Navigator view | derived per render | never stored | the client's estate index |
## Authority map for the navigator
- **Owner:** one estate index in the client (`src/app/features/estate/`):
`buildEstate()` is pure, `useEstate()` feeds it. It decides what rooms the
person sees, in which tier and group, and with what membership affordance.
- **Inputs:** the paged server `/hierarchy` of the root Space; root Space
state `org.gamecult.stewardships`; the person's membership per room from the
client store; unread and selection atoms for emphasis only; the `m.direct`
list for the direct-messages tier.
- **Outputs:** an ordered list of tiers (one per root room group and per
sub-Space, plus Direct), each holding steward groups or rooms, each room
marked joined, invited or joinable.
- **Derived:** steward group labels (Mind display names); counts; collapsed
state is per-device UI state only.
- **Demoted or dead:** the sidebar rail and its account-data ordering
(`useSidebarItems`) are dead; `useSpaceJoinedHierarchy` no longer decides
what is listed; Void's per-room steward reader is deleted, not kept beside
the index.
- **Shared paths:** every nav slot (home, direct, space routes) renders the
same navigator; tapping Join and sending a first post both go through
`mx.joinRoom`; deep links into a room still resolve through the existing
room routes.
## Reconstructed collaboration plan (Atlas import)
Planned in `#swarm` and `#minds` on 2026-10-31 and then lost to scroll. Status
as of 2026-10-31 ~15:00.
**Who holds which part**
| Part | Holder | Status | Evidence |
|---|---|---|---|
| The client: integration, navigator, emote slot, sound hookup, background canvas | Void (steward) | theme and steward grouping on branch; nothing else built | `void:stewardship:gamecult_-cinny.n1`; `#swarm` 10-31 14:54 Void report |
| Dings earcons: motif per event kind, one stable instrument per Mind | Aqua | not rendered: render env blocked, then .NET SDK installed and Faust scouted; AquaSynth PR #1 (Linux Faust loader, `faust-loader-platform-aware`) merged at 3b43b7bd | `aqua-body:follow_up:chat-s410-416`, `aqua-body:follow_up:chat-s410-416-render-env`, `aqua-body:resolution:follow_up.chat-s430-430.n1`, forge AquaSynth pull 1 |
| Ambient soundscape for the client | Aqua | idea only; no design recorded | operator `#swarm` 10-31 08:32 ("Motifs for everything"), `eureka-body:ruling:atlas-records-collaborative-work` |
| Background math, in CultMath: the public `fbm` gap, Phacelle erosion's Lipschitz bound, any new GLSL mirrors | Libby (steward of CultLib, which owns CultMath; holds `cultmath-tapes`) | sources located; nothing built | `#swarm` 10-31 06:34 and 06:46 Libby; `libby-body:follow_up:cultmath-ts-noise-absent`; `#swarm` 04:22 Libby accepts `cultmath-tapes` |
| Background scene and render: the raymarched terrain, the tree-and-squirrel intro scene, lighting and atmosphere, march and TAA tuning on the site-ground framework | Nibu (steward of Aetheria, whose volumetric nebula and cloud shaders she owns: `Raymarching/CloudShader.shader`, `Volumetric.cginc`) | proposed by the operator; not yet taken up by Nibu | operator to Self, 10-31: "If we want to spread the work around, Nibu owns the nebulae so she has shader expertise." |
| Animated bubbles, flourishes, corner avatar and XP bar | Void, with Kiko's offer on juice | advice only | `#swarm` 10-31 03:59 Kiko and Void, 06:45 Void |
| Attention bubbles | Eureka (Huginn heartbeat publishes), Void (render), Heimdall (scope rules) | advice only | `#swarm` 10-31 04:00 Eureka, 04:02 Heimdall |
**Agreements**
- Dings are pre-rendered clips shipped as static assets, no synth in the
browser: `dings/<mxid-localpart>/<motif>.ogg` plus a small manifest; an
unknown sender falls back to a neutral ding; ogg or mp3, not wav (Void
`#minds` 03:19, Aqua 07:41). The set is motif times instrument, and the
instrument says who spoke (operator 03:18, Eureka 03:19). Events that sound:
a mention, a DM, an invite, a message in an unmuted room; mentions and DMs
loud, ordinary messages quiet; everything else silent (Void `#minds` 03:19).
The operator wants far more than four motifs (08:32). The instrument per
Mind belongs in a typed document on the Mind side, not hardcoded (Eureka
03:19, advice). Aqua's ear check comes before anything is called done, and
the operator's ear is ground truth (Aqua 07:41). Browsers block audio until
the first click, so the first ding after a fresh load may be silent.
- The background has its own canvas and budget, separate from the bubble
flourishes; capped at 30 fps, paused when hidden; under reduced motion one
static frame; an adaptive step-down (Aqua 04:17, Void 04:18 and 04:22).
- The background is an infinite raymarched terrain: CultMath noise plus the
Phacelle erosion filter, in the shader (operator 04:18 "painter's algorithm",
05:24 "all we need is noise", 05:25 erosion, restated by the operator to
Self on 10-31: "use CultMath noise and erosion filter running in the shader
to have an infinite terrain. We can just raymarch that instead"). Voronoi
and Apollonian are dropped (05:24).
- Yggdrasil is the scene's centre (operator to Self, 10-31: "Yggdrasil should
be the star of the show now, so really the camera should just pull back a
bit when you login and the personas should be orbiting the tree, dancing
around each other in accordance with their activity"). On login the camera
pulls back from the tree; there is no separate intro to cut away from. The
Personas orbit the tree and move around each other by their live activity.
- One activity vocabulary drives both sound and motion: Aqua's activity-kinds
grammar (`aqua-body:follow_up:chat-s431-432`, "enumerate the kinds of agent
activity in typed state") is the shared contract between Aqua's motifs and
Nibu's orbits, not two vocabularies. The live feed behind it is the same
typed state the swarm dashboard projects
(`eureka-body:ruling:swarm-dashboard-first-thing2-surface`, Libby;
`libby-body:follow_up:chat-s354-359`): Run documents, the heartbeat trace.
What reaches the client is a member-safe projection: kind, Mind, time and
intensity, never briefs or mind contents. Matrix presence and typing cover
humans and are too coarse for Minds. Where the feed comes from is
`ratatoskr:question:activity-feed-source`.
- The scene renders in two passes, not one loop (operator to Self, 10-31:
"The tape optimized solid surface march should run really fast, i imagine,
way faster than the volumetric version, so they should not be one loop").
Pass 1, the solid-surface march (terrain, tree, squirrel), sphere-traces
with the field's Lipschitz bound and interval tile culling, at its own
resolution, and writes depth. Pass 2, the volumetric atmosphere march on
the site-ground framework (reduced resolution, TAA), is bounded by pass 1's
depth, so it marches only up to the surface hit. Each pass has its own
budget and its own measurement. Nibu owns both passes.
- The web math path is CultLib's own: HLSL source lowered by dxc and
SPIRV-Cross to WebGL2 GLSL ES (`packages/cultmath/shaders/CultMath.glsl`,
`.Phacelle.glsl`; `tools/glsl/build-glsl.sh`), and C# CPU math through
.NET wasm, with parity presumed for now (operator 06:23, 06:48; Libby
06:34). No TS or WGSL port. The gamecult.org site ground already
concatenates `CultMath.glsl` in `ground.js` and runs an interval-culled,
TAA-reprojected per-pixel march (CultLib `docs/cultmath-interval-ground-cut.md`,
spec `cultmath-interval-ground-cut-site-ground.r3`): that framework is the
base, not new tape work.
- Gaps go to the owner: a plain public `fbm` (only `fbm_grad`, `span_fbm`,
`af_fbm` exist) is added in CultMath, not in the client
(`libby-body:follow_up:cultmath-ts-noise-absent`; Libby 07:57 "tell me what
you need and I'll fill it at the library").
- Mood and flourish ride as metadata on the event, never guessed from text;
bounded per-frame budget; reduced-motion fallback (Void 03:59, Kiko 03:59).
- XP is two numbers, never one: a cosmetic, labelled game bar fed by things
worth doing, and contribution credit shown only once a real measure exists
(Void 06:45, with Nibu and Kiko).
**Open items**
- Libby: the raymarch step bound needs the field's Lipschitz bound. CultMath
tracks `SNOISE_LIPSCHITZ` for noise; erosion's bound is unknown and must be
probed before the march is trusted.
- Libby: public `fbm` in CultMath, with its GLSL mirror.
- Nibu (once she takes it): the terrain scene, lighting and atmosphere, and
march and TAA tuning on the site ground's framework at a phone budget;
waits on Libby's `fbm` and the erosion bound. Then the intro scene in the
same world.
- Aqua: render the first earcon set once Faust builds in her sandbox; then the
instrument-per-Mind list and the manifest; then the soundscape design.
- Aqua and Nibu: agree the activity vocabulary once (Aqua's grammar), so
motifs and orbits read the same kinds.
- Void: the navigator (next cut); the Dings hook at
`ClientNonUIFeatures.tsx` 131-258; the background canvas after Libby's shader.
- Eureka: compression and caching for the shell on the chat host
(`ratatoskr:follow_up:chat-shell-compression`); the stewardship projection's
room ids; a Web Push gateway if the operator picks it.
## Cut sequence
`estate-navigator` is specced (`ratatoskr:cut_spec:cut-estate-navigator.r1`).
Each later cut is one line, with what it waits on.
- `resume-fast`: precache the app shell in the service worker, nudge sync on
`visibilitychange`, and measure cold and warm resume on the operator's phone
before and after; waits on nothing; runs beside `estate-navigator` in its
own worktree, and pairs with the ops follow-up on compression.
- `pwa-web-push`: service worker `push` and `notificationclick` handlers, a
`webpush` pusher registered with VAPID keys, and a self-hosted Sygnal on the
chat host; waits on `ratatoskr:question:pwa-web-push`.
- `sliding-sync`: move the client to simplified sliding sync (MSC4186), which
the server already offers; waits on `resume-fast`'s measurements showing
catch-up sync dominates.
- `dings`: per-Mind earcons through the manifest at the existing notification
hook, and the same motifs in push notifications; waits on Aqua's renders.
- `emote-slot`: the pose-id emote on every bubble and the picker; waits on
`estate-navigator` (shared header work).
- `participant-tiers`: participants tiered by estate in the header menu, Minds
indexed by stewardship; waits on `estate-navigator`.
- `language`: our own strings and translations, the `org.gamecult.language`
picker, and the walk-through login for `patron-pt1`; waits on
`estate-navigator`.
- `terrain-background`: the raymarched CultMath noise and Phacelle terrain on
the site ground's march and TAA framework, in its own canvas and budget, as
two passes (solid-surface sphere trace writing depth, then the volumetric
atmosphere bounded by it), each measured on a phone; Nibu on the scene and
both passes, Libby's CultMath underneath, Void
integrating; waits on Libby's `fbm` and the erosion Lipschitz probe (both in
CultLib), then a phone measurement.
- `yggdrasil-scene`: the tree and squirrel at the centre of the terrain world;
on login the camera pulls back from the tree, and it may play during the
cold start or resume sync but never holds the client up; Nibu on the scene,
Libby on any CultMath it needs, Void integrating, Aqua's soundscape under
it; waits on `terrain-background`.
- `orbiting-personas`: Personas orbit the tree and dance around each other by
live activity, from the shared activity vocabulary; waits on
`yggdrasil-scene`, Aqua's activity-kinds grammar, and
`ratatoskr:question:activity-feed-source`.
- `bubbles-and-juice`: animated bubbles and mood flourishes from event
metadata, the corner avatar and the cosmetic XP bar; waits on `emote-slot`.
- `attention-bubbles`: per-person attention bubbles from Huginn's typed
attention document, scoped to room members; waits on Huginn publishing it.
- `rename-ratatoskr`: package name, app name and branding, manifest and
titles, the `/preview/` path and its deploy, and every in-repo `cinny`
reference that is ours (not upstream engine paths); waits on Void's forge
rename of `GameCult/cinny` to `GameCult/Ratatoskr`
(`eureka-body:ruling:chat-client-named-ratatoskr`), and the campaign's repo
list moves with it.

View file

@ -1,46 +0,0 @@
// Assertions for the estate drives: every drive states the outcome it exists to show and exits
// non-zero when the page or the fake homeserver's log says otherwise.
const fs = require('fs');
const fails = [];
exports.expect = (label, ok, detail = '') => {
console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`);
if (!ok) fails.push(label);
};
/** Exit code for the drive: 4 when any assertion failed. */
exports.done = () => (fails.length ? 4 : 0);
/** The fake homeserver's request log (HSLOG is shared with the server process). */
exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n');
/** Bodies of the account-data PUTs for m.direct, in order. */
exports.mDirectPuts = () =>
exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]);
/** Join requests the server saw for a room id. */
exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length;
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
/** The tier header above a row, 'absent' when the page does not show the row, 'none-above' when no tier header precedes it. */
const tierOf = (pg, name) =>
pg.evaluate((n) => {
const s = document.body.innerText.split('\n').map((x) => x.trim());
const i = s.indexOf(n);
if (i < 0) return 'absent';
for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j];
return 'none-above';
}, name);
/**
* The tier a room is listed under, read from a fresh visit to the room list: a collapsed tier is
* opened only if the row is not already showing, so reading never closes what is open.
*/
exports.revealedTier = async (pg, name) => {
await pg.goto('http://127.0.0.1:8080/#/home/');
await pg.waitForTimeout(2500);
let t = await tierOf(pg, name);
for (const h of HEADERS) {
if (t !== 'absent') break;
const hl = pg.getByText(h, { exact: true });
if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); t = await tierOf(pg, name); }
}
return t;
};
/** True when the page text matches re within ms of now (a message can be replaced by a row change). */
exports.sawText = (pg, re, ms) =>
pg.waitForFunction((src) => new RegExp(src, 'i').test(document.body.innerText), re.source, { timeout: ms, polling: 50 }).then(() => true, () => false);
exports.isDm = (tier) => /^direct messages$/i.test(tier);

View file

@ -1,72 +0,0 @@
// Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh
// against fakehs.cjs with the env flag each mode needs.
const { chromium } = require('playwright');
const { expect, done, isDm, mDirectPuts, joinsOf, revealedTier, sawText } = require('./check.cjs');
const MODE = process.env.MODE;
const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
const log = (...a) => console.log(`[${MODE}]`, ...a);
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
let b;
// Close the browser, but never wait on it: a hung close must not hang the run.
const quit = (code) => Promise.race([Promise.resolve(b && b.close()).catch(() => undefined), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(code));
setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000);
(async () => {
b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error') console.log('[console]', m.type(), m.text().slice(0, 160)); });
const open = async (h = '#/home/') => { await pg.goto('http://127.0.0.1:8080/' + h); await pg.reload(); await pg.waitForTimeout(9000); };
await pg.goto('http://127.0.0.1:8080/');
await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
await open();
const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' ');
const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; };
const tierOf = (name) => revealedTier(pg, name);
const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); };
const tiers = async (tag) => { const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; };
const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test'));
const acceptInInbox = async (name) => {
// reach the inbox through the navigator's own Inbox tab
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000);
const tab = pg.locator('[aria-label="Inbox"], [aria-label*="nbox"]'); log('INBOX_TAB', await tab.count());
if (await tab.count()) { await tab.first().click(); await pg.waitForTimeout(1500); }
const inv = pg.getByText('Invites', { exact: true }); if (await inv.count()) { await inv.first().click(); await pg.waitForTimeout(1500); }
log('URL', pg.url());
if (!/invites/.test(pg.url())) { await pg.goto('http://127.0.0.1:8080/#/inbox/invites/'); await pg.waitForTimeout(3000); log('URL_FALLBACK', pg.url()); }
{ const pub = pg.getByText('Public', { exact: true }); log('PUBLIC_FILTER', await pub.count()); if (await pub.count()) { await pub.first().click(); await pg.waitForTimeout(1500); } }
for (let r = 0; r < 3; r++) { const v = pg.getByText('View All', { exact: true }); if (!(await v.count())) break; await v.first().click(); await pg.waitForTimeout(800); }
const b0 = await body(); const i = b0.indexOf(name); log('INBOX has', name, i >= 0, JSON.stringify(b0.slice(Math.max(0, i - 120), i + 120)));
const card = pg.locator('div').filter({ hasText: name }).filter({ has: pg.getByText('Accept', { exact: true }) }).last();
await card.getByText('Accept', { exact: true }).first().click(); log('ACCEPT', name, t());
};
if (MODE === 'pjfail') {
await tap('Friend DM'); await pg.waitForTimeout(3000);
const bb = await body(); const k = bb.search(/Internal server error/); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message');
expect('a failed m.direct write shows the refusal', k >= 0, bb.slice(0, 200));
expect('no join is sent while the filing is failing', joinsOf('!dm1:test') === 0, String(joinsOf('!dm1:test')));
await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('after retry');
expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test')));
expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend);
} else if (MODE === 'jdrop') {
await tap('Friend DM'); const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_DROP', saw);
expect('a dropped join shows the retryable message', saw, (await body()).slice(0, 200));
await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload');
expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend);
expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend);
} else if (MODE === 'admin') {
await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin estate');
expect('an admin-invited DM is filed under Direct Messages', isDm(r.friend), r.friend);
} else if (MODE === 'inboxadmin') {
await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin inbox');
expect('an admin-invited DM accepted in the inbox is filed under Direct Messages', isDm(r.friend), r.friend);
} else if (MODE === 'inboxreload') {
await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/');
await pg.waitForTimeout(30000); const r = await tiers('inbox reload');
expect('a DM accepted in the inbox is filed under Direct Messages after a reload', isDm(r.slow), r.slow);
} else if (MODE === 'pjreload') {
await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open();
await pg.waitForTimeout(8000); await tiers('pjreload');
const n = await (await reveal('Slow DM')).count(); log('SLOW_DM_STILL_JOINABLE', n);
expect('a join cut off by a reload stays joinable', n === 1, String(n));
}
await quit(done());
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });

View file

@ -1,42 +0,0 @@
const { chromium } = require('playwright');
const { expect, done, isDm, mDirectPuts, hsLog, revealedTier, sawText } = require('./check.cjs');
const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
const log = (...a) => console.log(`[${MODE}]`, ...a);
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
let b; const quit = (c) => Promise.race([Promise.resolve(b && b.close()).catch(() => 0), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(c));
setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000);
(async () => {
b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
const pg = await ctx.newPage();
await pg.goto('http://127.0.0.1:8080/');
await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000);
const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' ');
const tiers = async (tag) => { const f = await revealedTier(pg, 'Friend DM'); log('TIER', tag, '| Friend DM', f); return f; };
let joined = false;
if (MODE === 'deeplink') {
for (const route of ['direct', 'home']) {
await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000);
const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300)));
const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count());
if (await j.count()) { joined = true; await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; }
}
const f = await tiers('after deeplink join');
const stamps = (re) => hsLog().filter((l) => re.test(l)).map((l) => Number(l.split(' ')[0]));
const put = stamps(/ PUT .*m\.direct BODY .*!dm1:test/)[0], join = stamps(/ JOIN !dm1:test$/)[0];
expect('the deep link offers one Join button', joined, 'no Join button');
expect('the DM is filed before the join is sent', put !== undefined && join !== undefined && put <= join, `put ${put} join ${join}`);
expect('the DM is filed under Direct Messages', isDm(f), f);
} else if (MODE === 'gw504') {
const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]');
for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } }
log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t());
const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_504', saw);
expect('a gateway timeout shows the retryable message', saw, (await body()).slice(0, 200));
await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)');
expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts()));
expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f);
}
await quit(done());
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });

View file

@ -1,48 +0,0 @@
const { chromium } = require('playwright');
const { expect, done, isDm, revealedTier } = require('./check.cjs');
const MODE = process.env.MODE; // twodm | reload
const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
const log = (...a) => console.log(`[${MODE}]`, ...a);
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
let tierBefore;
(async () => {
const b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error' || /command|direct/i.test(m.text())) console.log('[console]', m.type(), m.text().slice(0, 200)); });
const open = async () => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); };
await pg.goto('http://127.0.0.1:8080/');
await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
await open();
const text = () => pg.evaluate(() => document.body.innerText);
const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; };
const tierOf = (name) => revealedTier(pg, name);
const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); };
if (MODE === 'twodm') {
await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM');
await pg.waitForTimeout(45000);
} else if (MODE === 'convert') {
await tap('Friend DM'); await pg.waitForTimeout(6000);
tierBefore = await tierOf('Friend DM'); log('TIER before', tierBefore);
await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000);
const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url());
await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500);
log('ED_TEXT', JSON.stringify(await ed.first().innerText())); log('BODY', JSON.stringify((await text()).slice(-600)));
await pg.getByText('Convert direct message to room').first().click(); await pg.waitForTimeout(400); log('ED_PICKED', JSON.stringify(await ed.first().innerHTML()).slice(0, 300));
await pg.keyboard.press('Enter'); log('SENT', t()); await pg.waitForTimeout(800); log('ED_TEXT2', JSON.stringify(await ed.first().innerText().catch(()=>'')));
if (await ed.first().innerText().catch(() => '')) { await pg.keyboard.press('Enter'); log('SENT2', t()); }
await pg.waitForTimeout(8000);
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000);
} else {
await tap('Slow DM'); await pg.waitForTimeout(3000);
log('RELOAD', t()); await open();
await pg.waitForTimeout(30000);
}
const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM');
log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend);
if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); }
else if (MODE === 'convert') {
expect('Friend DM was filed under Direct Messages before the conversion', isDm(tierBefore), tierBefore);
expect('the converted room left Direct Messages and is still listed', !isDm(friend) && friend !== 'absent', friend);
} else expect('Slow DM is filed under Direct Messages after the reload', isDm(slow), slow);
await b.close(); process.exitCode = done();
})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; });

View file

@ -1,127 +0,0 @@
// Soul s2 fake homeserver. Invitee with no root state; stewards only in m.space.child content.
// Rooms: GEN joined; OP invited; 6 project rooms invited (odin slow: join arrives at +24 s);
// PUB public joinable, not in store; STRAY joined outside hierarchy; DM1 invited direct (fast);
// DM2 invited direct (slow, +24 s). NOROOT=1: the root alias does not resolve.
const http = require('http'), fs = require('fs'), path = require('path');
const DIST = process.argv[2], PORT = 8080, ME = '@t:test';
const NOROOT = process.env.NOROOT === '1';
const log = [];
const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin'];
const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' };
const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p }));
const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test', DM3 = '!dm3:test';
const SLOW = new Set([PROJ[5].id, DM2, DM3]);
const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' };
PROJ.forEach((r) => (NAMES[r.id] = r.name));
const held = []; const pendingState = []; const pending = []; const adLog = []; let ev = 0;
const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() });
const strip = (id, name, space, direct) => [
{ type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } },
{ type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } },
{ type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } },
...(direct ? [{ type: 'm.room.member', state_key: '@friend:test', sender: '@friend:test', content: { membership: 'join', displayname: 'Friend' } }] : []),
{ type: 'm.room.member', state_key: ME, sender: direct ? (process.env.INVITER_ADMIN === '1' && id === DM1 ? '@admin:test' : '@friend:test') : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } },
];
const joinedRoom = (id, name, extra = [], space = false) => ({
state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] },
timeline: { events: [{ type: 'm.room.message', sender: '@admin:test', content: { msgtype: 'm.text', body: `welcome to ${name}` }, event_id: `$m${++ev}`, origin_server_ts: Date.now() }], limited: false },
ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {},
});
const invites = () => {
const o = {};
o[ROOT] = { invite_state: { events: strip(ROOT, 'GameCult', true) } };
o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } };
o[OP] = { invite_state: { events: strip(OP, 'Operator') } };
PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } }));
o[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } };
o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } };
o[DM3] = { invite_state: { events: strip(DM3, NAMES[DM3], false, true) } };
return o;
};
const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 });
const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra });
const hierarchy = () => [
hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PUB), child(PSP)] }),
hroom(GEN, 'General', '#general:test'),
hroom(OP, 'Operator', '#operator:test'),
hroom(PUB, 'Commons', '#commons:test', { join_rule: 'public' }),
hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, STEWARD[r.key])) }),
...PROJ.map((r) => hroom(r.id, r.name, r.alias)),
];
let syncN = 1;
const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*' }); res.end(JSON.stringify(body)); };
const mime = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.json': 'application/json', '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
const flush = () => fs.writeFileSync(process.env.HSLOG, log.join('\n'));
http.createServer((req, res) => {
const u = new URL(req.url, 'http://x'); const p = decodeURIComponent(u.pathname);
if (req.method === 'OPTIONS') return json(res, 200, {});
if (!p.startsWith('/_matrix') && !p.startsWith('/.well-known')) {
let f = path.join(DIST, p === '/' ? 'index.html' : p);
if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) f = path.join(DIST, 'index.html');
res.writeHead(200, { 'Content-Type': mime[path.extname(f)] || 'application/octet-stream' });
return fs.createReadStream(f).pipe(res);
}
let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => {
if (req.method === 'PUT' && p.includes('m.direct')) console.log('MDIRECT_PUT ' + (Date.now()) + ' ' + body);
log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`);
if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} });
if (p.endsWith('/sync')) {
const since = u.searchParams.get('since');
if (!since) {
const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc');
return json(res, 200, { next_batch: `s${syncN}`, rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } });
}
const respond = () => {
const now = Date.now(); const join = {};
for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) {
const id = pending[i].id; pending.splice(i, 1);
const extra = id === DM1 || id === DM2 || id === DM3 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : [];
join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id);
}
for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); }
const k = Number(String(since).replace(/^s/, '')) || 0; const ad = adLog.filter((a) => a.after >= k).map((a) => ({ type: a.type, content: a.content })); // a server answers with what changed since the client's token, whichever client asks
if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } });
return json(res, 200, { next_batch: since, rooms: {} });
};
return setTimeout(respond, 300);
}
const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/);
if (req.method === 'POST' && jm) {
const id = jm[1]; log.push(`${Date.now()} JOIN ${id}`);
if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' });
const prior = pending.find((q) => q.id === DM2 || q.id === DM3);
pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) });
if (process.env.JOIN_504 === '1' && id === DM1) { console.log('JOIN_504 ' + Date.now() + ' ' + id); res.writeHead(504, {'Content-Type':'text/html'}); return res.end('<html>504 Gateway Time-out</html>'); }
if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); }
if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); }
return json(res, 200, { room_id: id });
}
const sm = p.match(/\/rooms\/([^/]+)\/state\/m\.room\.member\/(.+)$/);
if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); }
if (p.includes('/account_data/') && req.method === 'PUT' && p.includes('m.direct') && process.env.FAIL_MDIRECT === '1' && !global.failedOnce) { global.failedOnce = 1; console.log('MDIRECT_FAIL ' + Date.now()); return json(res, 500, { errcode: 'M_UNKNOWN', error: 'Internal server error' }); }
if (p.includes('/account_data/') && req.method === 'PUT') {
const DELAY = p.includes('m.direct') && process.env.DELAY_MDIRECT === '1';
const type = decodeURIComponent(p.split('/account_data/')[1]);
try { const c = JSON.parse(body); adLog.push({ type, content: c, after: syncN }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {}
if (DELAY) { console.log('MDIRECT_DELAYED ' + Date.now()); return setTimeout(() => json(res, 200, {}), 6000); }
return json(res, 200, {});
}
if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() });
if (p.includes('/directory/room/')) return NOROOT ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'Room alias not found' }) : json(res, 200, { room_id: ROOT, servers: ['test'] });
if (p.endsWith('/keys/upload')) return json(res, 200, { one_time_key_counts: { signed_curve25519: 50 } });
if (p.endsWith('/keys/query')) return json(res, 200, { device_keys: {}, failures: {} });
if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' });
if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } });
if (p.includes('/filter')) return json(res, 200, { filter_id: '1' });
if (p.includes('/account_data/')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' });
if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' });
if (p.includes('/members')) return json(res, 200, { chunk: [] });
if (p.includes('/profile/')) return json(res, 200, { displayname: 'Tess' });
if (p.includes('/capabilities')) return json(res, 200, { capabilities: {} });
if (p.includes('/devices')) return json(res, 200, { devices: [] });
if (p.includes('summary')) return json(res, 200, { room_id: p.split('/').pop(), name: 'Summary card', num_joined_members: 1, membership: 'invite', join_rule: 'invite' });
if (p.includes('/login')) return json(res, 200, { flows: [{ type: 'm.login.password' }] });
return json(res, 200, {});
});
}).listen(PORT, () => console.log('fakehs up'));
setInterval(flush, 300);

View file

@ -1,35 +0,0 @@
#!/usr/bin/env bash
# Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src:
# builds the client, then drives the direct-message and join-failure scenarios against fakehs.cjs.
set -e
cd /src
npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; }
npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; }
echo BUILD_OK $(git rev-parse HEAD)
printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json
mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1
cp /src/harness/estate/check.cjs /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/
set +e
for m in twodm reload convert; do
HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
HS=$!; sleep 2
HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/drive.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m"
kill $HS; wait $HS 2>/dev/null; sleep 1
echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt
done
failure() { m=$1; shift
env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
HS=$!; sleep 2
HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m"
kill $HS; wait $HS 2>/dev/null; sleep 1
echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; }
failure pjfail FAIL_MDIRECT=1
failure pjreload DELAY_MDIRECT=1
failure jdrop JOIN_DROP=1
failure admin INVITER_ADMIN=1
failure inboxadmin INVITER_ADMIN=1
failure inboxreload JOIN_DELAY=1
DRIVER=drive-routes failure deeplink
DRIVER=drive-routes failure gw504 JOIN_504=1
echo "FAILED_DRIVES:${FAILED:- none}"
[ -z "$FAILED" ]

947
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "cinny",
"version": "4.12.8",
"version": "4.12.7",
"description": "Yet another matrix client",
"main": "index.js",
"type": "module",
@ -18,8 +18,7 @@
"typecheck": "tsc --noEmit",
"prepare": "husky install",
"commit": "git-cz",
"bump": "node scripts/update-version.js",
"test": "vitest run"
"bump": "node scripts/update-version.js"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": "eslint",
@ -57,20 +56,21 @@
"emojibase-data": "15.3.2",
"file-saver": "2.0.5",
"focus-trap-react": "10.0.2",
"folds": "2.7.2",
"folds": "2.7.3",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"i18next": "23.12.2",
"i18next-browser-languagedetector": "8.0.0",
"i18next-http-backend": "4.0.3",
"idb": "8.0.3",
"immer": "9.0.16",
"is-hotkey": "0.2.0",
"jotai": "2.6.0",
"linkify-react": "4.3.2",
"linkifyjs": "4.3.2",
"matrix-js-sdk": "41.7.0",
"matrix-widget-api": "1.16.1",
"matrix-widget-api": "1.20.0",
"millify": "6.1.0",
"neurone": "0.0.9",
"pdfjs-dist": "4.2.67",
"prismjs": "1.30.0",
"react": "18.2.0",
@ -83,18 +83,15 @@
"react-i18next": "15.0.0",
"react-range": "1.8.14",
"react-router-dom": "6.30.6",
"sanitize-html": "2.18.1",
"sanitize-html": "2.17.7",
"slate": "0.123.0",
"slate-dom": "0.123.0",
"slate-history": "0.113.1",
"slate-react": "0.123.0",
"ua-parser-js": "1.0.35",
"@fontsource/ubuntu": "5.2.5",
"@fontsource/montserrat": "5.2.5",
"@fontsource/ibm-plex-mono": "5.2.5"
"ua-parser-js": "1.0.35"
},
"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",
"@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1",
@ -123,11 +120,10 @@
"husky": "9.1.7",
"lint-staged": "16.3.2",
"prettier": "2.8.1",
"typescript": "4.9.4",
"typescript": "6.0.3",
"vite": "5.4.19",
"vite-plugin-pwa": "0.20.5",
"vite-plugin-static-copy": "1.0.4",
"vite-plugin-top-level-await": "1.4.4",
"vitest": "1.6.0"
"vite-plugin-top-level-await": "1.4.4"
}
}

View file

@ -14,7 +14,7 @@ import {
Scroll,
config,
} from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { AccountDataEvent } from 'neurone/lib/api/types';
import { Cursor } from '../plugins/text-area';
import { syntaxErrorPosition } from '../utils/dom';
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>;
type AccountDataInfo = {
type: string;
content: object;
};
type AccountDataEditProps = {
type: string;
defaultContent: string;
submitChange: AccountDataSubmitCallback;
onCancel: () => void;
onSave: (info: AccountDataInfo) => void;
onSave: (info: AccountDataEvent) => void;
};
function AccountDataEdit({
type,
@ -57,7 +52,7 @@ function AccountDataEdit({
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 handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
@ -159,7 +154,9 @@ function AccountDataEdit({
{submitState.status === AsyncStatus.Error && (
<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>
)}
</Box>
@ -242,34 +239,30 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
}
export type AccountDataEditorProps = {
type?: string;
content?: object;
event?: AccountDataEvent;
submitChange: AccountDataSubmitCallback;
requestClose: () => void;
};
export function AccountDataEditor({
type,
content,
submitChange,
requestClose,
}: AccountDataEditorProps) {
const [data, setData] = useState<AccountDataInfo>({
type: type ?? '',
content: content ?? {},
});
export function AccountDataEditor({ event, submitChange, requestClose }: AccountDataEditorProps) {
const [data, setData] = useState<AccountDataEvent>(
event ?? {
type: '',
content: {},
}
);
const [edit, setEdit] = useState(!type);
const [edit, setEdit] = useState(!data.type);
const closeEdit = useCallback(() => {
if (!type) {
if (!event) {
requestClose();
return;
}
setEdit(false);
}, [type, requestClose]);
}, [event, requestClose]);
const handleSave = useCallback((info: AccountDataInfo) => {
const handleSave = useCallback((info: AccountDataEvent) => {
setData(info);
setEdit(false);
}, []);

View file

@ -1,33 +1,48 @@
import React, { ReactNode } from 'react';
import { AuthDict, AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
import { getUIAFlowForStages } from '../utils/matrix-uia';
import React, { ReactNode, useState } from 'react';
import {
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 { UIAFlowOverlay } from './UIAFlowOverlay';
import { PasswordStage, SSOStage } from './uia-stages';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { OAuthStage, PasswordStage, SSOStage } from './uia-stages';
import { useClient } from '../hooks/useClient';
export const SUPPORTED_IN_APP_UIA_STAGES = [AuthType.Password, AuthType.Sso];
export const pickUIAFlow = (uiaFlows: UIAFlow[]): UIAFlow | undefined => {
const passwordFlow = getUIAFlowForStages(uiaFlows, [AuthType.Password]);
if (passwordFlow) return passwordFlow;
return getUIAFlowForStages(uiaFlows, [AuthType.Sso]);
};
export const SUPPORTED_IN_APP_UIA_STAGES = [AuthType.Password, AuthType.Sso, AuthType.OAuth];
type ActionUIAProps = {
authData: IAuthData;
authData: AuthResponse;
ongoingFlow: UIAFlow;
action: (authDict: AuthDict) => void;
action: (authDict: object) => void;
onCancel: () => void;
};
export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) {
const mx = useMatrixClient();
const ct = useClient();
const completed = useUIACompleted(authData);
const { getStageToComplete } = useUIAFlow(authData, ongoingFlow);
const stageToComplete = getStageToComplete();
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 (
<UIAFlowOverlay
currentStep={completed.length + 1}
@ -36,7 +51,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
>
{stageToComplete.type === AuthType.Password && (
<PasswordStage
userId={mx.getUserId()!}
userId={ct.session.userId}
stageData={stageToComplete}
onCancel={onCancel}
submitAuthDict={action}
@ -44,30 +59,110 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
)}
{stageToComplete.type === AuthType.Sso && stageToComplete.session && (
<SSOStage
ssoRedirectURL={mx.getFallbackAuthUrl(AuthType.Sso, stageToComplete.session)}
ssoRedirectURL={ssoRedirectURL.toString()}
stageData={stageToComplete}
onCancel={onCancel}
submitAuthDict={action}
/>
)}
{stageToComplete.type === AuthType.OAuth && (
<OAuthStage stageData={stageToComplete} onCancel={onCancel} submitAuthDict={action} />
)}
</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 = {
authData: IAuthData;
authData: AuthResponse;
onCancel: () => void;
unsupported: () => ReactNode;
children: (ongoingFlow: UIAFlow) => ReactNode;
};
export function ActionUIAFlowsLoader({
authData,
onCancel,
unsupported,
children,
}: ActionUIAFlowsLoaderProps) {
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);
}

View file

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

View file

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

View file

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

View file

@ -1,15 +1,23 @@
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 { useMatrixClient } from '../hooks/useMatrixClient';
import { useClient } from '../hooks/useClient';
import { Capabilities } from '../hooks/useCapabilities';
type CapabilitiesLoaderProps = {
children: (capabilities: Capabilities | undefined) => ReactNode;
};
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(() => {
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 { VerificationMethod } from 'matrix-js-sdk/lib/types';
import {
Box,
Button,
@ -23,13 +16,24 @@ import {
} from 'folds';
import FocusTrap from 'focus-trap-react';
import {
useVerificationRequestPhase,
useVerificationRequestReceived,
useVerifierCancel,
useVerifierShowSas,
} from '../hooks/useVerificationRequest';
CancelableVerificationStage,
CancelVerificationStage,
DoneVerificationStage,
InboundRequestStage,
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 { ContainerColor } from '../styles/ContainerColor.css';
import { useObservableValue } from '../hooks/useObservableValue';
const DialogHeaderStyles: CSSProperties = {
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@ -70,10 +74,12 @@ function VerificationWaitAccept() {
}
type VerificationAcceptProps = {
onAccept: () => Promise<void>;
stage: InboundRequestStage;
};
function VerificationAccept({ onAccept }: VerificationAcceptProps) {
const [acceptState, accept] = useAsyncCallback(onAccept);
function VerificationAccept({ stage }: VerificationAcceptProps) {
const [acceptState, accept] = useAsyncCallback(
useCallback(() => stage.ready(['m.sas.v1']), [stage])
);
const accepting = acceptState.status === AsyncStatus.Loading;
return (
@ -102,12 +108,12 @@ function VerificationWaitStart() {
}
type VerificationStartProps = {
onStart: () => Promise<void>;
stage: ReadyVerificationStage;
};
function AutoVerificationStart({ onStart }: VerificationStartProps) {
function AutoVerificationStart({ stage }: VerificationStartProps) {
useEffect(() => {
onStart();
}, [onStart]);
stage.startSas();
}, [stage]);
return (
<Box direction="Column" gap="400">
@ -116,8 +122,8 @@ function AutoVerificationStart({ onStart }: VerificationStartProps) {
);
}
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
function CompareEmoji({ stage }: { stage: SasAcceptStage }) {
const [confirmState, confirm] = useAsyncCallback(useCallback(() => stage.match(), [stage]));
const confirming =
confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success;
@ -135,17 +141,17 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
wrap="Wrap"
justifyContent="Center"
>
{sasData.sas.emoji?.map(([emoji, name], index) => (
{stage.emoji?.map((emoji, index) => (
<Box
// eslint-disable-next-line react/no-array-index-key
key={`${emoji}${name}${index}`}
key={`${emoji.description}${index}`}
direction="Column"
gap="100"
justifyContent="Center"
alignItems="Center"
>
<Text size="H1">{emoji}</Text>
<Text size="T200">{name}</Text>
<Text size="H1">{emoji.emoji}</Text>
<Text size="T200">{emoji.description}</Text>
</Box>
))}
</Box>
@ -162,7 +168,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
<Button
variant="Primary"
fill="Soft"
onClick={() => sasData.mismatch()}
onClick={() => stage.mismatch()}
disabled={confirming}
>
<Text size="B400">Do not Match</Text>
@ -173,21 +179,23 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
}
type SasVerificationProps = {
verifier: Verifier;
sas: SasMethod;
onCancel: () => void;
};
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
const [sasData, setSasData] = useState<ShowSasCallbacks>();
useVerifierShowSas(verifier, setSasData);
useVerifierCancel(verifier, onCancel);
function SasVerification({ sas, onCancel }: SasVerificationProps) {
const stage = useObservableValue(sas.stage);
useEffect(() => {
verifier.verify();
}, [verifier]);
if (stage instanceof SasStartStage) {
stage.accept();
}
if (stage instanceof SasCancelState) {
onCancel();
}
}, [stage, onCancel]);
if (sasData) {
return <CompareEmoji sasData={sasData} />;
if (stage instanceof SasAcceptStage && stage.emoji) {
return <CompareEmoji stage={stage} />;
}
return (
@ -214,12 +222,13 @@ function VerificationDone({ onExit }: VerificationDoneProps) {
}
type VerificationCanceledProps = {
stage: CancelVerificationStage;
onClose: () => void;
};
function VerificationCanceled({ onClose }: VerificationCanceledProps) {
function VerificationCanceled({ stage, onClose }: VerificationCanceledProps) {
return (
<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}>
<Text size="B400">Close</Text>
</Button>
@ -228,23 +237,18 @@ function VerificationCanceled({ onClose }: VerificationCanceledProps) {
}
type DeviceVerificationProps = {
request: VerificationRequest;
request: InboundVerification | OutboundVerification;
onExit: () => void;
};
export function DeviceVerification({ request, onExit }: DeviceVerificationProps) {
const phase = useVerificationRequestPhase(request);
const stage = useObservableValue(request.stage);
const handleCancel = useCallback(() => {
if (request.phase !== VerificationPhase.Done && request.phase !== VerificationPhase.Cancelled) {
request.cancel();
if (stage instanceof CancelableVerificationStage) {
stage.cancel();
}
onExit();
}, [request, onExit]);
const handleAccept = useCallback(() => request.accept(), [request]);
const handleStart = useCallback(async () => {
await request.startVerification(VerificationMethod.Sas);
}, [request]);
}, [stage, onExit]);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
@ -266,30 +270,27 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{phase === VerificationPhase.Requested &&
(request.initiatedByMe ? (
<VerificationWaitAccept />
) : (
<VerificationAccept onAccept={handleAccept} />
))}
{phase === VerificationPhase.Ready &&
(request.initiatedByMe ? (
<AutoVerificationStart onStart={handleStart} />
{stage instanceof InboundRequestStage && <VerificationAccept stage={stage} />}
{stage instanceof OutboundRequestStage && <VerificationWaitAccept />}
{stage instanceof ReadyVerificationStage &&
(request instanceof OutboundVerification ? (
<AutoVerificationStart stage={stage} />
) : (
<VerificationWaitStart />
))}
{phase === VerificationPhase.Started &&
(request.verifier ? (
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
{stage instanceof StartVerificationStage &&
(stage.method ? (
<SasVerification sas={stage.method} onCancel={handleCancel} />
) : (
<VerificationUnexpected
message="Unexpected Error! Verification is started but verifier is missing."
onClose={handleCancel}
/>
))}
{phase === VerificationPhase.Done && <VerificationDone onExit={onExit} />}
{phase === VerificationPhase.Cancelled && (
<VerificationCanceled onClose={handleCancel} />
{stage instanceof DoneVerificationStage && <VerificationDone onExit={onExit} />}
{stage instanceof CancelVerificationStage && (
<VerificationCanceled stage={stage} onClose={handleCancel} />
)}
</Box>
</Dialog>
@ -300,7 +301,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
}
export function ReceiveSelfDeviceVerification() {
const [request, setRequest] = useState<VerificationRequest>();
const [request, setRequest] = useState<InboundVerification | OutboundVerification>();
useVerificationRequestReceived(setRequest);
@ -310,7 +311,7 @@ export function ReceiveSelfDeviceVerification() {
if (!request) return null;
if (!request.isSelfVerification) {
if (!request.selfVerification) {
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 {
Dialog,
Header,
@ -12,154 +19,236 @@ import {
Chip,
color,
Spinner,
ProgressBar,
} from 'folds';
import FileSaver from 'file-saver';
import to from 'await-to-js';
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
import { BackupManager } from 'neurone/lib/crypto/machine/backup';
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 { ContainerColor } from '../styles/ContainerColor.css';
import { copyToClipboard } from '../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { clearSecretStorageKeys } from '../../client/secretStorageKeys';
import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAlive } from '../hooks/useAlive';
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> = (
authDict: AuthDict | null
) => Promise<[IAuthData, undefined] | [undefined, T]>;
type PerformAction<T> = (authDict: AuthDict | null) => Promise<T>;
type UIAAction<T> = {
authData: IAuthData;
callback: UIACallback<T>;
cancelCallback: () => void;
type SetupProgressChecklist = {
bootstrapCrossSinging?: boolean;
putMasterAccountData?: boolean;
putSelfAccountData?: boolean;
putUserAcountData?: boolean;
verfiyOwnDevice?: boolean;
postBackupVersion?: boolean;
putBackupAccountData?: boolean;
putRecoveryKeyAccountData?: boolean;
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 = {
onComplete: (recoveryKey: string) => void;
};
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 [uiaAction, setUIAAction] = useState<UIAAction<void>>();
const [nextAuthData, setNextAuthData] = useState<IAuthData | null>(); // null means no next action.
useEffect(() => uia.onUIA.subscribe(setUIAData), [uia]);
const handleAction = useCallback(
async (authDict: AuthDict) => {
if (!uiaAction) {
throw new Error('Unexpected Error! UIA action is perform without data.');
}
if (alive()) {
setNextAuthData(null);
}
const [authData] = await uiaAction.callback(authDict);
if (alive() && authData) {
setNextAuthData(authData);
}
(authDict: object) => {
setUIAData(undefined);
uia.retry(authDict);
},
[uiaAction, alive]
[uia]
);
const resetUIA = useCallback(() => {
if (!alive()) return;
setUIAAction(undefined);
setNextAuthData(undefined);
}, [alive]);
const authUploadDeviceSigningKeys: UIAuthCallback<void> = useCallback(
(makeRequest) =>
new Promise<void>((resolve, reject) => {
makeRequest(null)
.then(() => {
resolve();
resetUIA();
})
.catch((error) => {
if (error instanceof MatrixError && error.httpStatus === 401) {
const authData = error.data as IAuthData;
const action = makeUIAAction(
authData,
makeRequest as PerformAction<void>,
resolve,
(err) => {
resetUIA();
reject(err);
}
);
if (alive()) {
setUIAAction(action);
} else {
reject(new Error('Authentication failed! Failed to setup device verification.'));
}
return;
}
reject(error);
});
}),
[alive, resetUIA]
);
const handleCancel = useCallback(() => {
setUIAData(undefined);
uia.clear();
}, [uia]);
const [setupState, setup] = useAsyncCallback<void, Error, [string | undefined]>(
useCallback(
async (passphrase) => {
const crypto = mx.getCrypto();
if (!crypto) throw new Error('Unexpected Error! Crypto module not found!');
setSetupProgress({});
const retrier = new MatrixRequestRetrier(sleep);
const recoveryKeyData = await crypto.createRecoveryKeyFromPassphrase(passphrase);
if (!recoveryKeyData.encodedPrivateKey) {
throw new Error('Unexpected Error! Failed to create recovery key.');
const defaultKeyId = randomString(16);
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({
createSecretStorageKey: async () => recoveryKeyData,
setupNewSecretStorage: true,
});
const masterEnc = await AesHmacSha2.encrypt(
recoveryKey,
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({
authUploadDeviceSigningKeys,
setupNewCrossSigning: true,
});
const { userId } = ct.session;
const masterSecret: Secret = { encrypted: { [defaultKeyId]: masterEnc } };
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);
};
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 (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
<Text size="T300">
Generate a <b>Recovery Key</b> for verifying identity if you do not have access to other
devices. Additionally, setup a passphrase as a memorable alternative.
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Passphrase (Optional)</Text>
<PasswordInput name="passphraseInput" size="400" readOnly={loading} />
</Box>
<Button
type="submit"
disabled={loading}
before={loading && <Spinner size="200" variant="Primary" fill="Solid" />}
>
<Text size="B400">Continue</Text>
</Button>
{setupProgress ? (
<Box direction="Column" gap="400">
<Text size="L400">Setup progress</Text>
<Box style={{ paddingLeft: config.space.S200 }} direction="Column" gap="100">
{setupSteps.map(({ key, label }) => (
<Text size="T200" key={key}>
{setupProgress[key] ? '✅' : '⏳'} {label}
</Text>
))}
</Box>
<ProgressBar variant="Secondary" size="300" min={0} value={completedSteps} />
</Box>
) : (
<>
<Text size="T300">
Generate a <b>Recovery Key</b> for verifying identity if you do not have access to other
devices. Additionally, setup a passphrase as a memorable alternative.
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Passphrase (Optional)</Text>
<PasswordInput name="passphraseInput" size="400" readOnly={loading} />
</Box>
<Button
type="submit"
disabled={loading}
before={loading && <Spinner size="200" variant="Primary" fill="Solid" />}
>
<Text size="B400">Continue</Text>
</Button>
</>
)}
{setupState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{setupState.error ? setupState.error.message : 'Unexpected Error!'}</b>
</Text>
)}
{nextAuthData !== null && uiaAction && (
{uiaData && (
<ActionUIAFlowsLoader
authData={nextAuthData ?? uiaAction.authData}
authData={uiaData}
onCancel={handleCancel}
unsupported={() => (
<Text size="T200">
<Text size="T200" style={{ color: color.Critical.Main }}>
Authentication steps to perform this action are not supported by client.
</Text>
)}
>
{(ongoingFlow) => (
<ActionUIA
authData={nextAuthData ?? uiaAction.authData}
authData={uiaData}
ongoingFlow={ongoingFlow}
action={handleAction}
onCancel={uiaAction.cancelCallback}
onCancel={handleCancel}
/>
)}
</ActionUIAFlowsLoader>
@ -228,6 +354,7 @@ type RecoveryKeyDisplayProps = {
recoveryKey: string;
};
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const ct = useClient();
const [show, setShow] = useState(false);
const handleCopy = () => {
@ -235,10 +362,13 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
};
const handleDownload = () => {
const username = getMxIdLocalPart(ct.session.userId);
const servername = getMxIdServer(ct.session.userId);
const blob = new Blob([recoveryKey], {
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, '*');
@ -270,12 +400,12 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
</Box>
</Box>
<Box direction="Column" gap="200">
<Button onClick={handleCopy}>
<Text size="B400">Copy</Text>
</Button>
<Button onClick={handleDownload} fill="Soft">
<Button onClick={handleDownload}>
<Text size="B400">Download</Text>
</Button>
<Button onClick={handleCopy} fill="Soft">
<Text size="B400">Copy</Text>
</Button>
</Box>
</Box>
);

View file

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

View file

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

View file

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

View file

@ -13,13 +13,15 @@ import {
color,
} from 'folds';
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 { SettingTile } from './setting-tile';
import { SecretStorageKeyContent } from '../../types/matrix/accountData';
import { SecretStorageRecoveryKey, SecretStorageRecoveryPassphrase } from './SecretStorage';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { storePrivateKey } from '../../client/secretStorageKeys';
import { useClient } from '../hooks/useClient';
import { getCrossSigningPrivateKeys, useCrossSigningSecretData } from '../hooks/useCrossSigning';
import { AccountDataType } from '../../types/matrix/accountData';
export enum ManualVerificationMethod {
RecoveryPassphrase = 'passphrase',
@ -112,7 +114,7 @@ export function ManualVerificationMethodSwitcher({
type ManualVerificationTileProps = {
secretStorageKeyId: string;
secretStorageKeyContent: SecretStorageKeyContent;
secretStorageKeyContent: AesHmacSha2KeyDescription;
options?: ReactNode;
};
export function ManualVerificationTile({
@ -120,7 +122,8 @@ export function ManualVerificationTile({
secretStorageKeyContent,
options,
}: ManualVerificationTileProps) {
const mx = useMatrixClient();
const ct = useClient();
const csSecretData = useCrossSigningSecretData(secretStorageKeyId);
const hasPassphrase = !!secretStorageKeyContent.passphrase;
const [method, setMethod] = useState(
@ -130,25 +133,53 @@ export function ManualVerificationTile({
);
const verifyAndRestoreBackup = useCallback(
async (recoveryKey: Uint8Array) => {
const crypto = mx.getCrypto();
if (!crypto) {
throw new Error('Unexpected Error! Crypto object not found.');
async (recoveryKey: Uint8Array<ArrayBuffer>) => {
const status = await ct.machine.crossSigning.getStatus();
const machineHasKeys = status.master && status.self && status.user;
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({});
await crypto.bootstrapSecretStorage({});
// device.verify uploads signatures to mark device as verifed
// 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]>(
verifyAndRestoreBackup
);
const [verifyState, handleDecodedRecoveryKey] = useAsyncCallback<
void,
Error,
[Uint8Array<ArrayBuffer>]
>(verifyAndRestoreBackup);
const verifying = verifyState.status === AsyncStatus.Loading;
return (
@ -174,7 +205,7 @@ export function ManualVerificationTile({
{method === ManualVerificationMethod.RecoveryKey && (
<SecretStorageRecoveryKey
processing={verifying}
keyContent={secretStorageKeyContent}
keyDescription={secretStorageKeyContent}
onDecodedRecoveryKey={handleDecodedRecoveryKey}
/>
)}
@ -182,8 +213,8 @@ export function ManualVerificationTile({
secretStorageKeyContent.passphrase && (
<SecretStorageRecoveryPassphrase
processing={verifying}
keyContent={secretStorageKeyContent}
passphraseContent={secretStorageKeyContent.passphrase}
keyDescription={secretStorageKeyContent}
passphraseInfo={secretStorageKeyContent.passphrase}
onDecodedRecoveryKey={handleDecodedRecoveryKey}
/>
)}

View file

@ -1,15 +1,25 @@
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 { useMatrixClient } from '../hooks/useMatrixClient';
import { MediaConfig } from '../hooks/useMediaConfig';
import { useClient } from '../hooks/useClient';
type MediaConfigLoaderProps = {
children: (mediaConfig: MediaConfig | undefined) => ReactNode;
};
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(() => {
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 { MsgType } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { config } from 'folds';
import {
AudioContent,
DownloadFile,
FileContent,
ImageContent,
EmptyContent,
MAudio,
MBadEncrypted,
MEmote,
MFile,
MImage,
MLocation,
MNotice,
MSticker,
MText,
MVideo,
ReadPdfFile,
ReadTextFile,
RenderBody,
ThumbnailContent,
UnsupportedContent,
VideoContent,
} from './message';
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
import { Image, MediaControl, Video } from './media';
import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to';
import { IImageContent } from '../../types/matrix/common';
import { MsgType } from '../../types/matrix/common';
import { rContent } from '../../client/reader';
import { MessageEvent } from '../../types/matrix/room';
type RenderMessageContentProps = {
displayName: string;
msgType: string;
eventType: string;
ts: number;
edited?: boolean;
getContent: <T>() => T;
content: object;
mediaAutoLoad?: boolean;
urlPreview?: boolean;
highlightRegex?: RegExp;
@ -47,10 +34,10 @@ type RenderMessageContentProps = {
};
export function RenderMessageContent({
displayName,
msgType,
eventType,
ts,
edited,
getContent,
content,
mediaAutoLoad,
urlPreview,
highlightRegex,
@ -58,90 +45,53 @@ export function RenderMessageContent({
linkifyOpts,
outlineAttachment,
}: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => {
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
if (filteredUrls.length === 0) return undefined;
if (eventType === MessageEvent.Sticker) {
return (
<UrlPreviewHolder>
{filteredUrls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={ts} />
))}
</UrlPreviewHolder>
<MSticker
content={content}
mediaAutoLoad={mediaAutoLoad}
outlineAttachment={outlineAttachment}
/>
);
};
}
if (eventType !== MessageEvent.RoomMessage) {
return <UnsupportedContent eventType={eventType} />;
}
const renderCaption = () => {
const content: IImageContent = getContent();
if (content.filename && content.filename !== content.body) {
const filename = rContent.filename(content);
const body = rContent.body(content);
if (filename && filename !== body && body) {
return (
<MText
style={{ marginTop: config.space.S200 }}
style={{ marginTop: config.space.S100 }}
edited={edited}
content={content}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
urlPreview={urlPreview}
ts={ts}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}
return null;
};
const renderFile = () => (
<>
<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()}
</>
);
const msgType = rContent.msgtype(content);
if (msgType === MsgType.Text) {
return (
<MText
edited={edited}
content={getContent()}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
content={content}
urlPreview={urlPreview}
ts={ts}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}
@ -151,16 +101,12 @@ export function RenderMessageContent({
<MEmote
displayName={displayName}
edited={edited}
content={getContent()}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
content={content}
urlPreview={urlPreview}
ts={ts}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}
@ -169,16 +115,12 @@ export function RenderMessageContent({
return (
<MNotice
edited={edited}
content={getContent()}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
content={content}
urlPreview={urlPreview}
ts={ts}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}
@ -186,18 +128,7 @@ export function RenderMessageContent({
if (msgType === MsgType.Image) {
return (
<>
<MImage
content={getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
outlined={outlineAttachment}
/>
<MImage content={content} mediaAutoLoad={mediaAutoLoad} outlined={outlineAttachment} />
{renderCaption()}
</>
);
@ -206,31 +137,7 @@ export function RenderMessageContent({
if (msgType === MsgType.Video) {
return (
<>
<MVideo
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}
/>
<MVideo content={content} mediaAutoLoad={mediaAutoLoad} outlined={outlineAttachment} />
{renderCaption()}
</>
);
@ -239,30 +146,38 @@ export function RenderMessageContent({
if (msgType === MsgType.Audio) {
return (
<>
<MAudio
content={getContent()}
renderAsFile={renderFile}
renderAudioContent={(props) => (
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
)}
outlined={outlineAttachment}
/>
<MAudio content={content} outlined={outlineAttachment} />
{renderCaption()}
</>
);
}
if (msgType === MsgType.File) {
return renderFile();
return (
<>
<MFile content={content} outlined={outlineAttachment} />
{renderCaption()}
</>
);
}
if (msgType === MsgType.Location) {
return <MLocation content={getContent()} />;
return <MLocation content={content} />;
}
if (msgType === 'm.bad.encrypted') {
return <MBadEncrypted />;
if (Object.keys(content).length === 0) {
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 { MatrixClient, Room } from 'matrix-js-sdk';
import { ReactElement, useCallback, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { LocalRoomSummary, useLocalRoomSummary } from '../hooks/useLocalRoomSummary';
import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request';
import { getRoomSummary, GetRoomSummaryOK } from 'neurone/lib/api/matrix/client/v1/roomSummary';
import { getHierarchy, GetHierarchyOK } from 'neurone/lib/api/matrix/client/v1/rooms';
import { SpaceHierarchyRoomChunk } from 'neurone/lib/api/types';
import { AsyncState, AsyncStatus } from '../hooks/useAsyncCallback';
export type IRoomSummary = Awaited<ReturnType<MatrixClient['getRoomSummary']>>;
import { useClient } from '../hooks/useClient';
import { useSpecVersionSupport } from '../hooks/useSpecVersions';
import { LocalRoomSummary, useLocalRoomSummary } from '../hooks/useLocalRoomSummary';
type RoomSummaryLoaderProps = {
roomIdOrAlias: string;
children: (roomSummary?: IRoomSummary) => ReactNode;
children: (roomSummary?: GetRoomSummaryOK) => ReactElement;
};
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({
queryKey: [roomIdOrAlias, `summary`],
@ -27,27 +42,42 @@ export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoader
}
export function LocalRoomSummaryLoader({
room,
roomId,
children,
}: {
room: Room;
children: (roomSummary: LocalRoomSummary) => ReactNode;
roomId: string;
children: (summary: LocalRoomSummary) => ReactElement;
}) {
const summary = useLocalRoomSummary(room);
const summary = useLocalRoomSummary(roomId);
return children(summary);
}
export function HierarchyRoomSummaryLoader({
roomId,
children,
}: {
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 { data, error } = useQuery({
@ -62,7 +92,7 @@ export function HierarchyRoomSummaryLoader({
},
});
let state: AsyncState<IHierarchyRoom, Error> = {
let state: AsyncState<SpaceHierarchyRoomChunk, Error> = {
status: AsyncStatus.Loading,
};
if (error) {
@ -78,7 +108,7 @@ export function HierarchyRoomSummaryLoader({
};
}
const summary = data?.rooms[0] ?? undefined;
const summary = data?.rooms[0];
if (summary) {
state = {
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 { Box, Text, Button, Spinner, color } from 'folds';
import { decodeRecoveryKey, deriveRecoveryKeyFromPassphrase } from 'matrix-js-sdk/lib/crypto-api';
import { PasswordInput } from './password-input';
import {
SecretStorageKeyContent,
SecretStoragePassphraseContent,
} from '../../types/matrix/accountData';
AesHmacSha2,
AesHmacSha2KeyDescription,
Passphrase,
PassphraseInfo,
RecoveryKey,
} from 'neurone/lib/crypto/ssss';
import { PasswordInput } from './password-input';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAlive } from '../hooks/useAlive';
type SecretStorageRecoveryPassphraseProps = {
processing?: boolean;
keyContent: SecretStorageKeyContent;
passphraseContent: SecretStoragePassphraseContent;
onDecodedRecoveryKey: (recoveryKey: Uint8Array) => void;
keyDescription: AesHmacSha2KeyDescription;
passphraseInfo: PassphraseInfo;
onDecodedRecoveryKey: (recoveryKey: Uint8Array<ArrayBuffer>) => void;
};
export function SecretStorageRecoveryPassphrase({
processing,
keyContent,
passphraseContent,
keyDescription,
passphraseInfo,
onDecodedRecoveryKey,
}: SecretStorageRecoveryPassphraseProps) {
const mx = useMatrixClient();
const alive = useAlive();
const [driveKeyState, submitPassphrase] = useAsyncCallback<
Uint8Array,
Uint8Array<ArrayBuffer>,
Error,
Parameters<typeof deriveRecoveryKeyFromPassphrase>
[passphrase: string]
>(
useCallback(
async (passphrase, salt, iterations, bits) => {
const decodedRecoveryKey = await deriveRecoveryKeyFromPassphrase(
passphrase,
salt,
iterations,
bits
async (passphrase) => {
const decodedRecoveryKey = await Passphrase.derive(passphrase, passphraseInfo);
const { mac: regeneratedMac } = await AesHmacSha2.driveKeyMetaData(
decodedRecoveryKey,
keyDescription.iv
);
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
if (!match) {
if (regeneratedMac !== keyDescription.mac) {
throw new Error('Invalid recovery passphrase.');
}
return decodedRecoveryKey;
},
[mx, keyContent]
[keyDescription, passphraseInfo]
)
);
@ -64,8 +62,7 @@ export function SecretStorageRecoveryPassphrase({
const recoveryPassphrase = recoveryPassphraseInput.value.trim();
if (!recoveryPassphrase) return;
const { salt, iterations, bits } = passphraseContent;
submitPassphrase(recoveryPassphrase, salt, iterations, bits).then((decodedRecoveryKey) => {
submitPassphrase(recoveryPassphrase).then((decodedRecoveryKey) => {
if (alive()) {
recoveryPassphraseInput.value = '';
onDecodedRecoveryKey(decodedRecoveryKey);
@ -115,31 +112,37 @@ export function SecretStorageRecoveryPassphrase({
type SecretStorageRecoveryKeyProps = {
processing?: boolean;
keyContent: SecretStorageKeyContent;
onDecodedRecoveryKey: (recoveryKey: Uint8Array) => void;
keyDescription: AesHmacSha2KeyDescription;
onDecodedRecoveryKey: (recoveryKey: Uint8Array<ArrayBuffer>) => void;
};
export function SecretStorageRecoveryKey({
processing,
keyContent,
keyDescription,
onDecodedRecoveryKey,
}: SecretStorageRecoveryKeyProps) {
const mx = useMatrixClient();
const alive = useAlive();
const [driveKeyState, submitRecoveryKey] = useAsyncCallback<Uint8Array, Error, [string]>(
const [driveKeyState, submitRecoveryKey] = useAsyncCallback<
Uint8Array<ArrayBuffer>,
Error,
[string]
>(
useCallback(
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.');
}
return decodedRecoveryKey;
},
[mx, keyContent]
[keyDescription]
)
);

View file

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

View file

@ -2,6 +2,29 @@ import { ReactNode, useCallback, useEffect, useState } from 'react';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
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 = {
baseUrl: string;
fallback?: () => ReactNode;
@ -15,8 +38,15 @@ export function SpecVersionsLoader({
children,
}: SpecVersionsLoaderProps) {
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 ignoreCallback = useCallback(() => setIgnoreError(true), []);
@ -25,19 +55,13 @@ export function SpecVersionsLoader({
load();
}, [load]);
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
if (!data && (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading)) {
return fallback?.();
}
if (!ignoreError && state.status === AsyncStatus.Error) {
if (!data && !ignoreError && state.status === AsyncStatus.Error) {
return error?.(state.error, load, ignoreCallback);
}
return children(
state.status === AsyncStatus.Success
? state.data
: {
versions: [],
}
);
return children(state.status === AsyncStatus.Success ? state.data : data ?? FALLBACK_DATA);
}

View file

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

View file

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

View file

@ -15,7 +15,7 @@ export function AuthFooter() {
target="_blank"
rel="noreferrer"
>
v4.12.8
v4.12.7
</Text>
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
Twitter

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

View file

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

View file

@ -1,18 +1,19 @@
import {
ICreateRoomOpts,
ICreateRoomStateEvent,
JoinRule,
MatrixClient,
RestrictedAllowType,
Room,
} from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { RoomType, StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers';
CreateRoomStateEvent,
postCreateRoom,
PostCreateRoomBody,
PostCreateRoomOK,
} from 'neurone/lib/api/matrix/client/v3/createRoom';
import { MatrixResponseResult, parseMatrixResp } from 'neurone/lib/matrix-request';
import { putStateEvent } from 'neurone/lib/api/matrix/client/v3/rooms';
import { JoinRulesContent, RoomType, StateEvent, StateEventType } from '../../../types/matrix/room';
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';
export const createRoomCreationContent = (
const createRoomCreationContent = (
type: RoomType | undefined,
allowFederation: boolean,
additionalCreators: string[] | undefined
@ -31,28 +32,28 @@ export const createRoomCreationContent = (
return content;
};
export const createRoomJoinRulesState = (
const createRoomJoinRulesState = (
access: CreateRoomAccess,
parent: Room | undefined,
parentId: string | undefined,
knock: boolean
) => {
let content: RoomJoinRulesEventContent = {
join_rule: knock ? JoinRule.Knock : JoinRule.Invite,
let content: JoinRulesContent = {
join_rule: knock ? 'knock' : 'invite',
};
if (access === CreateRoomAccess.Public) {
content = {
join_rule: JoinRule.Public,
join_rule: 'public',
};
}
if (access === CreateRoomAccess.Restricted && parent) {
if (access === CreateRoomAccess.Restricted && parentId) {
content = {
join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted,
join_rule: knock ? 'knock_restricted' : 'restricted',
allow: [
{
type: RestrictedAllowType.RoomMembership,
room_id: parent.roomId,
type: 'm.room_membership',
room_id: parentId,
},
],
};
@ -65,12 +66,12 @@ export const createRoomJoinRulesState = (
};
};
export const createRoomParentState = (parent: Room) => ({
const createRoomParentState = (parentId: string, via: string[]) => ({
type: StateEvent.SpaceParent,
state_key: parent.roomId,
state_key: parentId,
content: {
canonical: true,
via: getViaServers(parent),
via,
},
});
@ -101,7 +102,7 @@ export const createVoiceRoomPowerLevelsOverride = () => ({
export type CreateRoomData = {
version: string;
type?: RoomType;
parent?: Room;
parentId?: string;
access: CreateRoomAccess;
name: string;
topic?: string;
@ -111,24 +112,33 @@ export type CreateRoomData = {
allowFederation: boolean;
additionalCreators?: string[];
};
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
const initialState: ICreateRoomStateEvent[] = [];
export const createRoom = async (
ct: ClientContext,
data: CreateRoomData
): Promise<MatrixResponseResult<string>> => {
const initialState: CreateRoomStateEvent[] = [];
if (data.encryption) {
initialState.push(createRoomEncryptionState());
}
if (data.parent) {
initialState.push(createRoomParentState(data.parent));
if (data.parentId) {
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) {
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,
name: data.name,
topic: data.topic,
@ -144,23 +154,34 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
};
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) {
await mx.sendStateEvent(
data.parent.roomId,
StateEvent.SpaceChild as any,
{
auto_join: false,
suggested: false,
via: [getMxIdServer(mx.getUserId() ?? '') ?? ''],
},
result.room_id
const [err, result] = await parseMatrixResp<PostCreateRoomOK>(response);
if (err) {
return [err, undefined];
}
if (data.parentId) {
const resp = await ct.requestAuthJSON(
putStateEvent({
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,
forwardRef,
useCallback,
useEffect,
useState,
} from 'react';
import { Box, Scroll, Text } from 'folds';
import { Descendant, Editor, createEditor } from 'slate';
import { Descendant, Editor, Transforms, createEditor } from 'slate';
import {
Slate,
Editable,
@ -20,17 +21,9 @@ import {
import { withHistory } from 'slate-history';
import { BlockType } from './types';
import { RenderElement, RenderLeaf } from './Elements';
import { CustomElement } from './slate';
import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard';
const initialValue: CustomElement[] = [
{
type: BlockType.Paragraph,
children: [{ text: '' }],
},
];
const withInline = (editor: Editor): Editor => {
const { isInline } = editor;
@ -59,13 +52,13 @@ export const useEditor = (): Editor => {
export type EditorChangeHandler = (value: Descendant[]) => void;
type CustomEditorProps = {
editableName?: string;
top?: ReactNode;
bottom?: ReactNode;
before?: ReactNode;
after?: ReactNode;
maxHeight?: string;
editor: Editor;
initialValue?: Descendant[];
placeholder?: string;
onKeyDown?: KeyboardEventHandler;
onKeyUp?: KeyboardEventHandler;
@ -75,13 +68,13 @@ type CustomEditorProps = {
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
(
{
editableName,
top,
bottom,
before,
after,
maxHeight = '50vh',
editor,
initialValue,
placeholder,
onKeyDown,
onKeyUp,
@ -90,6 +83,19 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
},
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(
(props: RenderElementProps) => <RenderElement {...props} />,
[]
@ -120,7 +126,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
return (
<div className={css.Editor} ref={ref}>
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
<Slate editor={editor} initialValue={initialValue ?? defaultValue} onChange={onChange}>
{top}
<Box alignItems="Start">
{before && (
@ -137,7 +143,6 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
hideTrack
>
<Editable
data-editable-name={editableName}
className={css.EditorTextarea}
placeholder={placeholder}
renderPlaceholder={renderPlaceholder}

View file

@ -10,11 +10,11 @@ import {
import * as css from '../../styles/CustomHtml.css';
import { CommandElement, EmoticonElement, LinkElement, MentionElement } from './slate';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getBeginCommand } from './utils';
import { BlockType } from './types';
import { mxcUrlToHttp } from '../../utils/matrix';
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:
// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405
@ -77,7 +77,7 @@ function RenderEmoticonElement({
element,
children,
}: { element: EmoticonElement } & RenderElementProps) {
const mx = useMatrixClient();
const ct = useClient();
const useAuthentication = useMediaAuthentication();
const selected = useSelected();
const focused = useFocused();
@ -93,7 +93,7 @@ function RenderEmoticonElement({
{element.key.startsWith('mxc://') ? (
<img
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}
/>
) : (

View file

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

View file

@ -1,11 +1,8 @@
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo } from 'react';
import { Editor } from 'slate';
import { Box, MenuItem, Text, toRem } from 'folds';
import { Room } from 'matrix-js-sdk';
import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard';
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
@ -13,17 +10,18 @@ import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
import { IEmoji, emojis } from '../../../plugins/emoji';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ImageUsage, PackImageReader } from '../../../plugins/custom-emoji';
import { getEmoticonSearchStr } from '../../../plugins/utils';
import { mxcToHttp } from '../../../../client/mxcToHttp';
import { useClient } from '../../../hooks/useClient';
type EmoticonCompleteHandler = (key: string, shortcode: string) => void;
type EmoticonSearchItem = PackImageReader | IEmoji;
type EmoticonAutocompleteProps = {
imagePackRooms: Room[];
imagePackRooms: string[];
editor: Editor;
query: AutocompleteQuery<string>;
requestClose: () => void;
@ -41,11 +39,11 @@ export function EmoticonAutocomplete({
query,
requestClose,
}: EmoticonAutocompleteProps) {
const mx = useMatrixClient();
const ct = useClient();
const useAuthentication = useMediaAuthentication();
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
const recentEmoji = useRecentEmoji(mx, 20);
const [imagePacks] = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
const recentEmojiResult = useRecentEmoji(20);
const searchList = useMemo(() => {
const list: Array<EmoticonSearchItem> = [];
@ -60,7 +58,9 @@ export function EmoticonAutocomplete({
getEmoticonSearchStr,
SEARCH_OPTIONS
);
const autoCompleteEmoticon = result ? result.items.slice(0, 20) : recentEmoji;
const autoCompleteEmoticon: EmoticonSearchItem[] = result
? result.items.slice(0, 20)
: recentEmojiResult.value ?? [];
useEffect(() => {
if (query.text) search(query.text);
@ -88,7 +88,6 @@ export function EmoticonAutocomplete({
{autoCompleteEmoticon.map((emoticon) => {
const isCustomEmoji = 'url' in emoticon;
const key = isCustomEmoji ? emoticon.url : emoticon.unicode;
const customEmojiUrl = mxcUrlToHttp(mx, key, useAuthentication);
return (
<MenuItem
@ -100,11 +99,11 @@ export function EmoticonAutocomplete({
}
onClick={() => handleAutocomplete(key, emoticon.shortcode)}
before={
isCustomEmoji && customEmojiUrl ? (
isCustomEmoji ? (
<Box
shrink="No"
as="img"
src={customEmojiUrl}
src={mxcToHttp(ct.session.baseUrl, key, useAuthentication).url() || key}
alt={emoticon.shortcode}
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 { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { JoinRule, MatrixClient } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { getDirectRoomAvatarUrl } from '../../../utils/room';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu';
import { getMxIdServer, isRoomAlias } from '../../../utils/matrix';
import { getMxIdServer, isRoomAlias, isRoomId } from '../../../utils/matrix';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { mDirectAtom } from '../../../state/mDirectList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { factoryRoomIdByActivity } from '../../../utils/sort';
import { factoryRoomIdByAtoZ } from '../../../utils/sort';
import { RoomAvatar, RoomIcon } from '../../room-avatar';
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;
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
const roomAliasFromQueryText = (myUserId: string, text: string) =>
isRoomAlias(`#${text}`)
? `#${text}`
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(myUserId)}`;
function UnknownRoomMentionItem({
query,
@ -33,8 +37,8 @@ function UnknownRoomMentionItem({
query: AutocompleteQuery<string>;
handleAutocomplete: MentionAutoCompleteHandler;
}) {
const mx = useMatrixClient();
const roomAlias: string = roomAliasFromQueryText(mx, query.text);
const ct = useClient();
const roomAlias: string = roomAliasFromQueryText(ct.session.userId, query.text);
const handleSelect = () => handleAutocomplete(roomAlias, roomAlias);
@ -76,22 +80,31 @@ export function RoomMentionAutocomplete({
query,
requestClose,
}: RoomMentionAutocompleteProps) {
const mx = useMatrixClient();
const ct = useClient();
const useAuthentication = useMediaAuthentication();
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(
allRooms,
useCallback(
(rId) => {
const r = mx.getRoom(rId);
if (!r) return 'Unknown Room';
const alias = r.getCanonicalAlias();
if (alias) return [r.name, alias];
return r.name;
const name = getRoomName(rId);
if (!name) return 'Unknown Room';
const alias = ct.roomToAlias.read.get(rId);
if (alias) return [name, alias];
return name;
},
[mx]
[ct, getRoomName]
),
SEARCH_OPTIONS
);
@ -104,12 +117,14 @@ export function RoomMentionAutocomplete({
}, [query.text, search, resetSearch]);
const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => {
const mentionRoom = mx.getRoom(roomAliasOrId);
const viaServers = mentionRoom ? getViaServers(mentionRoom) : undefined;
const creators = isRoomId(roomAliasOrId)
? ct.roomToCreators.read.get(roomAliasOrId)
: undefined;
const viaServers = creators ? getViaServers(creators) : undefined;
const mentionEl = createMentionElement(
roomAliasOrId,
name.startsWith('#') ? name : `#${name}`,
roomId === roomAliasOrId || mx.getRoom(roomId)?.getCanonicalAlias() === roomAliasOrId,
roomId === roomAliasOrId || ct.roomToAlias.read.get(roomId) === roomAliasOrId,
undefined,
viaServers
);
@ -121,14 +136,13 @@ export function RoomMentionAutocomplete({
useKeyDown(window, (evt: KeyboardEvent) => {
onTabPress(evt, () => {
if (autoCompleteRoomIds.length === 0) {
const alias = roomAliasFromQueryText(mx, query.text);
const alias = roomAliasFromQueryText(ct.session.userId, query.text);
handleAutocomplete(alias, alias);
return;
}
const rId = autoCompleteRoomIds[0];
const r = mx.getRoom(rId);
const name = r?.name ?? rId;
handleAutocomplete(r?.getCanonicalAlias() ?? rId, name);
const name = getRoomName(rId) ?? 'Unknown';
handleAutocomplete(ct.roomToAlias.read.get(rId) ?? rId, name);
});
});
@ -138,11 +152,20 @@ export function RoomMentionAutocomplete({
<UnknownRoomMentionItem query={query} handleAutocomplete={handleAutocomplete} />
) : (
autoCompleteRoomIds.map((rId) => {
const room = mx.getRoom(rId);
if (!room) return null;
const dm = mDirects.has(room.roomId);
const dm = mDirects.has(rId);
const isSpace = ct.spaceRooms.read.has(rId);
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 (
<MenuItem
@ -155,33 +178,37 @@ export function RoomMentionAutocomplete({
onClick={handleSelect}
after={
<Text size="T200" priority="300" truncate>
{room.getCanonicalAlias() ?? ''}
{alias}
</Text>
}
before={
<Avatar size="200">
{dm ? (
<Avatar size="200" radii={isSpace ? '300' : '400'}>
{showAvatar || dm || isSpace ? (
<RoomAvatar
roomId={room.roomId}
src={getDirectRoomAvatarUrl(mx, room)}
alt={room.name}
roomId={rId}
src={avatar}
alt={name}
renderFallback={() => (
<RoomIcon
size="50"
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
roomType={room.getType()}
joinRule={ct.roomToJoinRule.read.get(rId)}
roomType={ct.roomToType.read.get(rId)}
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>
}
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
{room.name}
{name}
</Text>
</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 { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu';
import { useRoomMembers } from '../../../hooks/useRoomMembers';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
SearchItemStrGetter,
UseAsyncSearchOptions,
useAsyncSearch,
} from '../../../hooks/useAsyncSearch';
import { GetRoomMemberMembershipCallback, useRoomMembersAPI } from '../../../hooks/useRoomMembers';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
import { getMxIdServer, isUserId } from '../../../utils/matrix';
import { getMemberSearchStr } from '../../../utils/room';
import { UserAvatar } from '../../user-avatar';
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;
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
const userIdFromQueryText = (myUserId: string, text: string) =>
isUserId(`@${text}`)
? `@${text}`
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(myUserId)}`;
function UnknownMentionItem({
userId,
@ -62,16 +59,18 @@ function UnknownMentionItem({
}
type UserMentionAutocompleteProps = {
room: Room;
roomId: string;
editor: Editor;
query: AutocompleteQuery<string>;
requestClose: () => void;
};
const withAllowedMembership = (member: RoomMember): boolean =>
member.membership === Membership.Join ||
member.membership === Membership.Invite ||
member.membership === Membership.Knock;
const withAllowedMembership =
(getMembership: GetRoomMemberMembershipCallback) =>
(member: JoinRoomMemberObject): boolean =>
getMembership(member.userId) === 'join' ||
getMembership(member.userId) === 'invite' ||
getMembership(member.userId) === 'knock';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
@ -80,25 +79,28 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = {
},
};
const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
getMemberSearchStr(m, query, mxIdToName);
const EMPTY_MEMBER_LIST: JoinRoomMemberObject[] = [];
export function UserMentionAutocomplete({
room,
roomId,
editor,
query,
requestClose,
}: UserMentionAutocompleteProps) {
const mx = useMatrixClient();
const ct = useClient();
const useAuthentication = useMediaAuthentication();
const roomId: string = room.roomId!;
const roomAliasOrId = room.getCanonicalAlias() || roomId;
const members = useRoomMembers(mx, roomId);
const roomAliasOrId = ct.roomToAlias.read.get(roomId) || roomId;
const membersAPI = useRoomMembersAPI();
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(
withAllowedMembership
withAllowedMembership(membersAPI.getMembership)
);
useEffect(() => {
@ -110,7 +112,7 @@ export function UserMentionAutocomplete({
const mentionEl = createMentionElement(
uId,
name.startsWith('@') ? name : `@${name}`,
mx.getUserId() === uId || roomAliasOrId === uId
uId === ct.session.userId || roomAliasOrId === uId
);
replaceWithElement(editor, query.range, mentionEl);
moveCursor(editor, true);
@ -124,18 +126,15 @@ export function UserMentionAutocomplete({
return;
}
if (autoCompleteMembers.length === 0) {
const userId = userIdFromQueryText(mx, query.text);
const userId = userIdFromQueryText(ct.session.userId, query.text);
handleAutocomplete(userId, userId);
return;
}
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 (
<AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
{query.text === 'room' && (
@ -147,15 +146,20 @@ export function UserMentionAutocomplete({
)}
{autoCompleteMembers.length === 0 ? (
<UnknownMentionItem
userId={userIdFromQueryText(mx, query.text)}
name={userIdFromQueryText(mx, query.text)}
userId={userIdFromQueryText(ct.session.userId, query.text)}
name={userIdFromQueryText(ct.session.userId, query.text)}
handleAutocomplete={handleAutocomplete}
/>
) : (
autoCompleteMembers.map((roomMember) => {
const avatarMxcUrl = roomMember.getMxcAvatarUrl();
const name = membersAPI.getName(roomMember.userId);
const avatarMxcUrl = membersAPI.getAvatar(roomMember.userId);
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;
return (
<MenuItem
@ -163,9 +167,9 @@ export function UserMentionAutocomplete({
as="button"
radii="300"
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={
<Text size="T200" priority="300" truncate>
{roomMember.userId}
@ -176,14 +180,18 @@ export function UserMentionAutocomplete({
<UserAvatar
userId={roomMember.userId}
src={avatarUrl ?? undefined}
alt={getName(roomMember)}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
alt={name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(name)}
</Text>
)}
/>
</Avatar>
}
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
{getName(roomMember)}
{name}
</Text>
</MenuItem>
);

View file

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

View file

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

View file

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

View file

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

View file

@ -19,7 +19,7 @@ import * as css from './style.css';
import { useFilePicker } from '../../hooks/useFilePicker';
import { CompactUploadCardRenderer } from '../upload-card';
import { UploadSuccess } from '../../state/upload';
import { getImageInfo, TUploadContent } from '../../utils/matrix';
import { getImageInfo, UploadContent } from '../../utils/matrix';
import { getImageFileUrl, loadImageElement, renameFile } from '../../utils/dom';
import { replaceSpaceWithDash, suffixRename } from '../../utils/common';
import { getFileNameWithoutExt } from '../../utils/mimeTypes';
@ -110,7 +110,7 @@ export const ImagePackContent = as<'div', ImagePackContentProps>(
[imagePack.meta]
);
const handleUploadRemove = useCallback((file: TUploadContent) => {
const handleUploadRemove = useCallback((file: UploadContent) => {
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 { PackAddress } from '../../plugins/custom-emoji';
import { Page, PageHeader, PageContent } from '../page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomImagePack } from './RoomImagePack';
import { UserImagePack } from './UserImagePack';
import { useClient } from '../../hooks/useClient';
type ImagePackViewProps = {
address: PackAddress | undefined;
requestClose: () => void;
};
export function ImagePackView({ address, requestClose }: ImagePackViewProps) {
const mx = useMatrixClient();
const room = address && mx.getRoom(address.roomId);
const ct = useClient();
return (
<Page>
@ -38,8 +37,8 @@ export function ImagePackView({ address, requestClose }: ImagePackViewProps) {
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
{room && address ? (
<RoomImagePack room={room} stateKey={address.stateKey} />
{address && ct.rooms.join.read.includes(address.roomId) ? (
<RoomImagePack roomId={address.roomId} stateKey={address.stateKey} />
) : (
<UserImagePack />
)}

View file

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

View file

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

View file

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

View file

@ -1,22 +1,32 @@
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 { ImagePack, PackContent } from '../../plugins/custom-emoji';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { AccountDataType } from '../../../types/matrix/accountData';
import { useUserImagePack } from '../../hooks/useImagePacks';
import { useClient } from '../../hooks/useClient';
export function UserImagePack() {
const mx = useMatrixClient();
const ct = useClient();
const defaultPack = useMemo(() => new ImagePack(mx.getUserId() ?? '', {}, undefined), [mx]);
const imagePack = useUserImagePack();
const defaultPack = useMemo(() => new ImagePack(ct.session.userId ?? '', {}, undefined), [ct]);
const imagePackResult = useUserImagePack();
const imagePack = imagePackResult.value ?? defaultPack;
const handleUpdate = useCallback(
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,
KeyboardEventHandler,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
@ -29,19 +28,19 @@ import {
Scroll,
MenuItem,
} from 'folds';
import { Room } from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey';
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 { useDirectUsers } from '../../hooks/useDirectUsers';
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix';
import { Membership } from '../../../types/matrix/room';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { BreakWord } from '../../styles/Text.css';
import { useAlive } from '../../hooks/useAlive';
import { useClient } from '../../hooks/useClient';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
@ -52,27 +51,19 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = {
const getUserIdString = (userId: string) => getMxIdLocalPart(userId) ?? userId;
type InviteUserProps = {
room: Room;
roomId: string;
requestClose: () => void;
};
export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
const mx = useMatrixClient();
export function InviteUserPrompt({ roomId, requestClose }: InviteUserProps) {
const ct = useClient();
const alive = useAlive();
const inputRef = useRef<HTMLInputElement>(null);
const directUsers = useDirectUsers();
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(
filteredUsers,
directUsers,
getUserIdString,
SEARCH_OPTIONS
);
@ -83,9 +74,19 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
const [inviteState, invite] = useAsyncCallback<void, Error, [string, string | undefined]>(
useCallback(
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 {
Dialog,
@ -16,10 +16,13 @@ import {
Button,
Spinner,
} from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { postLeave } from 'neurone/lib/api/matrix/client/v3/rooms';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
import { useClient } from '../../hooks/useClient';
import { deleteJoinRooms } from '../../../client/syncWriter/utils';
import { useAlive } from '../../hooks/useAlive';
type LeaveRoomPromptProps = {
roomId: string;
@ -27,24 +30,26 @@ type LeaveRoomPromptProps = {
onCancel: () => void;
};
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 () => {
mx.leave(roomId);
}, [mx, roomId])
const response = await ct.requestAuthJSON(postLeave({ pathParams: { roomId } }));
deleteJoinRooms(ct, [roomId]);
const [err] = await parseMatrixResp(response);
if (err) throw new Error(err.data.error ?? err.data.errcode);
}, [ct, roomId])
);
const handleLeave = () => {
leaveRoom();
leaveRoom().then(() => {
if (alive()) {
onDone();
}
});
};
useEffect(() => {
if (leaveState.status === AsyncStatus.Success) {
onDone();
}
}, [leaveState, onDone]);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<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 {
Dialog,
@ -16,10 +16,13 @@ import {
Button,
Spinner,
} from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { postLeave } from 'neurone/lib/api/matrix/client/v3/rooms';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
import { useClient } from '../../hooks/useClient';
import { deleteJoinRooms } from '../../../client/syncWriter/utils';
import { useAlive } from '../../hooks/useAlive';
type LeaveSpacePromptProps = {
roomId: string;
@ -27,24 +30,26 @@ type LeaveSpacePromptProps = {
onCancel: () => void;
};
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 () => {
mx.leave(roomId);
}, [mx, roomId])
const response = await ct.requestAuthJSON(postLeave({ pathParams: { roomId } }));
deleteJoinRooms(ct, [roomId]);
const [err] = await parseMatrixResp(response);
if (err) throw new Error(err.data.error ?? err.data.errcode);
}, [ct, roomId])
);
const handleLeave = () => {
leaveRoom();
leaveRoom().then(() => {
if (alive()) {
onDone();
}
});
};
useEffect(() => {
if (leaveState.status === AsyncStatus.Success) {
onDone();
}
}, [leaveState, onDone]);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>

View file

@ -1,48 +1,73 @@
import React, { ReactNode } from 'react';
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 * as css from './style.css';
const getName = (room: Room, member: RoomMember) =>
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
import { ClientContext } from '../../../client/context';
import { JoinRoomMemberObject } from '../../../client/storage/collections';
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 = {
mx: MatrixClient;
room: Room;
member: RoomMember;
ct: ClientContext;
member: JoinRoomMemberObject;
getName: GetRoomMemberNameCallback;
getAvatar: GetRoomMemberAvatarCallback;
useAuthentication: boolean;
after?: ReactNode;
};
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 avatarMxcUrl = member.getMxcAvatarUrl();
const name = getName(member.userId);
const avatarMxcUrl = getAvatar(member.userId);
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;
return (
<AsMemberTile className={css.MemberTile} {...props} ref={ref}>
<Avatar size="300" radii="400">
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="300" src={Icons.User} filled />}
/>
</Avatar>
<AvatarPresence
variant="Surface"
badge={presence && <PresenceBadge size="300" presence={presence.presence} />}
>
<Avatar size="300" radii="400">
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="300" src={Icons.User} filled />}
/>
</Avatar>
</AvatarPresence>
<Box grow="Yes" as="span" direction="Column">
<Text as="span" size="T300" truncate>
<b>{name}</b>
</Text>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="100">
<Text as="span" size="T200" priority="300" truncate>
{username}
@{username}
</Text>
</Box>
</Box>

View file

@ -3,15 +3,11 @@ import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver';
import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../utils/matrix';
import { decryptFile, downloadEncryptedMedia, downloadMedia } from '../../utils/matrix';
import { useClient } from '../../hooks/useClient';
import { mxcToHttp } from '../../../client/mxcToHttp';
const badgeStyles = { maxWidth: toRem(100) };
@ -19,24 +15,25 @@ type FileDownloadButtonProps = {
filename: string;
url: string;
mimeType: string;
encInfo?: EncryptedAttachmentInfo;
encInfo?: object;
};
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
const mx = useMatrixClient();
const ct = useClient();
const useAuthentication = useMediaAuthentication();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
)
: await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, filename);
return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, filename])
}, [ct, url, useAuthentication, mimeType, encInfo, filename])
);
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 { 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 { trimReplyFromBody } from '../../utils/room';
import { MessageTextBody } from './layout';
import {
AudioContent,
FileContent,
ImageContent,
MessageBadEncryptedContent,
MessageBrokenContent,
MessageDeletedContent,
MessageEditedContent,
UnsupportedMessageEventType,
MessageUnsupportedContent,
VideoContent,
MessageEmptyContent,
} 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 { parseGeoUri, scaleYDimension } from '../../utils/common';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
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 (
<Text>
<Text size={size}>
<MessageBadEncryptedContent />
</Text>
);
}
type RedactedContentProps = {
type RedactedContentProps = TextProps & {
reason?: string;
};
export function RedactedContent({ reason }: RedactedContentProps) {
export function RedactedContent({ size, reason }: RedactedContentProps) {
return (
<Text>
<Text size={size}>
<MessageDeletedContent reason={reason} />
</Text>
);
}
export function UnsupportedContent() {
export function UnsupportedContent({ size, eventType }: TextProps & { eventType?: string }) {
return (
<Text>
<MessageUnsupportedContent />
<Text size={size}>
{eventType ? (
<UnsupportedMessageEventType eventType={eventType} />
) : (
<MessageUnsupportedContent />
)}
</Text>
);
}
export function BrokenContent() {
export function BrokenContent({ size }: TextProps) {
return (
<Text>
<Text size={size}>
<MessageBrokenContent />
</Text>
);
}
type RenderBodyProps = {
export function EmptyContent({ size }: TextProps) {
return (
<Text size={size}>
<MessageEmptyContent />
</Text>
);
}
type UrlPreviewProps = {
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 = {
edited?: boolean;
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
content: object;
urlPreview?: boolean;
ts: number;
style?: CSSProperties;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function MText({ edited, content, renderBody, renderUrlsPreview, style }: MTextProps) {
const { body, formatted_body: customBody } = content;
export function MText({
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 />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
return (
<>
@ -91,13 +134,16 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
style={style}
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
<RenderBody
body={trimmedBody}
customBody={customBody}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{edited && <MessageEditedContent />}
</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 = {
displayName: string;
edited?: boolean;
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
content: object;
urlPreview?: boolean;
ts: number;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function MEmote({
displayName,
edited,
content,
renderBody,
renderUrlsPreview,
urlPreview,
ts,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
}: MEmoteProps) {
const { body, formatted_body: customBody } = content;
const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
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 (
<>
@ -131,30 +182,43 @@ export function MEmote({
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
<b>{`${displayName} `}</b>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
<RenderBody
body={trimmedBody}
customBody={customBody}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{edited && <MessageEditedContent />}
</MessageTextBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
{urlPreview && <UrlPreview body={trimmedBody} ts={ts} />}
</>
);
}
type MNoticeProps = {
edited?: boolean;
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
content: object;
urlPreview?: boolean;
ts: number;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNoticeProps) {
const { body, formatted_body: customBody } = content;
export function MNotice({
edited,
content,
urlPreview,
ts,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
}: MNoticeProps) {
const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
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 (
<>
@ -163,39 +227,34 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
<RenderBody
body={trimmedBody}
customBody={customBody}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{edited && <MessageEditedContent />}
</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 = {
content: IImageContent;
renderImageContent: (props: RenderImageContentProps) => ReactNode;
content: object;
mediaAutoLoad?: boolean;
outlined?: boolean;
};
export function MImage({ content, renderImageContent, outlined }: MImageProps) {
const imgInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
export function MImage({ content, mediaAutoLoad, outlined }: MImageProps) {
const info = rContent.info(content);
const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
if (typeof mxcUrl !== 'string') {
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 (
<Attachment outlined={outlined}>
@ -204,178 +263,154 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
height: toRem(height < 48 ? 48 : height),
}}
>
{renderImageContent({
body: content.body || 'Image',
info: imgInfo,
mimeType: imgInfo?.mimetype,
url: mxcUrl,
encInfo: content.file,
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})}
<ImageContent
body={rContent.body(content) ?? 'Image'}
info={info}
mimeType={rInfo.mimetype(info)}
url={mxcUrl}
encInfo={rContent.file(content)}
autoPlay={mediaAutoLoad}
markedAsSpoiler={rContent.spoiler(content)}
spoilerReason={rContent.spoilerReason(content)}
/>
</AttachmentBox>
</Attachment>
);
}
type RenderVideoContentProps = {
body: string;
info: IVideoInfo & IThumbnailContent;
mimeType: string;
url: string;
encInfo?: IEncryptedFile;
markedAsSpoiler?: boolean;
spoilerReason?: string;
};
type MVideoProps = {
content: IVideoContent;
renderAsFile: () => ReactNode;
renderVideoContent: (props: RenderVideoContentProps) => ReactNode;
type MFileProps = {
content: object;
outlined?: boolean;
};
export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: MVideoProps) {
const videoInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
const safeMimeType = getBlobSafeMimeType(videoInfo?.mimetype ?? '');
export function MFile({ content, outlined }: MFileProps) {
const fileInfo = rContent.info(content);
const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
if (!videoInfo || !safeMimeType.startsWith('video') || typeof mxcUrl !== 'string') {
if (mxcUrl) {
return renderAsFile();
}
if (typeof mxcUrl !== 'string') {
return <BrokenContent />;
}
const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
const filename = content.filename ?? content.body ?? 'Video';
const body = rContent.filename(content) ?? rContent.body(content) ?? 'Unnamed File';
const mimeType = rInfo.mimetype(fileInfo) ?? FALLBACK_MIMETYPE;
return (
<Attachment outlined={outlined}>
<AttachmentHeader>
<FileHeader
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}
/>
}
/>
<FileHeader body={body} mimeType={mimeType} />
</AttachmentHeader>
<AttachmentBox>
<AttachmentContent>
{renderAudioContent({
info: audioInfo,
mimeType: safeMimeType,
url: mxcUrl,
encInfo: content.file,
})}
<FileContent
body={body}
mimeType={mimeType}
info={fileInfo ?? {}}
url={mxcUrl}
encInfo={rContent.file(content)}
/>
</AttachmentContent>
</AttachmentBox>
</Attachment>
);
}
type RenderFileContentProps = {
body: string;
info: IFileInfo & IThumbnailContent;
mimeType: string;
url: string;
encInfo?: IEncryptedFile;
};
type MFileProps = {
content: IFileContent;
renderFileContent: (props: RenderFileContentProps) => ReactNode;
type MVideoProps = {
content: object;
mediaAutoLoad?: boolean;
outlined?: boolean;
};
export function MFile({ content, renderFileContent, outlined }: MFileProps) {
const fileInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
export function MVideo({ content, mediaAutoLoad, outlined }: MVideoProps) {
const videoInfo = rContent.info(content);
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 />;
}
const height = scaleYDimension(rInfo.w(videoInfo) || 400, 400, rInfo.h(videoInfo) || 400);
const filename = rContent.filename(content) ?? rContent.body(content) ?? 'Video';
return (
<Attachment outlined={outlined}>
<AttachmentHeader>
<FileHeader
body={content.filename ?? content.body ?? 'Unnamed File'}
mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
body={filename}
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>
<AttachmentBox>
<AttachmentContent>
{renderFileContent({
body: content.filename ?? content.body ?? 'File',
info: fileInfo ?? {},
mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE,
url: mxcUrl,
encInfo: content.file,
})}
<AudioContent
info={audioInfo}
mimeType={safeMimeType}
url={mxcUrl}
encInfo={rContent.file(content)}
/>
</AttachmentContent>
</AttachmentBox>
</Attachment>
@ -383,11 +418,11 @@ export function MFile({ content, renderFileContent, outlined }: MFileProps) {
}
type MLocationProps = {
content: IContent;
content: object;
};
export function MLocation({ content }: MLocationProps) {
const geoUri = content.geo_uri;
if (typeof geoUri !== 'string') return <BrokenContent />;
const geoUri = rContent.geoUri(content);
if (!geoUri) return <BrokenContent />;
const location = parseGeoUri(geoUri);
if (!location) return <BrokenContent />;
@ -411,16 +446,18 @@ export function MLocation({ content }: MLocationProps) {
}
type MStickerProps = {
content: IImageContent;
renderImageContent: (props: RenderImageContentProps) => ReactNode;
content: object;
mediaAutoLoad?: boolean;
outlineAttachment?: boolean;
};
export function MSticker({ content, renderImageContent }: MStickerProps) {
const imgInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
export function MSticker({ content, mediaAutoLoad, outlineAttachment }: MStickerProps) {
const imgInfo = rContent.info(content);
const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
if (typeof mxcUrl !== 'string') {
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 (
<AttachmentBox
@ -429,13 +466,7 @@ export function MSticker({ content, renderImageContent }: MStickerProps) {
width: toRem(152),
}}
>
{renderImageContent({
body: content.body || 'Sticker',
info: imgInfo,
mimeType: imgInfo?.mimetype,
url: mxcUrl,
encInfo: content.file,
})}
<MImage content={content} mediaAutoLoad={mediaAutoLoad} outlined={outlineAttachment} />
</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';
const Container = createVar();
@ -48,6 +48,7 @@ export const Reaction = style([
},
'&[aria-disabled=true], &:disabled': {
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([
DefaultReset,
{

View file

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

View file

@ -6,10 +6,11 @@ import { MessageEmptyContent } from './content';
import { sanitizeCustomHtml } from '../../utils/sanitize';
import { highlightText, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
const NEXT_LINE_ONLY_REGEX = /^\n+$/;
type RenderBodyProps = {
body: string;
customBody?: string;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
@ -21,9 +22,9 @@ export function RenderBody({
htmlReactParserOptions,
linkifyOpts,
}: RenderBodyProps) {
if (body === '') <MessageEmptyContent />;
if (customBody) {
if (customBody === '') <MessageEmptyContent />;
if (body === '' || NEXT_LINE_ONLY_REGEX.test(body)) return <MessageEmptyContent />;
if (typeof customBody === 'string') {
if (customBody === '') return <MessageEmptyContent />;
return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
}
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 { EventTimelineSet, Room } from 'matrix-js-sdk';
import { Box, Icon, Icons, Text, color, config, toRem } from 'folds';
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
import classNames from 'classnames';
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { ClientEventWithoutRoomId } from 'neurone/lib/api/types';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { LinePlaceholder } from './placeholder';
import { randomNumberBetween } from '../../utils/common';
import * as css from './Reply.css';
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { MessageFailedContent } from './content';
import { useRoomEvent } from '../../hooks/useRoomEvent';
import colorMXID from '../../../util/colorMXID';
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 = {
userColor?: string;
type ReplyMessageProps = {
username?: ReactNode;
usernameColor?: string;
children: ReactNode;
onClick?: () => void;
};
export const ReplyLayout = as<'div', ReplyLayoutProps>(
({ username, userColor, className, children, ...props }, ref) => (
<Box
className={classNames(css.Reply, className)}
alignItems="Center"
gap="100"
{...props}
ref={ref}
>
<Box style={{ color: userColor, maxWidth: toRem(200) }} alignItems="Center" shrink="No">
<Icon size="100" src={Icons.ReplyArrow} />
export function ReplyMessage({ username, usernameColor, onClick, children }: ReplyMessageProps) {
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!onClick) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick();
}
};
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}
</Box>
<Box grow="Yes" className={css.ReplyContent}>
{children}
</Box>
<Box {...commonProps}>{children}</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 = {
room: Room;
timelineSet?: EventTimelineSet | undefined;
replyEventId: string;
threadRootId?: string | undefined;
onClick?: MouseEventHandler | undefined;
roomId: string;
eventId: string;
onClick?: (replyId: string) => void;
onUserClick?: MouseEventHandler<HTMLButtonElement>;
getMemberPowerTag?: GetMemberPowerTag;
accessibleTagColors?: Map<string, string>;
legacyUsernameColor?: boolean;
getMemberName: GetRoomMemberNameCallback;
timeline?: TimelineView;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export const Reply = as<'div', ReplyProps>(
(
{
room,
timelineSet,
replyEventId,
threadRootId,
onClick,
getMemberPowerTag,
accessibleTagColors,
legacyUsernameColor,
...props
},
ref
) => {
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
const getFromLocalTimeline = useCallback(
() => timelineSet?.findEventById(replyEventId),
[timelineSet, replyEventId]
);
const replyEvent = useRoomEvent(room, replyEventId, getFromLocalTimeline);
export function Reply({
roomId,
eventId,
onClick,
onUserClick,
getMemberPowerTag,
accessibleTagColors,
legacyUsernameColor,
getMemberName,
timeline,
htmlReactParserOptions,
linkifyOpts,
}: ReplyProps) {
const ct = useClient();
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
const { body } = replyEvent?.getContent() ?? {};
const sender = replyEvent?.getSender();
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
const redactedEvents = useObservableValueOptionally(timeline?.relations.redacted);
const edits = useObservableValueOptionally(timeline?.relations.edits.eventToRelations);
const usernameColor = legacyUsernameColor ? colorMXID(sender ?? replyEventId) : tagColor;
const replyEvent = useRoomEvent(
roomId,
eventId,
useCallback(() => timeline?.findEvent(eventId), [timeline, eventId])
);
const fallbackBody = replyEvent?.isRedacted() ? (
<MessageDeletedContent />
) : (
<MessageFailedContent />
);
const sender = replyEvent?.sender;
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
const usernameColor = legacyUsernameColor ? colorMXID(sender ?? eventId) : tagColor;
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 (
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
{threadRootId && (
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }} as="div">
{redacted ? (
<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>
);
}
);
};
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 { Text, as } from 'folds';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
import {
timeDayMonthYear,
timeDayMonYear,
timeHourMinute,
today,
yesterday,
} from '../../utils/time';
export type TimeProps = {
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 */
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 { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { Range } from 'react-range';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IAudioInfo } from '../../../../types/matrix/common';
import {
PlayTimeCallback,
useMediaLoading,
@ -16,58 +14,45 @@ import {
} from '../../../hooks/media';
import { useThrottle } from '../../../hooks/useThrottle';
import { secondsToMinutesAndSeconds } from '../../../utils/common';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { decryptFile, downloadEncryptedMedia, downloadMedia } from '../../../utils/matrix';
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 = {
wait: 500,
immediate: true,
};
type RenderMediaControlProps = {
after: ReactNode;
leftControl: ReactNode;
rightControl: ReactNode;
children: ReactNode;
};
export type AudioContentProps = {
mimeType: string;
url: string;
info: IAudioInfo;
encInfo?: EncryptedAttachmentInfo;
renderMediaControl: (props: RenderMediaControlProps) => ReactNode;
info: object;
encInfo?: object;
};
export function AudioContent({
mimeType,
url,
info,
encInfo,
renderMediaControl,
}: AudioContentProps) {
const mx = useMatrixClient();
export function AudioContent({ mimeType, url, info, encInfo }: AudioContentProps) {
const ct = useClient();
const useAuthentication = useMediaAuthentication();
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url();
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
)
: await downloadMedia(mediaUrl);
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo])
}, [ct, url, useAuthentication, mimeType, encInfo])
);
const audioRef = useRef<HTMLAudioElement | null>(null);
const [currentTime, setCurrentTime] = useState(0);
// 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 getAudioRef = useCallback(() => audioRef.current, []);
@ -75,9 +60,9 @@ export function AudioContent({
const { playing, setPlaying } = useMediaPlay(getAudioRef);
const { seek } = useMediaSeek(getAudioRef);
const { volume, mute, setMute, setVolume } = useMediaVolume(getAudioRef);
const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => {
const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, cTime) => {
setDuration(d);
setCurrentTime(ct);
setCurrentTime(cTime);
}, []);
useMediaPlayTimeCallback(
getAudioRef,
@ -91,95 +76,25 @@ export function AudioContent({
loadSrc();
}
};
return renderMediaControl({
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>
return (
<MediaControl
after={
<Range
step={0.1}
step={1}
min={0}
max={1}
values={[volume]}
onChange={(values) => setVolume(values[0])}
max={duration || 1}
values={[currentTime]}
onChange={(values) => seek(values[0])}
renderTrack={(params) => (
<div {...params.props}>
{params.children}
<ProgressBar
style={{ width: toRem(48) }}
as="div"
variant="Secondary"
size="300"
min={0}
max={1}
value={volume}
max={duration}
value={currentTime}
radii="300"
/>
</div>
@ -199,12 +114,82 @@ export function AudioContent({
/>
)}
/>
</>
),
children: (
}
leftControl={
<>
<Chip
onClick={handlePlay}
variant="Secondary"
radii="300"
disabled={srcState.status === AsyncStatus.Loading}
before={
srcState.status === AsyncStatus.Loading || loading ? (
<Spinner variant="Secondary" size="50" />
) : (
<Icon src={playing ? Icons.Pause : Icons.Play} size="50" filled={playing} />
)
}
>
<Text size="B300">{playing ? 'Pause' : 'Play'}</Text>
</Chip>
<Text size="T200">{`${secondsToMinutesAndSeconds(
currentTime
)} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
</>
}
rightControl={
<>
<IconButton
variant="SurfaceVariant"
size="300"
radii="Pill"
onClick={() => setMute(!mute)}
aria-pressed={mute}
>
<Icon src={mute ? Icons.VolumeMute : Icons.VolumeHigh} size="50" />
</IconButton>
<Range
step={0.1}
min={0}
max={1}
values={[volume]}
onChange={(values) => setVolume(values[0])}
renderTrack={(params) => (
<div {...params.props}>
{params.children}
<ProgressBar
style={{ width: toRem(48) }}
variant="Secondary"
size="300"
min={0}
max={1}
value={volume}
radii="300"
/>
</div>
)}
renderThumb={(params) => (
<Badge
size="300"
variant="Secondary"
fill="Solid"
radii="Pill"
outlined
{...params.props}
style={{
...params.props.style,
zIndex: 0,
}}
/>
)}
/>
</>
}
>
<audio controls={false} autoPlay ref={audioRef}>
{srcState.status === AsyncStatus.Success && <source src={srcState.data} type={mimeType} />}
</audio>
),
});
</MediaControl>
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -6,5 +6,6 @@ export * from './Reply';
export * from './content';
export * from './Time';
export * from './MsgTypeRenderers';
export * from './MsgTypeReplyRenderers';
export * from './FileHeader';
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} />
));
export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?: boolean }>(
({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
export const MessageTextBody = as<
'div',
css.MessageTextBodyVariants & { size?: 'T400' | 'T300'; notice?: boolean; truncate?: boolean }
>(
(
{ as: asComp = 'div', className, size, preWrap, jumboEmoji, emote, notice, truncate, ...props },
ref
) => (
<Text
as={asComp}
size="T400"
size={size ?? 'T400'}
priority={notice ? '300' : '400'}
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
truncate={truncate}
{...props}
ref={ref}
/>

View file

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

View file

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

View file

@ -15,7 +15,7 @@ export const AvatarPresenceBadge = style({
zIndex: 1,
display: 'flex',
padding: config.borderWidth.B600,
padding: '5%',
backgroundColor: 'inherit',
borderRadius: config.radii.Pill,
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 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 { getRoomIconSrc } from '../../utils/room';
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 = {
roomId: string;
@ -18,7 +54,7 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
evt.currentTarget.setAttribute('data-image-loaded', 'true');
};
if (!src || error) {
if (!src) {
return (
<AvatarFallback
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 (
<AvatarImage
className={css.RoomAvatar}
className={classNames(css.RoomAvatar, css.RoomAvatarImg)}
src={src}
alt={alt}
onError={() => setError(true)}

View file

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

View file

@ -1,60 +1,63 @@
import React, { useCallback, useState } from 'react';
import { Avatar, Box, Button, Spinner, 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 React, { useState } from 'react';
import { Avatar, Box, Button, Text, as } from 'folds';
import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { RoomAvatar } from '../room-avatar';
import { nameInitials } from '../../utils/common';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
import { mDirectAtom } from '../../state/mDirectList';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { InviteUserPrompt } from '../invite-user-prompt';
import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom';
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 = {
room: Room;
};
export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
const mx = useMatrixClient();
export const RoomIntro = as<
'div',
{
name: string;
avatar?: string;
}
>(({ name, avatar, ...props }, ref) => {
const ct = useClient();
const roomId = useRoom();
const useAuthentication = useMediaAuthentication();
const { navigateRoom } = useRoomNavigate();
const mDirects = useAtomValue(mDirectAtom);
const [invitePrompt, setInvitePrompt] = useState(false);
const { getName } = useRoomMembersAPI();
const createEvent = getStateEvent(room, StateEvent.RoomCreate);
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
const name = useRoomName(room);
const topic = useRoomTopic(room);
const avatarHttpUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
const topic = useRoomTopic(roomId);
const avatarHttpUrl = avatar
? mxcToHttp(ct.session.baseUrl, avatar, useAuthentication).url()
: undefined;
const createContent = createEvent?.getContent<IRoomCreateContent>();
const ts = createEvent?.getTs();
const creatorId = createEvent?.getSender();
const creatorName =
creatorId && (getMemberDisplayName(room, creatorId) ?? getMxIdLocalPart(creatorId));
const prevRoomId = createContent?.predecessor?.room_id;
const createResult = useRoomStateEvent(roomId, StateEventType.RoomCreate, '');
const event = createResult.value?.event;
const content = event?.content;
const [prevRoomState, joinPrevRoom] = useAsyncCallback(
useCallback((roomId: string) => joinEstateRoomOrThrow(mx, roomId), [mx])
);
const ts = event?.origin_server_ts;
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 (
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
<Box>
<Avatar size="500">
<RoomAvatar
roomId={room.roomId}
roomId={roomId}
src={avatarHttpUrl ?? undefined}
alt={name}
renderFallback={() => <Text size="H2">{nameInitials(name)}</Text>}
@ -83,36 +86,19 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
</Button>
{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>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,10 @@
import React, { useEffect, useCallback, FormEventHandler } from 'react';
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 { 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({
title,
@ -77,10 +78,14 @@ export function EmailStageDialog({
}: StageComponentProps & {
email?: string;
clientSecret: string;
emailTokenState: AsyncState<RequestEmailTokenResponse, MatrixError>;
emailTokenState: AsyncState<RequestEmailTokenResult, Error>;
requestEmailToken: RequestEmailTokenCallback;
}) {
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(
(sessionId: string) => {
@ -120,14 +125,23 @@ export function EmailStageDialog({
);
}
if (emailTokenState.status === AsyncStatus.Error) {
if (requestError) {
return (
<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={
emailTokenState.error?.data?.error ??
emailTokenState.error.message ??
'Failed to send verification Email request.'
matrixError ? printMatrixError(matrixError) : 'Failed to send verification Email request.'
}
onRetry={handleEmailSubmit}
onCancel={onCancel}
@ -135,19 +149,19 @@ export function EmailStageDialog({
);
}
if (emailTokenState.status === AsyncStatus.Success) {
if (result) {
return (
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<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 && (
<Text style={{ color: color.Critical.Main }}>{`${errorCode}: ${error}`}</Text>
)}
</Box>
<Button variant="Primary" onClick={() => handleSubmit(emailTokenState.data.result.sid)}>
<Button variant="Primary" onClick={() => handleSubmit(result.result.sid)}>
<Text as="span" size="B400">
Continue
</Text>

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