Compare commits

...

308 commits

Author SHA1 Message Date
903eefb475 Merge commit 'refs/pull/2487/head' of https://github.com/cinnyapp/cinny into dev
Some checks failed
Deploy to Netlify (dev) / Deploy to Netlify (push) Has been cancelled
nyabau
2026-02-13 04:21:56 +02:00
hazre
92f490e9d9
feat: show connected/connecting call status 2026-02-13 01:46:24 +01:00
James
efb3e115db
fix: permissions and room icon resolution (#2)
* Initialize call state upon room creation for call rooms, remove subsequent useless permission

* handle case of missing call permissions

* use call icon for room item summary when room is call room

* replace previous icon src resolution function with a more robust approach

* replace usages of previous icon resolution function with new implementation

* fix room name not updating for a while when changed

* set up framework for room power level overrides upon room creation

* override join call permission to all members upon room creation

* fix broken usages of RoomIcon

* remove unneeded import

* remove unnecessary logic

* format with prettier
2026-02-13 01:03:46 +01:00
haz
9554b31c7d
Merge branch 'dev' into feat/element-call 2026-02-12 13:38:00 +01:00
Andrew Murphy
fd37dfe3f9
Fix muted rooms showing unread badges (#2581)
fix: detect muted rooms with empty actions array

The mute detection was checking for `actions[0] === "dont_notify"` but
Cinny sets `actions: []` (empty array) when muting a room, which is
the correct behavior per Matrix spec where empty actions means no
notification.

This caused muted rooms to still show unread badges and contribute to
space badge counts.

Fixes the isMutedRule check to handle both:
- Empty actions array (current Matrix spec)
- "dont_notify" string (deprecated but may exist in older rules)
2026-02-12 21:45:37 +11:00
Gimle Larpes
1ce6ca2b07
Re-add mEvent.getSender() === mx.getUserId() check for deletion of messages (#2607)
* hide "Delete Message" if it is forbidden

* Fix the stuff I broke :/
2026-02-12 21:40:11 +11:00
Ajay Bura
e04aeb865f
Merge branch 'dev' into feat/element-call 2026-02-12 13:52:55 +05:30
renovate[bot]
83e5125b37
fix(deps): update dependency folds to v2.5.0 (#2606)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-12 16:56:47 +11:00
Gimle Larpes
ca82aa283a
Hide "Delete Message" if it is forbidden (#2602)
hide "Delete Message" if it is forbidden
2026-02-12 16:27:17 +11:00
Zach
8ce33ee6ff
Replace envs.net with unredacted.org in config (#2601)
* Replace 'envs.net' with 'unredacted.org' in config

https://envs.net/ is shutting down their Matrix server

* Update defaultHomeserver and reorder servers list

* Remove 'monero.social' from homeserver list
2026-02-12 10:39:58 +11:00
haz
5aec2732dd
Merge pull request #1 from YoJames2019/feat/element-call
fix page header background color on room view header
2026-02-11 20:40:07 +01:00
YoJames2019
a6f75eb5c5 fix page header background color on room view header 2026-02-11 14:07:59 -05:00
hazre
afac47d312
style: blend header and room input button styles in call nav 2026-02-11 18:38:00 +01:00
hazre
4f498af458
fix: restore header icon button fill behavior
Fixes regression from b074d421b66eb4d8b600dfa55b967e6c4f783044.
2026-02-11 18:03:41 +01:00
hazre
7ceba0301e
fix: keep call media controls visible before joining 2026-02-11 16:24:38 +01:00
hazre
9dbe53a36a
fix: clean up call nav/call view console warnings 2026-02-11 15:18:16 +01:00
hazre
47f1d1183a
fix: show call nav status while active call is ongoing 2026-02-11 14:51:43 +01:00
hazre
e01009fd07
Merge remote-tracking branch 'upstream/dev' into feat/element-call 2026-02-11 14:26:40 +01:00
YoJames2019
008669efdf format using prettier rules from project prettierrc 2026-02-10 22:55:26 -05:00
YoJames2019
9562103210 remove debug logs 2026-02-10 22:39:53 -05:00
YoJames2019
40957632d5 clean up ts/eslint errors 2026-02-10 10:56:00 -05:00
YoJames2019
5b3a0f1334 set default mic state to enabled 2026-02-09 22:22:08 -05:00
YoJames2019
990a92a32c redo roomcallnavstatus ui, force user preferred mute/video states when first joining calls, update variable names and remove unnecessary logic 2026-02-09 22:17:28 -05:00
YoJames2019
e481116b04 update text spacing 2026-02-09 01:06:08 -05:00
YoJames2019
9e1aab2973 bump element call to 0.16.3, apply cinny theme to element call ui, replace element call lobby (backup iframe) with custom ui and only use element call for the in-call ui 2026-02-09 00:45:48 -05:00
YoJames2019
7bca8fb911 add call related permissions to room permissions 2026-02-08 08:01:52 -05:00
Ginger
13dd8fcc06
Allow account data to be deleted if the homeserver supports it 2025-10-06 14:18:41 -04:00
Ginger
205ea1655a
Add a context menu option to view a user's raw extended profile fields 2025-10-06 14:02:50 -04:00
Ginger
d42bcc6e3d
Use a common CollapsibleCard element for collapsible settings cards 2025-10-06 12:21:01 -04:00
Ginger
af9460ef8b
Fix incorrect logic when checking for profile field changes 2025-10-06 11:45:32 -04:00
Ginger
5bc9654d32
Add a panel in Developer Tools for editing profile fields 2025-10-06 11:44:41 -04:00
Ginger
4e7b64eb5f
Merge branch 'dev' into msc4133 2025-10-01 13:56:37 -04:00
Ginger
f9b0d8c86f
Add some explanatory comments 2025-09-24 10:44:17 -04:00
Ginger
458b1c0172
Merge branch 'dev' into msc4133 2025-09-22 11:46:32 -04:00
Ginger
4c5acc1940
Use proper deep comparison for hasChanged 2025-09-20 16:40:18 -04:00
Ginger
cfee62ffe6
Fix profile field comparison 2025-09-20 14:40:13 -04:00
Ginger
79b37e177b
Improve text contrast in IDP profile settings element 2025-09-19 10:50:57 -04:00
Ginger
4c515bb72e
Move timezone chip to a better position 2025-09-18 12:36:08 -04:00
Ginger
8a8443bda4
Move profile field elements into their own files 2025-09-18 12:34:34 -04:00
Ginger
317cd366c3
Hide profile fields which are blocked by a capability 2025-09-18 12:29:51 -04:00
Ginger
aafd028af4
Fix support for MSC4133-less homeservers, add OIDC profile link 2025-09-18 10:20:52 -04:00
Ginger
984803c52c
Add slightly more padding above the profile save and cancel buttons 2025-09-16 09:36:07 -04:00
Ginger
07df0c2c79
Use Tanstack Query when fetching extended profiles to improve caching 2025-09-16 09:23:06 -04:00
Ginger
c389365ea2
Merge branch 'dev' into msc4133 2025-09-15 18:27:50 -04:00
Ginger
d4deba6074
Use a consistent fallback icon in settings for users with no avatar 2025-09-15 14:50:29 -04:00
Ginger
c5b59ea122
Add a setting for user pronouns 2025-09-15 14:46:08 -04:00
Ginger
c7f6e33a2b
Propery delete blank profile fields 2025-09-15 14:06:41 -04:00
Ginger
5c2c8984aa
Fix flickering issues when updating profile fields 2025-09-15 14:03:08 -04:00
Ginger
c3901804c0
Add a chip and setting for user timezones 2025-09-15 13:46:27 -04:00
Ginger
3c1aa0e699
Rework profile settings to show a preview and support more fields 2025-09-15 10:47:21 -04:00
Jaggar
a299e9c4cb
Merge pull request #58 from GimleLarpes/patch-1
Simplify RoomNavUser
2025-08-18 23:39:50 +00:00
Jaggar
141f148e37
Merge pull request #60 from GimleLarpes/patch-3
Reworks/cleans up structure of Room, RoomView and CallView
2025-08-09 20:51:09 +00:00
Jaggar
51cfd7201c
Merge pull request #59 from GimleLarpes/patch-2
Simplify RoomViewHeader.tsx
2025-08-09 20:47:12 +00:00
Gimle Larpes
528cbc5c79 Update CallView.tsx to accomodate restructuring of Room, RoomView and CallView + suggested changes 2025-07-31 21:05:15 +02:00
Gimle Larpes
e504a9ef4c Update RoomView.tsx to accomodate restructuring of Room, RoomView and CallView 2025-07-31 20:52:01 +02:00
Gimle Larpes
fb9ca31a43
Update Room.tsx to accomodate restructuring of Room, RoomView and CallView 2025-07-31 20:36:16 +02:00
Gimle Larpes
b91a9d72b0
Simplify RoomViewHeader.tsx
Remove unused UI elements that don't have implemented functionality. Replace custom function for checking if a room is direct with a standard hook.
2025-07-31 14:08:44 +02:00
Gimle Larpes
4be70422b1
Simplify RoomNavUser
Discard future functionality since it probably won't exist in time for merging this PR
2025-07-30 16:46:27 +02:00
Jaggar
77f8a0409a
Merge pull request #54 from GigiaJ/pr-41
Pr 41
2025-07-05 07:07:33 +00:00
Jaggar
235bb63c15
Merge pull request #41 from GimleLarpes/patch-1
Revert most changes to Space.tsx
2025-07-05 07:07:02 +00:00
Gigiaj
5a25da4415 update references to use callMembership instead 2025-07-04 22:21:53 -05:00
Gigiaj
92e24e5281 swap userId to callMembership as a prop and add a nullchecked userId that uses the membership sender 2025-07-04 22:21:31 -05:00
Gigiaj
ca2c868624 Rename file, sprinkle in the magic one line for matrixRTCSession. and remove comment block 2025-07-04 22:19:19 -05:00
Gimle Larpes
79fab78c71 changes to RoomNavItem, RoomNavUser and add useCallMembers 2025-06-29 14:21:22 +02:00
Gimle Larpes
f407905d73
Show call room even if category is collapsed 2025-06-27 12:51:36 +02:00
Gimle Larpes
efc77ceb44
Revert most changes to Space.tsx 2025-06-27 12:38:13 +02:00
Jaggar
eaf70fb79a
Update src/app/features/room/RoomViewHeader.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-24 19:01:41 -05:00
Jaggar
2869735e2b
Update src/app/features/room/RoomViewHeader.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-24 19:01:09 -05:00
Jaggar
40a2277996
Update src/app/features/room/RoomViewHeader.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-24 19:00:54 -05:00
Jaggar
7e948f0050
Update src/app/features/room/RoomViewHeader.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-24 19:00:19 -05:00
Jaggar
6d0e8b7f79
Update src/app/features/room/RoomViewHeader.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-24 19:00:06 -05:00
Gigiaj
78c38506fa update element-call version 2025-06-20 16:51:40 -05:00
Jaggar
12e4ba9981
Merge branch 'dev' into dev 2025-06-18 23:23:43 -05:00
Gigiaj
20815390e6 Remove No Active Call text when not in a call 2025-06-18 18:39:30 -05:00
Gigiaj
7712e34d22 adjust room header for calling 2025-06-18 17:42:53 -05:00
Jaggar
4403eacdf1
Update src/app/features/room/RoomView.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-18 17:42:15 -05:00
Jaggar
9be2a945da
Update src/app/features/room/RoomView.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-18 17:27:34 -05:00
Jaggar
a2c8097c01
Update src/app/features/call/CallView.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-18 17:27:26 -05:00
Jaggar
c675131802
Update src/app/features/call/CallView.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-18 17:26:57 -05:00
Jaggar
6b3c9dfddc
Update src/app/features/call/CallView.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-18 17:26:47 -05:00
Jaggar
e6e751d305
Update src/app/pages/client/space/Space.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-17 11:38:41 -05:00
Jaggar
dd158fa652
Update src/app/features/room-nav/RoomNavUser.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-17 11:38:29 -05:00
Jaggar
b46c9ed023
Update src/app/features/room-nav/RoomNavUser.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-17 11:38:19 -05:00
Jaggar
aca2a40309
Update src/app/features/room-nav/RoomNavUser.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-17 11:38:11 -05:00
Jaggar
f74feec123
Update src/app/features/room-nav/RoomNavItem.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-17 11:37:56 -05:00
Jaggar
7a169fcc0e
Update src/app/features/room-nav/RoomNavItem.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-17 11:37:44 -05:00
Jaggar
b08fa60a93
Update src/app/features/room-nav/RoomNavItem.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-17 11:37:25 -05:00
Jaggar
9f749d1a9d
Update src/app/features/room-nav/RoomNavItem.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-17 11:37:03 -05:00
Jaggar
3f2c2af351
Update src/app/features/room-nav/RoomNavItem.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-17 11:36:43 -05:00
Jaggar
4d14eb9fb7
Update src/app/features/room-nav/RoomNavItem.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-17 11:36:24 -05:00
Jaggar
8e4f310aa3
Update src/app/features/room/RoomViewHeader.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-15 23:31:53 -05:00
Jaggar
5d22b287db
Update src/app/features/room-nav/RoomNavItem.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-15 23:31:44 -05:00
Jaggar
2575d77b37
Update src/app/features/room-nav/RoomCallNavStatus.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-15 23:31:34 -05:00
Jaggar
0d2f76e11d
Update src/app/features/room-nav/RoomCallNavStatus.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-15 23:31:24 -05:00
Jaggar
8dfc3aa36f
Update src/app/features/room-nav/RoomCallNavStatus.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-15 23:31:16 -05:00
Jaggar
3b767870c5
Update src/app/features/room-nav/RoomCallNavStatus.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-15 23:30:59 -05:00
Jaggar
a5a9e72c5f
Update src/app/features/room-nav/RoomCallNavStatus.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-15 23:30:47 -05:00
Jaggar
e1146b1c06
Update src/app/features/room-nav/RoomCallNavStatus.tsx
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
2025-06-15 23:30:17 -05:00
Gigiaj
daadbe4358 Invert icons to show the state instead of the action they will perform (more visual clarity) 2025-06-10 20:40:45 -05:00
Gigiaj
02ac70affc Re-arrange more options and add checks for each option to see if it is a call room (probably should manage a state to see if a header is already on screen and provide a slightly modified visual based on that for call rooms) 2025-06-10 20:01:15 -05:00
Gigiaj
7228c2e35f Remove unneeded prop 2025-06-10 20:00:21 -05:00
Gigiaj
e78d1dab14 Update to use new icons (thank you) 2025-06-10 20:00:07 -05:00
Jaggar
e42a617fc2
Merge branch 'dev' into dev 2025-06-10 19:31:12 -05:00
Gigiaj
73c17d3558 Fixes complaints of null contentDocument in iframe 2025-05-28 17:11:46 -05:00
Jaggar
153d7d12fc
Merge branch 'dev' into dev 2025-05-28 22:08:46 +00:00
Gigiaj
6edee72a15 Fixes a bug where if you left a call then went to a lobby and joined it didn't update the actual activeCallRoomId 2025-05-28 17:05:41 -05:00
Gigiaj
e3f1697367 Far cleaner and more sensible handling of the call window... I just really don't like the idea of sending a click event, but right now the element-call code treats preload/skipLobby hangups (sent from our end) as if they had no lobby at all and thus black screens. Other implementation was working around that without just sending a click event on the iframe's hangup button. 2025-05-28 16:11:25 -05:00
Gigiaj
42252829c6 Bind the on messaging iframe for easier access in hangup/join handling 2025-05-28 16:08:01 -05:00
Gigiaj
39b20c7cc7 Technically corrects the hangup button in the widget, should be more precise though 2025-05-27 01:14:42 -05:00
Jaggar
78dcdfda95
Merge branch 'dev' into dev 2025-05-27 04:12:48 +00:00
Gigiaj
3e6d55f03b Fix dependency array 2025-05-26 23:12:14 -05:00
Gigiaj
c99112b78b A bit of an abomination, but adds a state counter to iteratively handle the diverse potential states (where a user can join from the nav bar or the join button, hang up from either as well, and account for the juggling iframes)
Black screens shouldn't be occurring now.
2025-05-26 23:11:47 -05:00
Gigiaj
0e332d6616 Solves CCH. Looks like CLCH left 2025-05-26 01:02:35 -05:00
Gigiaj
923982ef30 Fixes CLJH, found CCH 2025-05-26 00:41:55 -05:00
Gigiaj
0bd42a3994 Solves the CHCH sequence issue, CLJH remains 2025-05-26 00:37:48 -05:00
Gigiaj
9b98083d4a In widget hang up button should be handled correct now 2025-05-25 23:41:07 -05:00
Gigiaj
18ea2d2063 Fix formatting 2025-05-25 23:17:36 -05:00
Gigiaj
335df8d6ba Seems to shore up the remaining state issues with the status bar hangup 2025-05-25 23:16:37 -05:00
Gigiaj
df84eb1d71 More correct filter (viewedRoom can return false on that compare in some cases) 2025-05-25 22:40:03 -05:00
Gigiaj
bf131f76dc Re-add intended switching behavior 2025-05-25 22:09:17 -05:00
Gigiaj
1a821961f3 Fix for cases where you're viewing a lobby and hang up your existing call and try to join lobby (was not rendering for the correct room id, but was joining the correct call prior) 2025-05-25 22:01:57 -05:00
Gigiaj
108eb60b4a Seems to avoid almost all invalid states (hang up while viewing another lobby and hitting join seems to black screen, sets the active call as the previous active room id, but does join the viewed room correctly) 2025-05-25 21:29:53 -05:00
Gigiaj
22903c9340 Properly declare new hangup method sig 2025-05-25 16:34:55 -05:00
Gigiaj
f3c0aebda2 Remove unused 2025-05-25 16:32:52 -05:00
Gigiaj
99576a2432 Remove viewed room setting here and pass the room to hang up (seems state doesn't update fast enough otherwise) 2025-05-25 16:30:23 -05:00
Gigiaj
7d26601bfc This seems to manage the hangup state with the status bar button well enough that black screens should never be encountered 2025-05-25 16:29:55 -05:00
Gigiaj
9499289fb3 Prevent null rooms from ever rendering 2025-05-25 16:29:01 -05:00
Jaggar
f842356438
Merge branch 'dev' into dev 2025-05-25 00:39:38 +00:00
Gigiaj
0ef9c56a25 Update rendering logic to clear up remaining rendering bug (straight to call -> lobby of another room and joining call from that interface -> lobby of that previous room and joining was leading to duplication of the user in lobbies. This was actually from listening to and acknowledging hangups from the viewed widget in CallProvider) 2025-05-24 19:34:26 -05:00
Gigiaj
f2f98a6973 Add storing widget for comparing with (since we already store room id and the clientWidgetApi anyway) 2025-05-24 19:31:43 -05:00
Gigiaj
3818671446 Remove repetitive check 2025-05-24 19:30:53 -05:00
Gigiaj
8b22573e43 Re-add the default view current active room behavior 2025-05-23 16:43:50 -05:00
Gigiaj
0b6009aaee Seems to sort out the hangup status button bug the occurred after joining a call via lobby 2025-05-23 16:34:44 -05:00
Gigiaj
872e9a257f Reduce code reuse in handleJoin 2025-05-23 13:57:01 -05:00
Gigiaj
e220387b3f Corrects the state for the situations where both iframes are "active" (not necessarily visible) 2025-05-23 13:22:22 -05:00
Gigiaj
03cbecc3f9 Provides correct behavior when call isn't active and no activeClientWidgetApi exists yet 2025-05-23 13:21:40 -05:00
Gigiaj
b6afe3b968 Fixes call initializing by default on mobile 2025-05-23 12:04:06 -05:00
Gigiaj
65ff5e3943 Just check for state on both which should only occur at initial 2025-05-23 01:55:54 -05:00
Gigiaj
b18f6366c7 Revert navitem change 2025-05-23 01:55:06 -05:00
Gigiaj
9302003c30 Sets initial states so the iframes don't cause the other to fail with the npm embedded package 2025-05-23 01:52:38 -05:00
Gigiaj
1871f70704 Fix path issue from moving file locations 2025-05-23 01:24:32 -05:00
Gigiaj
27196cbad2 Set dep version to exact 2025-05-23 01:17:50 -05:00
Gigiaj
9b3c6e7c92 Add package-lock changes 2025-05-23 01:13:23 -05:00
Gigiaj
d54bc2c110 Move files to more correct location 2025-05-23 00:01:19 -05:00
Gigiaj
c108295e5a add vite preview as an npm script 2025-05-22 23:53:30 -05:00
Gigiaj
d3ec9facf6 Fix vite build to place element-call correctly for embedded npm package support 2025-05-22 23:53:17 -05:00
Gigiaj
4c8ab4e79d null the default url so that we fallback to the embedded version (would recommend hosting it until performance issue is determined) 2025-05-22 23:52:51 -05:00
Gigiaj
ff02c461e7 Fixes element-call embedded support (but it seems to run poorly) 2025-05-22 23:52:19 -05:00
Gigiaj
e4ce4da8ee Remove unneeded logger.errors 2025-05-22 23:14:24 -05:00
Gigiaj
f262f54728 Fix to use shorthand prop 2025-05-22 23:13:08 -05:00
Gigiaj
395a24f1a7 Fix spelling mistake 2025-05-22 23:13:08 -05:00
GigiaJ
cd0d4c9704 Resolved merge conflict 2025-05-22 20:28:19 -05:00
Gigiaj
0db52c2d37 Moved CallProvider 2025-05-22 20:02:35 -05:00
Gigiaj
023a23d87c Rename and clean up 2025-05-22 20:02:13 -05:00
Gigiaj
a81492c5b2 update imports and dependency array 2025-05-22 19:57:40 -05:00
Gigiaj
67fbf949b0 Move and rename RoomCallNavStatus 2025-05-22 19:57:13 -05:00
Gigiaj
07a980a0c7 Remove CallActivation 2025-05-22 19:44:02 -05:00
Gigiaj
19f1df798f add widgetId to correct pos in getWidgetUrl usage 2025-05-22 19:43:11 -05:00
Gigiaj
c6ceb3f977 revert ternary expression change and add to dependency array 2025-05-22 19:42:46 -05:00
Gigiaj
5481595a67 Remove no longer needed files 2025-05-22 19:42:21 -05:00
Gigiaj
3bd7588497 Add widgetId as a getWidgetUrl param 2025-05-22 17:49:40 -05:00
Gigiaj
e5505cd5c2 Remove unused 2025-05-22 17:42:23 -05:00
Gigiaj
7dcd43c64f add listener clearing, camel case es lint rule exception, remove unneeded else statements 2025-05-22 17:41:16 -05:00
Gigiaj
0b70ce7591 Remove Date.now() that was causing widgetId desync 2025-05-22 17:33:36 -05:00
Gigiaj
7ef33400c1 Remove superfluous comments and Date.now() that was causing loading... bug when widgetId desynced 2025-05-22 17:33:15 -05:00
Gigiaj
6e33c8e8da Applies the correct changes to the call state and removes listeners of old active widget so we don't trigger hang ups on the new one (the element-call widget likes to spam the hang up response back several times for some reason long after you tell it to hang up) 2025-05-21 21:14:31 -05:00
Gigiaj
d9c0c85483 Remove clean room ID and add default handler for if no active call has existed yet, but user clicks on show chat 2025-05-21 21:12:51 -05:00
Gigiaj
dabe7f7849 swap call status to be bound to call state and not active call id 2025-05-21 21:12:03 -05:00
Gigiaj
e688c19350 Fix mobile call room default behavior from auto-join to displaying lobby 2025-05-21 21:11:25 -05:00
Gigiaj
1e44557406 Add ideal call room join behavior where text rooms to call room simply joins, but doesn't swap current view 2025-05-20 11:26:54 -05:00
Gigiaj
3e3d68602f Better handling of the isCallActive in the join handler 2025-05-11 19:24:13 -05:00
Gigiaj
af455a5fe3 Ensure drawer doesn't appear in call room 2025-05-11 19:11:21 -05:00
Gigiaj
7043376383 Fix the position of the member drawer to its correct location 2025-05-11 19:09:39 -05:00
Gigiaj
74c883dfa8 Much closer to the call state handling we want w/ hangups and joins 2025-05-11 19:01:47 -05:00
Gigiaj
59a936b094 fix backup iframe visibility 2025-05-11 18:53:05 -05:00
Gigiaj
4486ef1e72 add url as a param for widget url 2025-05-11 17:55:19 -05:00
Gigiaj
f3612e23c6 Testing the iframe juggling. Seems to work for the first and second joins... so likely on the right path with this 2025-05-11 17:55:04 -05:00
Gigiaj
5ab0b39152 Fix the view to correctly display the active iframe based on which is currently hosting the active call (juggling views) 2025-05-11 17:54:11 -05:00
Gigiaj
d488c24974 add navigateRoom to be in both conditions for the nav button 2025-05-11 17:53:33 -05:00
Gigiaj
f28a3a1c7d Conditionals to manage the active iframe state better 2025-05-11 17:52:50 -05:00
Gigiaj
6601c47abc (broken) juggle the iframe states proper... still needs fixing 2025-05-10 20:42:21 -05:00
Gigiaj
1b89831c10 add roomViewId and related 2025-05-10 20:41:57 -05:00
Gigiaj
cde0d5fa64 Disable 2025-05-10 20:41:44 -05:00
Gigiaj
da3d20d0fe add ViewedRoom usage 2025-05-10 20:41:35 -05:00
Gigiaj
a1c0b79cbd split into two refs 2025-05-10 20:41:06 -05:00
Gigiaj
cd233053bc Improve call room view stability 2025-05-10 08:58:32 -05:00
Gigiaj
9ae7c318ab re-add mobile chat handling 2025-05-10 08:58:03 -05:00
Gigiaj
1cdc0680e8 correctly provide visibility 2025-05-09 14:17:39 -05:00
Gigiaj
ae9cc7a548 Fix unexpected visibility in non-room areas 2025-05-09 14:17:25 -05:00
Gigiaj
00ac8f654a Revert to original code as we've moved the outlet context passing out and made more direct use of the ref 2025-05-09 11:17:46 -05:00
Gigiaj
4293538dc3 Revert to original code as we've moved calling to be more inline with design 2025-05-09 11:16:13 -05:00
Gigiaj
807c90e2f5 swap to using ref provider context from to connect to persistentcallcontainer more directly 2025-05-09 11:14:08 -05:00
Gigiaj
eea8ffea05 Re-add layout as we're no longer oddly passing outlet context 2025-05-09 11:13:30 -05:00
Gigiaj
6714300779 Remove unused imports and restructure to support being parent to clientlayout 2025-05-09 11:12:48 -05:00
Gigiaj
a690dbd2ca Add backupIframeRef so we can re-add the lobby screen for non-joined calls (for viewing their text channels) 2025-05-09 09:38:43 -05:00
Gigiaj
0be5fb9732 remove unused params 2025-05-09 09:35:13 -05:00
Gigiaj
8b2fa10679 Pass forward the backupIframeRef now 2025-05-08 18:41:26 -05:00
Gigiaj
43ce6f0210 Update room to use CallView 2025-05-08 18:01:27 -05:00
Gigiaj
8b50ac150b funnel through just iframe for now for testing sake 2025-05-08 18:00:16 -05:00
Gigiaj
1bd593b530 update client layout to funnel outlet the iframes for the call container 2025-05-08 17:59:51 -05:00
Gigiaj
c05421efb7 Update to funnel Outlet context through for Call handling (might not be the best approach, but removes code replication in PersistentCallContainer where we were remaking the roomview entirely) 2025-05-08 17:59:01 -05:00
Gigiaj
abe79ceb66 Add CallView 2025-05-08 17:57:57 -05:00
Gigiaj
3fcf2fef59 loosely provide nav handling for testing refactoring 2025-05-08 17:57:45 -05:00
Gigiaj
9e919ea761 prepare to feed this to child elements for visibility handling 2025-05-08 17:56:49 -05:00
Gigiaj
824be5bdc2 re-add background to active call link button 2025-05-04 15:06:21 -05:00
Gigiaj
a5a8f2814e temp fix to allow the status to be cleared in some way 2025-05-04 15:04:07 -05:00
Gigiaj
d11bdb2f85 Rename callnavbottom and fix linking implementation to actually be correct 2025-05-04 15:03:46 -05:00
Gigiaj
4083bbb31e rename CallNavBottom to CallNavStatus 2025-05-04 15:03:23 -05:00
Gigiaj
5ee3897fde Add RoomNavUser for displaying the user avatar + name in the nav for a visual of call activity and participants 2025-05-04 12:16:15 -05:00
Gigiaj
9cb705149a Add state listener so the call activity is real time updated on joins/leaves within the space 2025-05-04 12:15:31 -05:00
Gigiaj
b40ddf0c61 Update hook to keep method signature (accepting an array of Rooms instead) to support multiple room event tracking of the same event 2025-05-04 12:14:46 -05:00
Gigiaj
2841386972 Add background variant to buttons 2025-05-04 07:21:50 -05:00
Gigiaj
2e0218c456 Add avatar and username for the space (needs to be moved into RoomNavItem proper) 2025-05-04 07:11:40 -05:00
Gigiaj
3dcfde4461 add check to prevent DCing from the call you're currently in... 2025-05-03 22:09:21 -05:00
Gigiaj
79647c5b50 Add users on the nav to showcase call activity and who is in the call 2025-05-03 21:50:39 -05:00
Gigiaj
7808adbbe1 add (really badly) state logic for the active iframe 2025-05-03 00:07:00 -05:00
Gigiaj
c64dbb0563 add a state store for which iFrame is active 2025-05-03 00:06:36 -05:00
Gigiaj
7f8aeb335f remove logger statement and swap hash to search 2025-05-02 17:11:33 -05:00
Gigiaj
a2a83fc316 update to enable chat icon to be able to open call room WITHOUT joining by making sure the navitems into a diff call room performs a hangup on click 2025-05-02 17:11:03 -05:00
Gigiaj
de1a629b79 prepare for juggling iframes and handling hang up appropriately 2025-05-02 17:06:54 -05:00
Gigiaj
93fbbecfdd update to support two iframes - still needs to leverage balancing the two properly but as a PoC it works 2025-05-02 17:04:50 -05:00
Gigiaj
7c6c1f53c0 prep visibility for multi-iframes 2025-05-02 17:04:11 -05:00
Gigiaj
8038c2ac8b add isCallActive to memo 2025-05-02 02:35:51 -05:00
GigiaJ
96be8e1b8a add chat button handling for call rooms and impl call room icon 2025-05-01 16:57:28 -05:00
GigiaJ
8c6a6265eb add call as a param to pass 2025-05-01 16:56:58 -05:00
GigiaJ
7ce3b40037 Add call room icon (needs variations based on join rules) 2025-05-01 16:56:47 -05:00
GigiaJ
a5551909c1 Activate when active call state is false 2025-05-01 16:33:39 -05:00
GigiaJ
ec741423c7 add join, screen_state, and hang up handling as well as logging state based on join + hang up actions 2025-05-01 16:24:36 -05:00
GigiaJ
b4d9828017 Add tooltips and properly implement the navlink 2025-05-01 16:19:04 -05:00
GigiaJ
4779b09879 Always embed to give a proper call room lobby experience 2025-05-01 12:48:35 -05:00
GigiaJ
8700fe84ab Add some parameters to be settable (need to create an param object type instead of using any) in widgetUrl 2025-04-29 15:29:02 -05:00
GigiaJ
e76664083d Update transport refs to allow us to properly bind listeners for media state and hangup 2025-04-29 15:28:13 -05:00
GigiaJ
1e8a69ff23 update references to transport to clientwidgetapi (as transport is a child object of) 2025-04-29 15:26:48 -05:00
GigiaJ
b5ca54d6fd Prevents undefined object from being checked for values and filtered against 2025-04-27 16:48:35 -05:00
GigiaJ
d6ffac74a7 Set mic default state to be generally accurate (need to set state from widget info at start instead) 2025-04-22 22:34:25 -04:00
GigiaJ
39d4eedb75 Disable DM calling button for now (not implemented properly yet) 2025-04-22 22:30:48 -04:00
GigiaJ
6691467545 Enable chat toggle for call rooms (still needs cleaner UI, but works as intended) 2025-04-22 22:29:43 -04:00
GigiaJ
c8790a5284 Fix syntaxical mistake causing chat open state to never update 2025-04-22 22:29:07 -04:00
GigiaJ
f7fb4bcc11 Remove chat open icon from DMs 2025-04-22 21:35:04 -04:00
GigiaJ
a4af96b530 Fix DM header since it *can* be a call room 2025-04-22 21:33:17 -04:00
GigiaJ
c7ea3f7e57 The code reuse is substantial enough to justify including the license 2025-04-22 21:17:19 -04:00
GigiaJ
2f980a727a The reuse is substantial enough to justify including the license 2025-04-22 21:16:36 -04:00
GigiaJ
1573353550 re-enable header buttons globally for now 2025-04-22 05:35:35 -04:00
GigiaJ
6d4b37f96d add call status bar call room link 2025-04-22 05:35:13 -04:00
GigiaJ
5677042157 Place holder buttons 2025-04-22 00:27:55 -04:00
GigiaJ
7beb8411a9 Revert casing to fix bottom nav status buttons (should swap back when as an in place option on send) 2025-04-22 00:27:31 -04:00
GigiaJ
02cb7ea9a7 Add a check for space to hide option in space creation 2025-04-21 21:10:27 -04:00
GigiaJ
5ae806444e Remove text chat for now 2025-04-21 21:05:14 -04:00
GigiaJ
39e7de3d21 Add room type option 2025-04-21 21:04:47 -04:00
GigiaJ
0fb5ffc68a add creation handling for calls by setting creation content type 2025-04-21 21:04:15 -04:00
GigiaJ
40a25aa03e Move out bottom nav into own file 2025-04-21 01:39:36 -04:00
GigiaJ
79b4303154 Add call status to bottom of home nav 2025-04-21 01:39:12 -04:00
GigiaJ
7ebf26e34b Add call status to bottom of DMs 2025-04-21 01:38:52 -04:00
GigiaJ
6ad8b477d1 fix bug with calls disabling dm and lobby 2025-04-21 01:38:33 -04:00
GigiaJ
7a03b91c93 Create NavBottom for call status 2025-04-21 01:38:08 -04:00
GigiaJ
0fe90e5e6d Remove comment 2025-04-20 14:39:30 -04:00
GigiaJ
9a3fc59ef0 Adjust to using a provider so we don't load powerlevels in our callcontainer 2025-04-20 14:39:11 -04:00
Gigiaj
1a3b10e32d add a roomview window for calls and rename room to roomId for accuracy 2025-04-18 03:03:13 -05:00
Gigiaj
1ac5b3d8fd add groundwork for call roomtimeline toggle 2025-04-18 03:01:44 -05:00
Gigiaj
8f87690b00 Return to lobby is very useful 2025-04-18 03:01:17 -05:00
Gigiaj
589b318b08 Start laying out a toggle for roomtimeline in a call 2025-04-18 03:00:52 -05:00
Gigiaj
fd0fa3c921 clean up call references 2025-04-18 03:00:31 -05:00
Gigiaj
d602631986 Fixes roomheader layout in calls 2025-04-17 10:22:02 -05:00
Gigiaj
5a5f144e20 remove unneeded for standalone? Not even sure element-web uses the embedded atp 2025-04-17 02:36:29 -05:00
Gigiaj
fd1ccc7281 add mute video and hangup buttons and implemented their functionality 2025-04-17 02:35:19 -05:00
Gigiaj
f76641b538 add in call state (mute/video/hangup) context and transport handling for said contexts 2025-04-17 02:31:34 -05:00
Gigiaj
14206dbb96 handle call room swapping better 2025-04-17 02:26:50 -05:00
Gigiaj
cb9e4ffe86 remove unneeded comments 2025-04-17 02:26:09 -05:00
Gigiaj
8d285d45f7 revert changes on this to b4f67ce 2025-04-17 02:22:40 -05:00
Gigiaj
232fbc5c62 Add active call name into bottom panel 2025-04-16 21:06:03 -05:00
Gigiaj
9b6bfea359 add elementCallUrl 2025-04-16 20:53:30 -05:00
Gigiaj
1629341c08 update to provide client context for the widget url's element call endpoint if provided 2025-04-16 20:53:10 -05:00
Gigiaj
9d352de995 Remove pointless comments, helper funcs, and clean up formatting 2025-04-16 20:52:29 -05:00
Gigiaj
0b6a7c752d fix import order 2025-04-16 20:45:33 -05:00
Gigiaj
28f5d8afe8 Remove unneeded comments 2025-04-16 20:44:33 -05:00
Gigiaj
2c8ae9693b Remove extra comments and unneeded helper function 2025-04-16 20:43:56 -05:00
Gigiaj
0106fab00b clean up to provide elementUrl option (instead of while testing just planting a string with the url) 2025-04-16 20:36:24 -05:00
Gigiaj
38ad5e3f7e add elementCallUrl 2025-04-16 20:17:59 -05:00
Gigiaj
eba9670664 Remove unneeded comments and imports and clean up name of Edget to SmallWidget 2025-04-16 20:13:18 -05:00
Gigiaj
9c5fde3258 Clean up file and remove broken import 2025-04-16 19:49:11 -05:00
Gigiaj
99e55b36c6 reisolate the individual components that need passed values 2025-04-15 22:47:55 -05:00
Gigiaj
cc88733204 readd openid handling 2025-04-15 22:47:30 -05:00
Gigiaj
5f0ca6a794 add callprovider and callactivationeffect 2025-04-15 22:17:10 -05:00
Gigiaj
b88da572a4 update client layout to handle active calls 2025-04-15 22:16:35 -05:00
Gigiaj
c367c90a96 add PersistentCallContainer (moved iframe to here from RoomView) 2025-04-15 22:15:22 -05:00
Gigiaj
d82e49aab8 add callprovider 2025-04-15 22:14:43 -05:00
Gigiaj
cccf9630a0 add CallActivation 2025-04-15 22:14:16 -05:00
Gigiaj
a540c86b08 remove transport testing comments
still need to fix embedded behavior (standalone works for testing rn)
2025-04-15 22:12:05 -05:00
Gigiaj
3403adeb61 add call button for DMs and try to setup for spreading across header for when in call menu (not working yet) 2025-04-15 22:09:45 -05:00
Gigiaj
f39a262eb5 remove iframe from RoomView as it isn't needed there (since you cant have persistence that way) 2025-04-15 22:08:03 -05:00
Gigiaj
ca7691ddc5 Begin setting up for the calling interface to be mounted.
Adds a separator for rooms based on voice vs text rooms
2025-04-15 22:07:32 -05:00
Gigiaj
b4f67ce0ec add prep for DM call button 2025-04-14 17:47:11 -05:00
Gigiaj
e2c97f81a7 remove unneeded imports 2025-04-14 09:49:53 -05:00
Gigiaj
c413396562 Add some missing imports 2025-04-14 09:49:06 -05:00
Gigiaj
8bd812b789 widget class 2025-04-14 09:44:06 -05:00
Gigiaj
9b23c757fe dummy meta class 2025-04-14 09:43:51 -05:00
Gigiaj
e1a080e50c clean up roomview removing unursed and refactoring superfluous to own files 2025-04-14 09:43:36 -05:00
Gigiaj
8baa3a2cf9 set versions 2025-04-14 04:30:23 -05:00
Gigiaj
b59a5ac307 add a generic getKnownRooms 2025-04-14 04:30:13 -05:00
Gigiaj
93a1401b7c shift load order to enable embedded element call to work proper +
add groundwork for intercepting the read_events so we can correctly process them (not sure what is wrong but I believe there is a weird set of version mismatches between the embedded app, cinny, and the matrix widget api)
2025-04-14 04:28:08 -05:00
Gigiaj
ba75abd8dd add the remainder of element's widget driver impl (and correct where needed) 2025-04-13 16:01:05 -05:00
Gigiaj
2fa1e1a187 create a shortened version of element's widget implementation 2025-04-13 16:00:41 -05:00
Gigiaj
7dedb12138 add element-call to the vite copyFiles for build 2025-04-12 03:40:32 -05:00
Gigiaj
aa65dd57ba add scaffolding for widget-based call 2025-04-12 03:40:13 -05:00
Gigiaj
ac0ac4ff35 add small widgetdriver 2025-04-12 03:39:59 -05:00
Gigiaj
389fc17e45 add embedded-element-call and react-sdk-module-api 2025-04-12 03:39:48 -05:00
Gigiaj
7e74fb1462 docker nginx embedded element-call 2025-04-12 03:39:21 -05:00
Gigiaj
c53d14d91e add groupcall state event 2025-04-12 03:38:58 -05:00
63 changed files with 4562 additions and 1194 deletions

View file

@ -1,21 +1,21 @@
{ {
"defaultHomeserver": 2, "defaultHomeserver": 1,
"homeserverList": [ "homeserverList": [
"converser.eu", "converser.eu",
"envs.net",
"matrix.org", "matrix.org",
"monero.social",
"mozilla.org", "mozilla.org",
"unredacted.org",
"xmr.se" "xmr.se"
], ],
"allowCustomHomeservers": true, "allowCustomHomeservers": true,
"elementCallUrl": null,
"featuredCommunities": { "featuredCommunities": {
"openAsDefault": false, "openAsDefault": false,
"spaces": [ "spaces": [
"#cinny-space:matrix.org", "#cinny-space:matrix.org",
"#community:matrix.org", "#community:matrix.org",
"#space:envs.net", "#space:unredacted.org",
"#science-space:matrix.org", "#science-space:matrix.org",
"#libregaming-games:tchncs.de", "#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org" "#mathematics-on:matrix.org"
@ -28,7 +28,7 @@
"#PrivSec.dev:arcticfoxes.net", "#PrivSec.dev:arcticfoxes.net",
"#disroot:aria-net.org" "#disroot:aria-net.org"
], ],
"servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"] "servers": [ "matrix.org", "mozilla.org", "unredacted.org" ]
}, },
"hashRouter": { "hashRouter": {

View file

@ -14,6 +14,8 @@ server {
rewrite ^/public/(.*)$ /public/$1 break; rewrite ^/public/(.*)$ /public/$1 break;
rewrite ^/assets/(.*)$ /assets/$1 break; rewrite ^/assets/(.*)$ /assets/$1 break;
rewrite ^/element-call/dist/(.*)$ /element-call/dist/$1 break;
rewrite ^(.+)$ /index.html break; rewrite ^(.+)$ /index.html break;
} }
} }

48
package-lock.json generated
View file

@ -13,6 +13,7 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14", "@fontsource/inter": "4.5.14",
"@matrix-org/react-sdk-module-api": "2.5.0",
"@tanstack/react-query": "5.24.1", "@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0", "@tanstack/react-virtual": "3.2.0",
@ -32,7 +33,7 @@
"emojibase-data": "15.3.2", "emojibase-data": "15.3.2",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"focus-trap-react": "10.0.2", "focus-trap-react": "10.0.2",
"folds": "2.4.0", "folds": "2.5.0",
"html-dom-parser": "4.0.0", "html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0", "html-react-parser": "4.2.0",
"i18next": "23.12.2", "i18next": "23.12.2",
@ -44,6 +45,7 @@
"linkify-react": "4.1.3", "linkify-react": "4.1.3",
"linkifyjs": "4.1.3", "linkifyjs": "4.1.3",
"matrix-js-sdk": "38.2.0", "matrix-js-sdk": "38.2.0",
"matrix-widget-api": "1.11.0",
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "4.2.67", "pdfjs-dist": "4.2.67",
"prismjs": "1.30.0", "prismjs": "1.30.0",
@ -62,9 +64,11 @@
"slate-dom": "0.112.2", "slate-dom": "0.112.2",
"slate-history": "0.110.3", "slate-history": "0.110.3",
"slate-react": "0.112.1", "slate-react": "0.112.1",
"ua-parser-js": "1.0.35" "ua-parser-js": "1.0.35",
"zod": "4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@element-hq/element-call-embedded": "0.16.3",
"@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3", "@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1", "@rollup/plugin-wasm": "6.1.1",
@ -1649,6 +1653,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@element-hq/element-call-embedded": {
"version": "0.16.3",
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.16.3.tgz",
"integrity": "sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==",
"dev": true
},
"node_modules/@emotion/hash": { "node_modules/@emotion/hash": {
"version": "0.9.2", "version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
@ -2264,6 +2274,18 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/@matrix-org/react-sdk-module-api": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-2.5.0.tgz",
"integrity": "sha512-l/SmiO47gPIRd6YJJGj+B6qbxyypJF6SEsfYr7j9rSW6E85ZYCqf+TpMM2LmfwZRADyKfCVkaJbbBZYpoD02VA==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.17.9"
},
"peerDependencies": {
"react": "^18"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -7157,9 +7179,9 @@
} }
}, },
"node_modules/folds": { "node_modules/folds": {
"version": "2.4.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/folds/-/folds-2.4.0.tgz", "resolved": "https://registry.npmjs.org/folds/-/folds-2.5.0.tgz",
"integrity": "sha512-Q5xCmvU3SIM8etQ9qLF6Y5Jtv01c9JpG3QcnF+Z3nlbMvtktfE13Pj7p0XgSPBcA3OuoU0zXiRwiTlMcbU7KhA==", "integrity": "sha512-UJhvXAQ1XnZ9w10KJwSW+frvzzWE/zcF0dH3fDVCD70RFHAxwEi0UkkVS8CaZGxZF2Wvt3qTJyTS5LW3LwwUAw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peerDependencies": { "peerDependencies": {
"@vanilla-extract/css": "1.9.2", "@vanilla-extract/css": "1.9.2",
@ -8663,9 +8685,9 @@
} }
}, },
"node_modules/matrix-widget-api": { "node_modules/matrix-widget-api": {
"version": "1.13.1", "version": "1.11.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz", "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.11.0.tgz",
"integrity": "sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==", "integrity": "sha512-ED/9hrJqDWVLeED0g1uJnYRhINh3ZTquwurdM+Hc8wLVJIQ8G/r7A7z74NC+8bBIHQ1Jo7i1Uq5CoJp/TzFYrA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@types/events": "^3.0.0", "@types/events": "^3.0.0",
@ -10904,6 +10926,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz",
"integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -12112,6 +12135,15 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zod": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
} }
} }
} }

View file

@ -10,6 +10,7 @@
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview",
"lint": "yarn check:eslint && yarn check:prettier", "lint": "yarn check:eslint && yarn check:prettier",
"check:eslint": "eslint src/*", "check:eslint": "eslint src/*",
"check:prettier": "prettier --check .", "check:prettier": "prettier --check .",
@ -24,6 +25,7 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14", "@fontsource/inter": "4.5.14",
"@matrix-org/react-sdk-module-api": "2.5.0",
"@tanstack/react-query": "5.24.1", "@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0", "@tanstack/react-virtual": "3.2.0",
@ -43,7 +45,7 @@
"emojibase-data": "15.3.2", "emojibase-data": "15.3.2",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"focus-trap-react": "10.0.2", "focus-trap-react": "10.0.2",
"folds": "2.4.0", "folds": "2.5.0",
"html-dom-parser": "4.0.0", "html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0", "html-react-parser": "4.2.0",
"i18next": "23.12.2", "i18next": "23.12.2",
@ -54,6 +56,7 @@
"jotai": "2.6.0", "jotai": "2.6.0",
"linkify-react": "4.1.3", "linkify-react": "4.1.3",
"linkifyjs": "4.1.3", "linkifyjs": "4.1.3",
"matrix-widget-api": "1.11.0",
"matrix-js-sdk": "38.2.0", "matrix-js-sdk": "38.2.0",
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "4.2.67", "pdfjs-dist": "4.2.67",
@ -73,9 +76,11 @@
"slate-dom": "0.112.2", "slate-dom": "0.112.2",
"slate-history": "0.110.3", "slate-history": "0.110.3",
"slate-react": "0.112.1", "slate-react": "0.112.1",
"ua-parser-js": "1.0.35" "ua-parser-js": "1.0.35",
"zod": "4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@element-hq/element-call-embedded": "0.16.3",
"@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3", "@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1", "@rollup/plugin-wasm": "6.1.1",
@ -107,4 +112,4 @@
"vite-plugin-static-copy": "1.0.4", "vite-plugin-static-copy": "1.0.4",
"vite-plugin-top-level-await": "1.4.4" "vite-plugin-top-level-await": "1.4.4"
} }
} }

View file

@ -27,6 +27,7 @@ import { useTextAreaCodeEditor } from '../hooks/useTextAreaCodeEditor';
const EDITOR_INTENT_SPACE_COUNT = 2; const EDITOR_INTENT_SPACE_COUNT = 2;
export type AccountDataSubmitCallback = (type: string, content: object) => Promise<void>; export type AccountDataSubmitCallback = (type: string, content: object) => Promise<void>;
export type AccountDataDeleteCallback = (type: string) => Promise<void>;
type AccountDataInfo = { type AccountDataInfo = {
type: string; type: string;
@ -83,8 +84,7 @@ function AccountDataEdit({
if ( if (
!typeStr || !typeStr ||
parsedContent === null || parsedContent === null
defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
) { ) {
return; return;
} }
@ -121,7 +121,7 @@ function AccountDataEdit({
aria-disabled={submitting} aria-disabled={submitting}
> >
<Box shrink="No" direction="Column" gap="100"> <Box shrink="No" direction="Column" gap="100">
<Text size="L400">Account Data</Text> <Text size="L400">Field Name</Text>
<Box gap="300"> <Box gap="300">
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<Input <Input
@ -195,9 +195,22 @@ function AccountDataEdit({
type AccountDataViewProps = { type AccountDataViewProps = {
type: string; type: string;
defaultContent: string; defaultContent: string;
onEdit: () => void; requestClose: () => void;
onEdit?: () => void;
submitDelete?: AccountDataDeleteCallback;
}; };
function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) { function AccountDataView({ type, defaultContent, onEdit, requestClose, submitDelete }: AccountDataViewProps) {
const [deleteState, deleteCallback] = useAsyncCallback<void, MatrixError, []>(useCallback(
async () => {
if (submitDelete !== undefined) {
await submitDelete(type);
requestClose();
}
},
[type, submitDelete, requestClose],
));
const deleting = deleteState.status === AsyncStatus.Loading;
return ( return (
<Box <Box
direction="Column" direction="Column"
@ -208,7 +221,7 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
> >
<Box shrink="No" gap="300" alignItems="End"> <Box shrink="No" gap="300" alignItems="End">
<Box grow="Yes" direction="Column" gap="100"> <Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Account Data</Text> <Text size="L400">Field Name</Text>
<Input <Input
variant="SurfaceVariant" variant="SurfaceVariant"
size="400" size="400"
@ -218,9 +231,23 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
required required
/> />
</Box> </Box>
<Button variant="Secondary" size="400" radii="300" onClick={onEdit}> {onEdit && (
<Text size="B400">Edit</Text> <Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
</Button> <Text size="B400">Edit</Text>
</Button>
)}
{submitDelete && (
<Button
variant="Critical"
size="400"
radii="300"
disabled={deleting}
before={deleting && <Spinner variant="Critical" fill="Solid" size="300" />}
onClick={deleteCallback}
>
<Text size="B400">Delete</Text>
</Button>
)}
</Box> </Box>
<Box grow="Yes" direction="Column" gap="100"> <Box grow="Yes" direction="Column" gap="100">
<Text size="L400">JSON Content</Text> <Text size="L400">JSON Content</Text>
@ -243,8 +270,9 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
export type AccountDataEditorProps = { export type AccountDataEditorProps = {
type?: string; type?: string;
content?: object; content?: unknown;
submitChange: AccountDataSubmitCallback; submitChange?: AccountDataSubmitCallback;
submitDelete?: AccountDataDeleteCallback;
requestClose: () => void; requestClose: () => void;
}; };
@ -252,6 +280,7 @@ export function AccountDataEditor({
type, type,
content, content,
submitChange, submitChange,
submitDelete,
requestClose, requestClose,
}: AccountDataEditorProps) { }: AccountDataEditorProps) {
const [data, setData] = useState<AccountDataInfo>({ const [data, setData] = useState<AccountDataInfo>({
@ -301,7 +330,7 @@ export function AccountDataEditor({
</Box> </Box>
</PageHeader> </PageHeader>
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
{edit ? ( {(edit && submitChange) ? (
<AccountDataEdit <AccountDataEdit
type={data.type} type={data.type}
defaultContent={contentJSONStr} defaultContent={contentJSONStr}
@ -313,7 +342,9 @@ export function AccountDataEditor({
<AccountDataView <AccountDataView
type={data.type} type={data.type}
defaultContent={contentJSONStr} defaultContent={contentJSONStr}
onEdit={() => setEdit(true)} requestClose={requestClose}
onEdit={submitChange ? () => setEdit(true) : undefined}
submitDelete={submitDelete}
/> />
)} )}
</Box> </Box>

View file

@ -0,0 +1,54 @@
import React, { ReactNode } from 'react';
import { Button, Icon, Icons, Text } from 'folds';
import { SequenceCard } from './sequence-card';
import { SequenceCardStyle } from '../features/settings/styles.css';
import { SettingTile } from './setting-tile';
type CollapsibleCardProps = {
expand: boolean;
setExpand: (expand: boolean) => void;
title?: ReactNode;
description?: ReactNode;
before?: ReactNode;
children?: ReactNode;
};
export function CollapsibleCard({
expand,
setExpand,
title,
description,
before,
children,
}: CollapsibleCardProps) {
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={title}
description={description}
before={before}
after={
<Button
onClick={() => setExpand(!expand)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
before={
<Icon src={expand ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
}
>
<Text size="B300">{expand ? 'Collapse' : 'Expand'}</Text>
</Button>
}
/>
{expand && children}
</SequenceCard>
);
}

View file

@ -11,6 +11,7 @@ import { CreateRoomKind } from './CreateRoomKindSelector';
import { RoomType, StateEvent } from '../../../types/matrix/room'; import { RoomType, StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
import { getMxIdServer } from '../../utils/matrix'; import { getMxIdServer } from '../../utils/matrix';
import { IPowerLevels } from '../../hooks/usePowerLevels';
export const createRoomCreationContent = ( export const createRoomCreationContent = (
type: RoomType | undefined, type: RoomType | undefined,
@ -82,6 +83,44 @@ export const createRoomEncryptionState = () => ({
}, },
}); });
export const createRoomCallState = () => ({
type: 'org.matrix.msc3401.call',
state_key: '',
content: {},
});
export const createPowerLevelContentOverrides = (
base: IPowerLevels,
overrides: Partial<IPowerLevels>
): IPowerLevels => ({
...base,
...overrides,
...(base.events || overrides.events
? {
events: {
...base.events,
...overrides.events,
},
}
: {}),
...(base.users || overrides.users
? {
users: {
...base.users,
...overrides.users,
},
}
: {}),
...(base.notifications || overrides.notifications
? {
notifications: {
...base.notifications,
...overrides.notifications,
},
}
: {}),
});
export type CreateRoomData = { export type CreateRoomData = {
version: string; version: string;
type?: RoomType; type?: RoomType;
@ -94,6 +133,7 @@ export type CreateRoomData = {
knock: boolean; knock: boolean;
allowFederation: boolean; allowFederation: boolean;
additionalCreators?: string[]; additionalCreators?: string[];
powerLevelContentOverrides?: IPowerLevels;
}; };
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => { export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
const initialState: ICreateRoomStateEvent[] = []; const initialState: ICreateRoomStateEvent[] = [];
@ -106,6 +146,10 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
initialState.push(createRoomParentState(data.parent)); initialState.push(createRoomParentState(data.parent));
} }
if (data.type === RoomType.Call) {
initialState.push(createRoomCallState());
}
initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock)); initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock));
const options: ICreateRoomOpts = { const options: ICreateRoomOpts = {
@ -136,5 +180,15 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
); );
} }
if (data.powerLevelContentOverrides) {
const roomPowers = await mx.getStateEvent(result.room_id, StateEvent.RoomPowerLevels, '');
const updatedPowers = createPowerLevelContentOverrides(
roomPowers,
data.powerLevelContentOverrides
);
await mx.sendStateEvent(result.room_id, StateEvent.RoomPowerLevels as any, updatedPowers, '');
}
return result.room_id; return result.room_id;
}; };

View file

@ -174,7 +174,7 @@ export function RoomMentionAutocomplete({
)} )}
/> />
) : ( ) : (
<RoomIcon size="100" joinRule={room.getJoinRule()} space={room.isSpaceRoom()} /> <RoomIcon size="100" joinRule={room.getJoinRule()} roomType={room.getType()} />
)} )}
</Avatar> </Avatar>
} }

View file

@ -2,7 +2,7 @@ import { JoinRule } from 'matrix-js-sdk';
import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds'; import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds';
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react'; import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
import * as css from './RoomAvatar.css'; import * as css from './RoomAvatar.css';
import { joinRuleToIconSrc } from '../../utils/room'; import { getRoomIconSrc } from '../../utils/room';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
type RoomAvatarProps = { type RoomAvatarProps = {
@ -44,13 +44,10 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
export const RoomIcon = forwardRef< export const RoomIcon = forwardRef<
SVGSVGElement, SVGSVGElement,
Omit<ComponentProps<typeof Icon>, 'src'> & { Omit<ComponentProps<typeof Icon>, 'src'> & {
joinRule: JoinRule; joinRule?: JoinRule;
space?: boolean; roomType?: string;
locked?: boolean;
} }
>(({ joinRule, space, ...props }, ref) => ( >(({ joinRule, roomType, locked, ...props }, ref) => (
<Icon <Icon src={getRoomIconSrc(Icons, roomType, joinRule, locked)} {...props} ref={ref} />
src={joinRuleToIconSrc(Icons, joinRule, space || false) ?? Icons.Hash}
{...props}
ref={ref}
/>
)); ));

View file

@ -1,4 +1,4 @@
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; import React, { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
@ -19,6 +19,13 @@ import {
Box, Box,
Scroll, Scroll,
Avatar, Avatar,
TooltipProvider,
Tooltip,
Badge,
Overlay,
OverlayBackdrop,
OverlayCenter,
Modal,
} from 'folds'; } from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdServer } from '../../utils/matrix'; import { getMxIdServer } from '../../utils/matrix';
@ -41,6 +48,11 @@ import { useTimeoutToggle } from '../../hooks/useTimeoutToggle';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { CutoutCard } from '../cutout-card'; import { CutoutCard } from '../cutout-card';
import { SettingTile } from '../setting-tile'; import { SettingTile } from '../setting-tile';
import { useInterval } from '../../hooks/useInterval';
import { TextViewer } from '../text-viewer';
import { ExtendedProfile } from '../../hooks/useExtendedProfile';
import { settingsAtom } from '../../state/settings';
import { useSetting } from '../../state/hooks/settings';
export function ServerChip({ server }: { server: string }) { export function ServerChip({ server }: { server: string }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
@ -436,15 +448,24 @@ export function IgnoredUserAlert() {
); );
} }
export function OptionsChip({ userId }: { userId: string }) { export function OptionsChip({
userId,
extendedProfile,
}: {
userId: string;
extendedProfile: ExtendedProfile | null;
}) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [cords, setCords] = useState<RectCords>(); const [developerToolsEnabled] = useSetting(settingsAtom, 'developerTools');
const open: MouseEventHandler<HTMLButtonElement> = (evt) => { const [profileFieldsOpen, setProfileFieldsOpen] = useState(false);
setCords(evt.currentTarget.getBoundingClientRect()); const [menuCoords, setMenuCoords] = useState<RectCords>();
const openMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCoords(evt.currentTarget.getBoundingClientRect());
}; };
const close = () => setCords(undefined); const closeMenu = () => setMenuCoords(undefined);
const ignoredUsers = useIgnoredUsers(); const ignoredUsers = useIgnoredUsers();
const ignored = ignoredUsers.includes(userId); const ignored = ignoredUsers.includes(userId);
@ -459,56 +480,163 @@ export function OptionsChip({ userId }: { userId: string }) {
const ignoring = ignoreState.status === AsyncStatus.Loading; const ignoring = ignoreState.status === AsyncStatus.Loading;
return ( return (
<PopOut <>
anchor={cords} {extendedProfile && (
position="Bottom" <Overlay open={profileFieldsOpen} backdrop={<OverlayBackdrop />}>
align="Start" <OverlayCenter>
offset={4} <FocusTrap
content={ focusTrapOptions={{
<FocusTrap clickOutsideDeactivates: true,
focusTrapOptions={{ onDeactivate: () => setProfileFieldsOpen(false),
initialFocus: false, escapeDeactivates: stopPropagation,
onDeactivate: close, }}
clickOutsideDeactivates: true, >
escapeDeactivates: stopPropagation, <Modal variant="Surface" size="500">
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt), <TextViewer
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), name="Profile Fields"
}} langName="json"
> text={JSON.stringify(extendedProfile, null, 2)}
<Menu> requestClose={() => setProfileFieldsOpen(false)}
<div style={{ padding: config.space.S100 }}> />
<MenuItem </Modal>
variant="Critical" </FocusTrap>
fill="None" </OverlayCenter>
size="300" </Overlay>
radii="300" )}
onClick={() => { <PopOut
toggleIgnore(); anchor={menuCoords}
close(); position="Bottom"
}} align="Start"
before={ offset={4}
ignoring ? ( content={
<Spinner variant="Critical" size="50" /> <FocusTrap
) : ( focusTrapOptions={{
<Icon size="50" src={Icons.Prohibited} /> initialFocus: false,
) onDeactivate: closeMenu,
} clickOutsideDeactivates: true,
disabled={ignoring} escapeDeactivates: stopPropagation,
> isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
<Text size="B300">{ignored ? 'Unblock User' : 'Block User'}</Text> isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
</MenuItem> }}
</div> >
</Menu> <Menu>
</FocusTrap> <div style={{ padding: config.space.S100 }}>
} <MenuItem
> variant="Critical"
<Chip variant="SurfaceVariant" radii="Pill" onClick={open} aria-pressed={!!cords}> fill="None"
{ignoring ? ( size="300"
<Spinner variant="Secondary" size="50" /> radii="300"
) : ( onClick={() => {
<Icon size="50" src={Icons.HorizontalDots} /> toggleIgnore();
)} closeMenu();
</Chip> }}
</PopOut> before={
ignoring ? (
<Spinner variant="Critical" size="50" />
) : (
<Icon size="50" src={Icons.Prohibited} />
)
}
disabled={ignoring}
>
<Text size="B300">{ignored ? 'Unblock User' : 'Block User'}</Text>
</MenuItem>
{extendedProfile && developerToolsEnabled && (
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="300"
onClick={() => {
setProfileFieldsOpen(true);
closeMenu();
}}
before={<Icon size="50" src={Icons.BlockCode} />}
>
<Text size="B300">View Profile Fields</Text>
</MenuItem>
)}
</div>
</Menu>
</FocusTrap>
}
>
<Chip variant="SurfaceVariant" radii="Pill" onClick={openMenu} aria-pressed={!!menuCoords}>
{ignoring ? (
<Spinner variant="Secondary" size="50" />
) : (
<Icon size="50" src={Icons.HorizontalDots} />
)}
</Chip>
</PopOut>
</>
);
}
export function TimezoneChip({ timezone }: { timezone: string }) {
const shortFormat = useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
dateStyle: undefined,
timeStyle: 'short',
timeZone: timezone,
}),
[timezone]
);
const longFormat = useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
dateStyle: 'long',
timeStyle: 'short',
timeZone: timezone,
}),
[timezone]
);
const [shortTime, setShortTime] = useState(shortFormat.format());
const [longTime, setLongTime] = useState(longFormat.format());
const updateTime = useCallback(() => {
setShortTime(shortFormat.format());
setLongTime(longFormat.format());
}, [setShortTime, setLongTime, shortFormat, longFormat]);
useEffect(() => {
updateTime();
}, [timezone, updateTime]);
useInterval(updateTime, 1000);
return (
<TooltipProvider
position="Top"
offset={5}
align="Center"
tooltip={
<Tooltip variant="SurfaceVariant" style={{ maxWidth: toRem(280) }}>
<Box direction="Column" alignItems="Start" gap="100">
<Box gap="100">
<Text size="L400">Timezone:</Text>
<Badge size="400" variant="Primary">
<Text size="T200">{timezone}</Text>
</Badge>
</Box>
<Text size="T200">{longTime}</Text>
</Box>
</Tooltip>
}
>
{(triggerRef) => (
<Chip
ref={triggerRef}
variant="SurfaceVariant"
radii="Pill"
style={{ cursor: 'initial' }}
before={<Icon size="50" src={Icons.RecentClock} />}
>
<Text size="B300" truncate>
{shortTime}
</Text>
</Chip>
)}
</TooltipProvider>
); );
} }

View file

@ -21,6 +21,7 @@ import { UserPresence } from '../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../presence'; import { AvatarPresence, PresenceBadge } from '../presence';
import { ImageViewer } from '../image-viewer'; import { ImageViewer } from '../image-viewer';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { ExtendedProfile } from '../../hooks/useExtendedProfile';
type UserHeroProps = { type UserHeroProps = {
userId: string; userId: string;
@ -95,9 +96,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
type UserHeroNameProps = { type UserHeroNameProps = {
displayName?: string; displayName?: string;
userId: string; userId: string;
extendedProfile?: ExtendedProfile;
}; };
export function UserHeroName({ displayName, userId }: UserHeroNameProps) { export function UserHeroName({ displayName, userId, extendedProfile }: UserHeroNameProps) {
const username = getMxIdLocalPart(userId); const username = getMxIdLocalPart(userId);
const pronouns = extendedProfile?.["io.fsky.nyx.pronouns"];
return ( return (
<Box grow="Yes" direction="Column" gap="0"> <Box grow="Yes" direction="Column" gap="0">
@ -110,9 +113,10 @@ export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
{displayName ?? username ?? userId} {displayName ?? username ?? userId}
</Text> </Text>
</Box> </Box>
<Box alignItems="Center" gap="100" wrap="Wrap"> <Box alignItems="Start" gap="100" wrap="Wrap" direction='Column'>
<Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}> <Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}>
@{username} @{username}
{pronouns && <span> · {pronouns.map(({ summary }) => summary).join(", ")}</span>}
</Text> </Text>
</Box> </Box>
</Box> </Box>

View file

@ -1,5 +1,5 @@
import { Box, Button, config, Icon, Icons, Text } from 'folds'; import { Box, Button, config, Icon, Icons, Text } from 'folds';
import React from 'react'; import React, { useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { UserHero, UserHeroName } from './UserHero'; import { UserHero, UserHeroName } from './UserHero';
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix'; import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
@ -9,7 +9,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePowerLevels } from '../../hooks/usePowerLevels'; import { usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
import { useUserPresence } from '../../hooks/useUserPresence'; import { useUserPresence } from '../../hooks/useUserPresence';
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips'; import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip, TimezoneChip } from './UserChips';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { PowerChip } from './PowerChip'; import { PowerChip } from './PowerChip';
import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration'; import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration';
@ -22,6 +22,7 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { CreatorChip } from './CreatorChip'; import { CreatorChip } from './CreatorChip';
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils'; import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
import { DirectCreateSearchParams } from '../../pages/paths'; import { DirectCreateSearchParams } from '../../pages/paths';
import { useExtendedProfile } from '../../hooks/useExtendedProfile';
type UserRoomProfileProps = { type UserRoomProfileProps = {
userId: string; userId: string;
@ -56,9 +57,24 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const displayName = getMemberDisplayName(room, userId); const displayName = getMemberDisplayName(room, userId);
const avatarMxc = getMemberAvatarMxc(room, userId); const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
const timezone = useMemo(() => {
// @ts-expect-error Intl.supportedValuesOf isn't in the types yet
const supportedTimezones = Intl.supportedValuesOf('timeZone') as string[];
const profileTimezone = extendedProfile?.['us.cloke.msc4175.tz'];
if (profileTimezone && supportedTimezones.includes(profileTimezone)) {
return profileTimezone;
}
return undefined;
}, [extendedProfile]);
const presence = useUserPresence(userId); const presence = useUserPresence(userId);
useEffect(() => {
refreshExtendedProfile();
}, [refreshExtendedProfile]);
const handleMessage = () => { const handleMessage = () => {
closeUserRoomProfile(); closeUserRoomProfile();
const directSearchParam: DirectCreateSearchParams = { const directSearchParam: DirectCreateSearchParams = {
@ -77,7 +93,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
<Box direction="Column" gap="500" style={{ padding: config.space.S400 }}> <Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
<Box direction="Column" gap="400"> <Box direction="Column" gap="400">
<Box gap="400" alignItems="Start"> <Box gap="400" alignItems="Start">
<UserHeroName displayName={displayName} userId={userId} /> <UserHeroName displayName={displayName} userId={userId} extendedProfile={extendedProfile ?? undefined} />
{userId !== myUserId && ( {userId !== myUserId && (
<Box shrink="No"> <Box shrink="No">
<Button <Button
@ -96,9 +112,10 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
<Box alignItems="Center" gap="200" wrap="Wrap"> <Box alignItems="Center" gap="200" wrap="Wrap">
{server && <ServerChip server={server} />} {server && <ServerChip server={server} />}
<ShareChip userId={userId} /> <ShareChip userId={userId} />
{timezone && <TimezoneChip timezone={timezone} />}
{creator ? <CreatorChip /> : <PowerChip userId={userId} />} {creator ? <CreatorChip /> : <PowerChip userId={userId} />}
{userId !== myUserId && <MutualRoomsChip userId={userId} />} {userId !== myUserId && <MutualRoomsChip userId={userId} />}
{userId !== myUserId && <OptionsChip userId={userId} />} {userId !== myUserId && <OptionsChip userId={userId} extendedProfile={extendedProfile ?? null} />}
</Box> </Box>
</Box> </Box>
{ignored && <IgnoredUserAlert />} {ignored && <IgnoredUserAlert />}

View file

@ -0,0 +1,37 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, config } from 'folds';
import { ContainerColor } from '../../styles/ContainerColor.css';
export const CallViewUserGrid = style({
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
marginInline: '20px',
gap: config.space.S400,
});
export const CallViewUser = style([
DefaultReset,
ContainerColor({ variant: 'SurfaceVariant' }),
{
height: '90px',
width: '150px',
borderRadius: config.radii.R500,
},
]);
export const UserLink = style({
color: 'inherit',
minWidth: 0,
cursor: 'pointer',
flexGrow: 0,
transition: 'all ease-out 200ms',
':hover': {
transform: 'translateY(-3px)',
textDecoration: 'unset',
},
':focus': {
outline: 'none',
},
});

View file

@ -0,0 +1,267 @@
import { EventType, Room } from 'matrix-js-sdk';
import React, {
useContext,
useCallback,
useEffect,
useRef,
MouseEventHandler,
useState,
ReactNode,
} from 'react';
import { Box, Button, config, Spinner, Text } from 'folds';
import { useCallState } from '../../pages/client/call/CallProvider';
import { useCallMembers } from '../../hooks/useCallMemberships';
import { CallRefContext } from '../../pages/client/call/PersistentCallContainer';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useDebounce } from '../../hooks/useDebounce';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { CallViewUser } from './CallViewUser';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import * as css from './CallView.css';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useRoomName } from '../../hooks/useRoomMeta';
type OriginalStyles = {
position?: string;
top?: string;
left?: string;
width?: string;
height?: string;
zIndex?: string;
display?: string;
visibility?: string;
pointerEvents?: string;
border?: string;
};
export function CallViewUserGrid({ children }: { children: ReactNode }) {
return (
<Box
className={css.CallViewUserGrid}
style={{
maxWidth: React.Children.count(children) === 4 ? '336px' : '503px',
}}
>
{children}
</Box>
);
}
export function CallView({ room }: { room: Room }) {
const callIframeRef = useContext(CallRefContext);
const iframeHostRef = useRef<HTMLDivElement>(null);
const originalIframeStylesRef = useRef<OriginalStyles | null>(null);
const mx = useMatrixClient();
const [visibleCallNames, setVisibleCallNames] = useState('');
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const roomName = useRoomName(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canJoin = permissions.event(EventType.GroupCallMemberPrefix, mx.getSafeUserId());
const {
isActiveCallReady,
activeCallRoomId,
isChatOpen,
setActiveCallRoomId,
hangUp,
setViewedCallRoomId,
} = useCallState();
const isActiveCallRoom = activeCallRoomId === room.roomId;
const callIsCurrentAndReady = isActiveCallRoom && isActiveCallReady;
const callMembers = useCallMembers(mx, room.roomId);
const getName = (userId: string) =>
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId);
const memberDisplayNames = callMembers.map((callMembership) =>
getName(callMembership.sender ?? '')
);
const { navigateRoom } = useRoomNavigate();
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const activeIframeDisplayRef = callIframeRef;
const applyFixedPositioningToIframe = useCallback(() => {
const iframeElement = activeIframeDisplayRef?.current;
const hostElement = iframeHostRef?.current;
if (iframeElement && hostElement) {
if (!originalIframeStylesRef.current) {
const computed = window.getComputedStyle(iframeElement);
originalIframeStylesRef.current = {
position: iframeElement.style.position || computed.position,
top: iframeElement.style.top || computed.top,
left: iframeElement.style.left || computed.left,
width: iframeElement.style.width || computed.width,
height: iframeElement.style.height || computed.height,
zIndex: iframeElement.style.zIndex || computed.zIndex,
display: iframeElement.style.display || computed.display,
visibility: iframeElement.style.visibility || computed.visibility,
pointerEvents: iframeElement.style.pointerEvents || computed.pointerEvents,
border: iframeElement.style.border || computed.border,
};
}
const hostRect = hostElement.getBoundingClientRect();
iframeElement.style.position = 'fixed';
iframeElement.style.top = `${hostRect.top}px`;
iframeElement.style.left = `${hostRect.left}px`;
iframeElement.style.width = `${hostRect.width}px`;
iframeElement.style.height = `${hostRect.height}px`;
iframeElement.style.border = 'none';
iframeElement.style.zIndex = '1000';
iframeElement.style.display = room.isCallRoom() ? 'block' : 'none';
iframeElement.style.visibility = 'visible';
iframeElement.style.pointerEvents = 'auto';
}
}, [activeIframeDisplayRef, room]);
const debouncedApplyFixedPositioning = useDebounce(applyFixedPositioningToIframe, {
wait: 50,
immediate: false,
});
useEffect(() => {
const iframeElement = activeIframeDisplayRef?.current;
const hostElement = iframeHostRef?.current;
if (room.isCallRoom() || (callIsCurrentAndReady && iframeElement && hostElement)) {
applyFixedPositioningToIframe();
const resizeObserver = new ResizeObserver(debouncedApplyFixedPositioning);
if (hostElement) resizeObserver.observe(hostElement);
window.addEventListener('scroll', debouncedApplyFixedPositioning, true);
return () => {
resizeObserver.disconnect();
window.removeEventListener('scroll', debouncedApplyFixedPositioning, true);
if (iframeElement && originalIframeStylesRef.current) {
const originalStyles = originalIframeStylesRef.current;
(Object.keys(originalStyles) as Array<keyof OriginalStyles>).forEach((key) => {
if (key in iframeElement.style) {
iframeElement.style[key as any] = originalStyles[key] || '';
}
});
}
originalIframeStylesRef.current = null;
};
}
return undefined;
}, [
activeIframeDisplayRef,
applyFixedPositioningToIframe,
debouncedApplyFixedPositioning,
callIsCurrentAndReady,
room,
]);
const handleJoinVCClick: MouseEventHandler<HTMLElement> = (evt) => {
if (!canJoin) return;
if (isMobile) {
evt.stopPropagation();
setViewedCallRoomId(room.roomId);
navigateRoom(room.roomId);
}
if (!callIsCurrentAndReady) {
hangUp();
setActiveCallRoomId(room.roomId);
}
};
const isCallViewVisible = room.isCallRoom() && (screenSize === ScreenSize.Desktop || !isChatOpen);
useEffect(() => {
if (memberDisplayNames.length <= 2) {
setVisibleCallNames(memberDisplayNames.join(' and '));
} else {
const visible = memberDisplayNames.slice(0, 2);
const remaining = memberDisplayNames.length - 2;
setVisibleCallNames(
`${visible.join(', ')}, and ${remaining} other${remaining > 1 ? 's' : ''}`
);
}
}, [memberDisplayNames]);
return (
<Box grow="Yes" direction="Column" style={{ display: isCallViewVisible ? 'flex' : 'none' }}>
<div
ref={iframeHostRef}
style={{
width: '100%',
height: '100%',
position: 'relative',
pointerEvents: 'none',
display: callIsCurrentAndReady ? 'flex' : 'none',
}}
/>
<Box
grow="Yes"
justifyContent="Center"
alignItems="Center"
direction="Column"
gap="300"
style={{
display: callIsCurrentAndReady ? 'none' : 'flex',
}}
>
<CallViewUserGrid>
{callMembers.slice(0, 6).map((callMember) => (
<CallViewUser key={callMember.membershipID} room={room} callMembership={callMember} />
))}
</CallViewUserGrid>
<Box
direction="Column"
alignItems="Center"
style={{
paddingBlock: config.space.S200,
}}
>
<Text
size="H1"
style={{
paddingBottom: config.space.S300,
}}
>
{roomName}
</Text>
<Text size="T200">
{visibleCallNames !== '' ? visibleCallNames : 'No one'}{' '}
{memberDisplayNames.length > 1 ? 'are' : 'is'} currently in voice
</Text>
</Box>
<Button
variant="Secondary"
disabled={!canJoin || isActiveCallRoom}
onClick={handleJoinVCClick}
>
{isActiveCallRoom ? (
<Box justifyContent="Center" alignItems="Center" gap="200">
<Spinner />
<Text size="B500">{activeCallRoomId === room.roomId ? `Joining` : 'Join Voice'}</Text>
</Box>
) : (
<Text size="B500">{canJoin ? 'Join Voice' : 'Channel Locked'}</Text>
)}
</Button>
</Box>
</Box>
);
}

View file

@ -0,0 +1,71 @@
import { as, Avatar, Box, Icon, Icons, Text } from 'folds';
import React from 'react';
import classNames from 'classnames';
import { Room } from 'matrix-js-sdk';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import { UserAvatar } from '../../components/user-avatar';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart } from '../../utils/matrix';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
import * as css from './CallView.css';
type CallViewUserProps = {
room: Room;
callMembership: CallMembership;
};
export const UserProfileButton = as<'button'>(
({ as: AsUserProfileButton = 'button', className, ...props }, ref) => (
<AsUserProfileButton className={classNames(css.UserLink, className)} {...props} ref={ref} />
)
);
export const CallViewUserBase = as<'div'>(({ className, ...props }, ref) => (
<Box
direction="Column"
gap="300"
className={classNames(css.CallViewUser, className)}
{...props}
ref={ref}
/>
));
export function CallViewUser({ room, callMembership }: CallViewUserProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const openProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const userId = callMembership.sender ?? '';
const avatarMxcUrl = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication)
: undefined;
const getName = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId);
const handleUserClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
openProfile(room.roomId, space?.roomId, userId, evt.currentTarget.getBoundingClientRect());
};
return (
<UserProfileButton onClick={handleUserClick} aria-label={getName}>
<CallViewUserBase>
<Box direction="Column" grow="Yes" alignItems="Center" gap="200" justifyContent="Center">
<Avatar size="200">
<UserAvatar
userId={userId}
src={avatarUrl ?? undefined}
alt={getName}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
<Text size="B400" priority="300" truncate>
{getName}
</Text>
</Box>
</CallViewUserBase>
</UserProfileButton>
);
}

View file

@ -0,0 +1,9 @@
import { Widget } from 'matrix-widget-api';
import { IApp } from './SmallWidget';
// Wrapper class for the widget definition
export class CinnyWidget extends Widget {
public constructor(private rawDefinition: IApp) {
super(rawDefinition);
}
}

View file

@ -0,0 +1,397 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2020-2023 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import EventEmitter from 'events';
import {
ClientEvent,
Direction,
IEvent,
KnownMembership,
MatrixClient,
MatrixEvent,
MatrixEventEvent,
} from 'matrix-js-sdk';
import {
ClientWidgetApi,
IRoomEvent,
IStickyActionRequest,
IWidget,
IWidgetData,
MatrixCapabilities,
WidgetApiFromWidgetAction,
WidgetKind,
} from 'matrix-widget-api';
import { CinnyWidget } from './CinnyWidget';
import { SmallWidgetDriver } from './SmallWidgetDriver';
/**
* Generates the URL for the Element Call widget.
* @param mx - The MatrixClient instance.
* @param roomId - The ID of the room.
* @returns The generated URL object.
*/
export const getWidgetUrl = (
mx: MatrixClient,
roomId: string,
elementCallUrl: string,
widgetId: string,
setParams: any
): URL => {
const baseUrl = window.location.origin;
const url = elementCallUrl
? new URL(`${elementCallUrl}/room`)
: new URL('/public/element-call/index.html#', baseUrl);
const params = new URLSearchParams({
embed: 'true',
widgetId,
appPrompt: 'false',
skipLobby: setParams.skipLobby ?? 'true', // TODO: skipLobby is deprecated, use intent instead (intent doesn't produce the same effect?)
returnToLobby: setParams.returnToLobby ?? 'true',
perParticipantE2EE: setParams.perParticipantE2EE ?? 'true',
header: 'none',
confineToRoom: 'true',
theme: setParams.theme ?? 'dark',
userId: mx.getUserId()!,
deviceId: mx.getDeviceId()!,
roomId,
baseUrl: mx.baseUrl!,
parentUrl: window.location.origin,
});
const replacedParams = params.toString().replace(/%24/g, '$');
url.search = `?${replacedParams}`;
return url;
};
export interface IApp extends IWidget {
client: MatrixClient;
roomId: string;
eventId?: string;
avatar_url?: string;
sender: string;
'io.element.managed_hybrid'?: boolean;
}
export class SmallWidget extends EventEmitter {
private client: MatrixClient;
private messaging: ClientWidgetApi | null = null;
private mockWidget: CinnyWidget;
public roomId?: string;
public url?: string;
public iframe: HTMLIFrameElement | null = null;
private type: string; // Type of the widget (e.g., 'm.call')
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
private readonly eventsToFeed = new WeakSet<MatrixEvent>();
private stickyPromise?: () => Promise<void>;
constructor(private iapp: IApp) {
super();
this.client = iapp.client;
this.roomId = iapp.roomId;
this.url = iapp.url;
this.type = iapp.type;
this.mockWidget = new CinnyWidget(iapp);
}
/**
* Initializes the widget messaging API.
* @param iframe - The HTMLIFrameElement to bind to.
* @returns The initialized ClientWidgetApi instance.
*/
startMessaging(iframe: HTMLIFrameElement): ClientWidgetApi {
// Ensure the driver is correctly instantiated
// The capabilities array might need adjustment based on required permissions
const driver = new SmallWidgetDriver(
this.client,
[],
this.mockWidget,
WidgetKind.Room,
true,
this.roomId
);
this.iframe = iframe;
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
// Emit events during the widget lifecycle
this.messaging.on('preparing', () => this.emit('preparing'));
this.messaging.on('error:preparing', (err: unknown) => this.emit('error:preparing', err));
this.messaging.once('ready', () => this.emit('ready'));
// this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified")); // Uncomment if needed
// Populate the map of "read up to" events for this widget with the current event in every room.
// This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
// requests timeline capabilities in other rooms down the road. It's just easier to manage here.
// eslint-disable-next-line no-restricted-syntax
for (const room of this.client.getRooms()) {
// Timelines are most recent last
const events = room.getLiveTimeline()?.getEvents() || [];
const roomEvent = events[events.length - 1];
// force later code to think the room is fresh
if (roomEvent) {
const eventId = roomEvent.getId();
if (eventId) this.readUpToMap[room.roomId] = eventId;
}
}
this.messaging.on('action:org.matrix.msc2876.read_events', (ev: CustomEvent) => {
const room = this.client.getRoom(this.roomId);
const events: Partial<IEvent>[] = [];
const { type } = ev.detail.data;
ev.preventDefault();
if (room === null) {
return this.messaging?.transport.reply(ev.detail, { events });
}
const state = room.getLiveTimeline().getState(Direction.Forward);
if (state === undefined) {
return this.messaging?.transport.reply(ev.detail, { events });
}
const stateEvents = state.events?.get(type);
Array.from(stateEvents?.values() ?? []).forEach((eventObject) => {
events.push(eventObject.event);
});
return this.messaging?.transport.reply(ev.detail, { events });
});
this.client.on(ClientEvent.Event, this.onEvent);
this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
this.messaging.on(
`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`,
async (ev: CustomEvent<IStickyActionRequest>) => {
if (this.messaging?.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
ev.preventDefault();
if (ev.detail.data.value) {
// If the widget wants to become sticky we wait for the stickyPromise to resolve
if (this.stickyPromise) await this.stickyPromise();
this.messaging.transport.reply(ev.detail, {});
}
// Stop being persistent can be done instantly
// MAKE PERSISTENT HERE
// Send the ack after the widget actually has become sticky.
}
}
);
return this.messaging;
}
private onEvent = (ev: MatrixEvent): void => {
this.client.decryptEventIfNeeded(ev);
this.feedEvent(ev);
};
private onEventDecrypted = (ev: MatrixEvent): void => {
this.feedEvent(ev);
};
private onReadEvent = (ev: MatrixEvent): void => {
this.feedEvent(ev);
};
private onToDeviceEvent = async (ev: MatrixEvent): Promise<void> => {
await this.client.decryptEventIfNeeded(ev);
if (ev.isDecryptionFailure()) return;
await this.messaging?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted());
};
/**
* Determines whether the event comes from a room that we've been invited to
* (in which case we likely don't have the full timeline).
*/
private isFromInvite(ev: MatrixEvent): boolean {
const room = this.client.getRoom(ev.getRoomId());
return room?.getMyMembership() === KnownMembership.Invite;
}
/**
* Determines whether the event has a relation to an unknown parent.
*/
private relatesToUnknown(ev: MatrixEvent): boolean {
// Replies to unknown events don't count
if (!ev.relationEventId || ev.replyEventId) return false;
const room = this.client.getRoom(ev.getRoomId());
return room === null || !room.findEventById(ev.relationEventId);
}
// eslint-disable-next-line class-methods-use-this
private arrayFastClone<T>(a: T[]): T[] {
return a.slice(0, a.length);
}
private advanceReadUpToMarker(ev: MatrixEvent): boolean {
const evId = ev.getId();
if (evId === undefined) return false;
const roomId = ev.getRoomId();
if (roomId === undefined) return false;
const room = this.client.getRoom(roomId);
if (room === null) return false;
const upToEventId = this.readUpToMap[ev.getRoomId()!];
if (!upToEventId) {
// There's no marker yet; start it at this event
this.readUpToMap[roomId] = evId;
return true;
}
// Small optimization for exact match (skip the search)
if (upToEventId === evId) return false;
// Timelines are most recent last, so reverse the order and limit ourselves to 100 events
// to avoid overusing the CPU.
const timeline = room.getLiveTimeline();
const events = this.arrayFastClone(timeline.getEvents()).reverse().slice(0, 100);
let advanced = false;
events.some((timelineEvent) => {
const id = timelineEvent.getId();
if (id === upToEventId) {
// The event must be somewhere before the "read up to" marker
return true;
}
if (id === evId) {
// The event is after the marker; advance it
this.readUpToMap[roomId] = evId;
advanced = true;
return true;
}
// We can't say for sure whether the widget has seen the event; let's
// just assume that it has
return false;
});
return advanced;
}
private feedEvent(ev: MatrixEvent): void {
if (this.messaging === null) return;
if (
// If we had decided earlier to feed this event to the widget, but
// it just wasn't ready, give it another try
this.eventsToFeed.delete(ev) ||
// Skip marker timeline check for events with relations to unknown parent because these
// events are not added to the timeline here and will be ignored otherwise:
// https://github.com/matrix-org/matrix-js-sdk/blob/d3dfcd924201d71b434af3d77343b5229b6ed75e/src/models/room.ts#L2207-L2213
this.relatesToUnknown(ev) ||
// Skip marker timeline check for rooms where membership is
// 'invite', otherwise the membership event from the invitation room
// will advance the marker and new state events will not be
// forwarded to the widget.
this.isFromInvite(ev) ||
// Check whether this event would be before or after our "read up to" marker. If it's
// before, or we can't decide, then we assume the widget will have already seen the event.
// If the event is after, or we don't have a marker for the room, then the marker will advance and we'll
// send it through.
// This approach of "read up to" prevents widgets receiving decryption spam from startup or
// receiving ancient events from backfill and such.
this.advanceReadUpToMarker(ev)
) {
// If the event is still being decrypted, remember that we want to
// feed it to the widget (even if not strictly in the order given by
// the timeline) and get back to it later
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) {
this.eventsToFeed.add(ev);
} else {
const raw = ev.getEffectiveEvent();
this.messaging.feedEvent(raw as IRoomEvent, this.roomId ?? '').catch(() => null);
}
}
}
/**
* Stops the widget messaging and cleans up resources.
*/
stopMessaging() {
if (this.messaging) {
this.messaging.stop(); // Example if a stop method exists
this.messaging.removeAllListeners(); // Remove listeners attached by SmallWidget
this.messaging = null;
}
}
}
/**
* Creates the data object for the widget.
* @param client - The MatrixClient instance.
* @param roomId - The ID of the room.
* @param currentData - Existing widget data.
* @param overwriteData - Data to merge or overwrite.
* @returns The final widget data object.
*/
export const getWidgetData = (
client: MatrixClient,
roomId: string,
currentData: object,
overwriteData: object
): IWidgetData => {
// Example: Determine E2EE based on room state if needed
const perParticipantE2EE = true; // Default or based on logic
// const roomEncryption = client.getRoom(roomId)?.currentState.getStateEvents(EventType.RoomEncryption, "");
// if (roomEncryption) perParticipantE2EE = true; // Simplified example
return {
...currentData,
...overwriteData,
perParticipantE2EE,
};
};
/**
* Creates a virtual widget definition (IApp).
* @param client - MatrixClient instance.
* @param id - Widget ID.
* @param creatorUserId - User ID of the creator.
* @param name - Widget display name.
* @param type - Widget type (e.g., 'm.call').
* @param url - Widget URL.
* @param waitForIframeLoad - Whether to wait for iframe load signal.
* @param data - Widget data.
* @param roomId - Room ID.
* @returns The IApp widget definition.
*/
export const createVirtualWidget = (
client: MatrixClient,
id: string,
creatorUserId: string,
name: string,
type: string,
url: URL,
waitForIframeLoad: boolean,
data: IWidgetData,
roomId: string
): IApp => ({
client,
id,
creatorUserId,
name,
type,
url: url.toString(), // Store URL as string in the definition
waitForIframeLoad,
data,
roomId,
// Add other required fields from IWidget if necessary
sender: creatorUserId, // Example: Assuming sender is the creator
});

View file

@ -0,0 +1,551 @@
/* eslint-disable no-return-await */
/* eslint-disable no-param-reassign */
/* eslint-disable no-continue */
/* eslint-disable class-methods-use-this */
/* eslint-disable no-dupe-class-members */
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2020-2023 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import {
type Capability,
EventDirection,
type ISendDelayedEventDetails,
type ISendEventDetails,
type IReadEventRelationsResult,
type IRoomEvent,
MatrixCapabilities,
type Widget,
WidgetDriver,
WidgetEventCapability,
WidgetKind,
type IWidgetApiErrorResponseDataDetails,
type ISearchUserDirectoryResult,
type IGetMediaConfigResult,
type UpdateDelayedEventAction,
OpenIDRequestState,
SimpleObservable,
IOpenIDUpdate,
} from 'matrix-widget-api';
import {
EventType,
type IContent,
MatrixError,
type MatrixEvent,
Direction,
type SendDelayedEventResponse,
type StateEvents,
type TimelineEvents,
MatrixClient,
} from 'matrix-js-sdk';
export class SmallWidgetDriver extends WidgetDriver {
private allowedCapabilities: Set<Capability>;
private readonly mxClient: MatrixClient; // Store the client instance
public constructor(
mx: MatrixClient,
allowedCapabilities: Capability[],
private forWidget: Widget,
private forWidgetKind: WidgetKind,
virtual: boolean, // Assuming 'virtual' might be needed later, kept for consistency
private inRoomId?: string
) {
super();
this.mxClient = mx; // Store the passed instance
this.allowedCapabilities = new Set([
...allowedCapabilities,
MatrixCapabilities.Screenshots,
// Add other base capabilities as needed, e.g., ElementWidgetCapabilities.RequiresClient
]);
// --- Capabilities specific to Element Call (or similar trusted widgets) ---
// This is a trusted Element Call widget that we control (adjust if not Element Call)
this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen);
this.allowedCapabilities.add(MatrixCapabilities.MSC3846TurnServers);
this.allowedCapabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent);
this.allowedCapabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent);
// Capability to access the room timeline (MSC2762)
this.allowedCapabilities.add(`org.matrix.msc2762.timeline:${inRoomId}`);
// Capability to read room state (MSC2762)
this.allowedCapabilities.add(`org.matrix.msc2762.state:${inRoomId}`);
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw
);
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Receive, 'org.matrix.msc3401.call').raw
);
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomEncryption).raw
);
const clientUserId = this.mxClient.getSafeUserId();
// For the legacy membership type
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(
EventDirection.Send,
'org.matrix.msc3401.call.member',
clientUserId
).raw
);
const clientDeviceId = this.mxClient.getDeviceId();
if (clientDeviceId !== null) {
// For the session membership type compliant with MSC4143
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(
EventDirection.Send,
'org.matrix.msc3401.call.member',
`_${clientUserId}_${clientDeviceId}`
).raw
);
// Version with no leading underscore, for room versions whose auth rules allow it
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(
EventDirection.Send,
'org.matrix.msc3401.call.member',
`${clientUserId}_${clientDeviceId}`
).raw
);
}
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Receive, 'org.matrix.msc3401.call.member')
.raw
);
// for determining auth rules specific to the room version
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomCreate).raw
);
const sendRecvRoomEvents = [
'io.element.call.encryption_keys',
'org.matrix.rageshake_request',
EventType.Reaction,
EventType.RoomRedaction,
'io.element.call.reaction',
];
// eslint-disable-next-line no-restricted-syntax
for (const eventType of sendRecvRoomEvents) {
this.allowedCapabilities.add(
WidgetEventCapability.forRoomEvent(EventDirection.Send, eventType).raw
);
this.allowedCapabilities.add(
WidgetEventCapability.forRoomEvent(EventDirection.Receive, eventType).raw
);
}
const sendRecvToDevice = [
EventType.CallInvite,
EventType.CallCandidates,
EventType.CallAnswer,
EventType.CallHangup,
EventType.CallReject,
EventType.CallSelectAnswer,
EventType.CallNegotiate,
EventType.CallSDPStreamMetadataChanged,
EventType.CallSDPStreamMetadataChangedPrefix,
EventType.CallReplaces,
EventType.CallEncryptionKeysPrefix,
];
// eslint-disable-next-line no-restricted-syntax
for (const eventType of sendRecvToDevice) {
this.allowedCapabilities.add(
WidgetEventCapability.forToDeviceEvent(EventDirection.Send, eventType).raw
);
this.allowedCapabilities.add(
WidgetEventCapability.forToDeviceEvent(EventDirection.Receive, eventType).raw
);
}
}
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
// Stubbed under the assumption voice calls will be valid thru element-call
return requested;
}
public async sendEvent<K extends keyof StateEvents>(
eventType: K,
content: StateEvents[K],
stateKey: string | null,
targetRoomId: string | null
): Promise<ISendEventDetails>;
public async sendEvent<K extends keyof TimelineEvents>(
eventType: K,
content: TimelineEvents[K],
stateKey: null,
targetRoomId: string | null
): Promise<ISendEventDetails>;
public async sendEvent(
eventType: string,
content: IContent,
stateKey: string | null = null,
targetRoomId: string | null = null
): Promise<ISendEventDetails> {
const client = this.mxClient;
const roomId = targetRoomId || this.inRoomId;
if (!client || !roomId) throw new Error('Not in a room or not attached to a client');
let r: { event_id: string } | null;
if (stateKey !== null) {
// state event
r = await client.sendStateEvent(
roomId,
eventType as keyof StateEvents,
content as StateEvents[keyof StateEvents],
stateKey
);
} else if (eventType === EventType.RoomRedaction) {
// special case: extract the `redacts` property and call redact
r = await client.redactEvent(roomId, content.redacts);
} else {
// message event
r = await client.sendEvent(
roomId,
eventType as keyof TimelineEvents,
content as TimelineEvents[keyof TimelineEvents]
);
}
return { roomId, eventId: r.event_id };
}
/**
* @experimental Part of MSC4140 & MSC4157
* @see {@link WidgetDriver#sendDelayedEvent}
*/
public async sendDelayedEvent<K extends keyof StateEvents>(
delay: number | null,
parentDelayId: string | null,
eventType: K,
content: StateEvents[K],
stateKey: string | null,
targetRoomId: string | null
): Promise<ISendDelayedEventDetails>;
/**
* @experimental Part of MSC4140 & MSC4157
*/
public async sendDelayedEvent<K extends keyof TimelineEvents>(
delay: number | null,
parentDelayId: string | null,
eventType: K,
content: TimelineEvents[K],
stateKey: null,
targetRoomId: string | null
): Promise<ISendDelayedEventDetails>;
public async sendDelayedEvent(
delay: number | null,
parentDelayId: string | null,
eventType: string,
content: IContent,
stateKey: string | null = null,
targetRoomId: string | null = null
): Promise<ISendDelayedEventDetails> {
const client = this.mxClient;
const roomId = targetRoomId || this.inRoomId;
if (!client || !roomId) throw new Error('Not in a room or not attached to a client');
let delayOpts;
if (delay !== null) {
delayOpts = {
delay,
...(parentDelayId !== null && { parent_delay_id: parentDelayId }),
};
} else if (parentDelayId !== null) {
delayOpts = {
parent_delay_id: parentDelayId,
};
} else {
throw new Error('Must provide at least one of delay or parentDelayId');
}
let r: SendDelayedEventResponse | null;
if (stateKey !== null) {
// state event
r = await client._unstable_sendDelayedStateEvent(
roomId,
delayOpts,
eventType as keyof StateEvents,
content as StateEvents[keyof StateEvents],
stateKey
);
} else {
// message event
r = await client._unstable_sendDelayedEvent(
roomId,
delayOpts,
null,
eventType as keyof TimelineEvents,
content as TimelineEvents[keyof TimelineEvents]
);
}
return {
roomId,
delayId: r.delay_id,
};
}
/**
* @experimental Part of MSC4140 & MSC4157
*/
public async updateDelayedEvent(
delayId: string,
action: UpdateDelayedEventAction
): Promise<void> {
const client = this.mxClient;
if (!client) throw new Error('Not in a room or not attached to a client');
await client._unstable_updateDelayedEvent(delayId, action);
}
/**
* Implements {@link WidgetDriver#sendToDevice}
*/
public async sendToDevice(
eventType: string,
encrypted: boolean,
contentMap: { [userId: string]: { [deviceId: string]: object } }
): Promise<void> {
const client = this.mxClient;
if (encrypted) {
const crypto = client.getCrypto();
if (!crypto) throw new Error('E2EE not enabled');
// attempt to re-batch these up into a single request
const invertedContentMap: { [content: string]: { userId: string; deviceId: string }[] } = {};
// eslint-disable-next-line no-restricted-syntax
for (const userId of Object.keys(contentMap)) {
const userContentMap = contentMap[userId];
// eslint-disable-next-line no-restricted-syntax
for (const deviceId of Object.keys(userContentMap)) {
const content = userContentMap[deviceId];
const stringifiedContent = JSON.stringify(content);
invertedContentMap[stringifiedContent] = invertedContentMap[stringifiedContent] || [];
invertedContentMap[stringifiedContent].push({ userId, deviceId });
}
}
await Promise.all(
Object.entries(invertedContentMap).map(async ([stringifiedContent, recipients]) => {
const batch = await crypto.encryptToDeviceMessages(
eventType,
recipients,
JSON.parse(stringifiedContent)
);
await client.queueToDevice(batch);
})
);
} else {
await client.queueToDevice({
eventType,
batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) =>
Object.entries(userContentMap).map(([deviceId, content]) => ({
userId,
deviceId,
payload: content,
}))
),
});
}
}
/**
* Reads all events of the given type, and optionally `msgtype` (if applicable/defined),
* the user has access to. The widget API will have already verified that the widget is
* capable of receiving the events. Less events than the limit are allowed to be returned,
* but not more.
* @param roomId The ID of the room to look within.
* @param eventType The event type to be read.
* @param msgtype The msgtype of the events to be read, if applicable/defined.
* @param stateKey The state key of the events to be read, if applicable/defined.
* @param limit The maximum number of events to retrieve. Will be zero to denote "as many as
* possible".
* @param since When null, retrieves the number of events specified by the "limit" parameter.
* Otherwise, the event ID at which only subsequent events will be returned, as many as specified
* in "limit".
* @returns {Promise<IRoomEvent[]>} Resolves to the room events, or an empty array.
*/
public async readRoomTimeline(
roomId: string,
eventType: string,
msgtype: string | undefined,
stateKey: string | undefined,
limit: number,
since: string | undefined
): Promise<IRoomEvent[]> {
limit = limit > 0 ? Math.min(limit, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
const room = this.mxClient.getRoom(roomId);
if (room === null) return [];
const results: MatrixEvent[] = [];
const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
for (let i = events.length - 1; i >= 0; i--) {
const ev = events[i];
if (results.length >= limit) break;
if (since !== undefined && ev.getId() === since) break;
if (ev.getType() !== eventType || ev.isState()) continue;
if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent().msgtype)
continue;
if (ev.getStateKey() !== undefined && stateKey !== undefined && ev.getStateKey() !== stateKey)
continue;
results.push(ev);
}
return results.map((e) => e.getEffectiveEvent() as IRoomEvent);
}
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
return observer.update({
state: OpenIDRequestState.Allowed,
token: await this.mxClient.getOpenIdToken(),
});
}
/**
* Reads the current values of all matching room state entries.
* @param roomId The ID of the room.
* @param eventType The event type of the entries to be read.
* @param stateKey The state key of the entry to be read. If undefined,
* all room state entries with a matching event type should be returned.
* @returns {Promise<IRoomEvent[]>} Resolves to the events representing the
* current values of the room state entries.
*/
public async readRoomState(
roomId: string,
eventType: string,
stateKey: string | undefined
): Promise<IRoomEvent[]> {
const room = this.mxClient.getRoom(roomId);
if (room === null) return [];
const state = room.getLiveTimeline().getState(Direction.Forward);
if (state === undefined) return [];
if (stateKey === undefined)
return state.getStateEvents(eventType).map((e) => e.getEffectiveEvent() as IRoomEvent);
const event = state.getStateEvents(eventType, stateKey);
return event === null ? [] : [event.getEffectiveEvent() as IRoomEvent];
}
/*
public async navigate(uri: string): Promise<void> {
navigateToPermalink(uri);
}
*/
public async readEventRelations(
eventId: string,
roomId?: string,
relationType?: string,
eventType?: string,
from?: string,
to?: string,
limit?: number,
direction?: 'f' | 'b'
): Promise<IReadEventRelationsResult> {
const client = this.mxClient;
const dir = direction as Direction;
roomId = roomId ?? this.inRoomId ?? undefined;
if (typeof roomId !== 'string') {
throw new Error('Error while reading the current room');
}
const { events, nextBatch, prevBatch } = await client.relations(
roomId,
eventId,
relationType ?? null,
eventType ?? null,
{ from, to, limit, dir }
);
return {
chunk: events.map((e) => e.getEffectiveEvent() as IRoomEvent),
nextBatch: nextBatch ?? undefined,
prevBatch: prevBatch ?? undefined,
};
}
public async searchUserDirectory(
searchTerm: string,
limit?: number
): Promise<ISearchUserDirectoryResult> {
const client = this.mxClient;
const { limited, results } = await client.searchUserDirectory({ term: searchTerm, limit });
return {
limited,
results: results.map((r) => ({
userId: r.user_id,
displayName: r.display_name,
avatarUrl: r.avatar_url,
})),
};
}
public async getMediaConfig(): Promise<IGetMediaConfigResult> {
const client = this.mxClient;
return await client.getMediaConfig();
}
public async uploadFile(file: XMLHttpRequestBodyInit): Promise<{ contentUri: string }> {
const client = this.mxClient;
const uploadResult = await client.uploadContent(file);
return { contentUri: uploadResult.content_uri };
}
/**
* Download a file from the media repository on the homeserver.
*
* @param contentUri - the MXC URI of the file to download
* @returns an object with: file - response contents as Blob
*/
/*
public async downloadFile(contentUri: string): Promise<{ file: XMLHttpRequestBodyInit }> {
const client = this.mxClient;
const media = mediaFromMxc(contentUri, client);
const response = await media.downloadSource();
const blob = await response.blob();
return { file: blob };
}
*/
/**
* Gets the IDs of all joined or invited rooms currently known to the
* client.
* @returns The room IDs.
*/
public getKnownRooms(): string[] {
return this.mxClient.getVisibleRooms().map((r) => r.roomId);
}
/**
* Expresses a {@link MatrixError} as a JSON payload
* for use by Widget API error responses.
* @param error The error to handle.
* @returns The error expressed as a JSON payload,
* or undefined if it is not a {@link MatrixError}.
*/
public processError(error: unknown): IWidgetApiErrorResponseDataDetails | undefined {
return error instanceof MatrixError
? { matrix_api_error: error.asWidgetApiErrorData() }
: undefined;
}
}

View file

@ -30,6 +30,7 @@ import {
AccountDataSubmitCallback, AccountDataSubmitCallback,
} from '../../../components/AccountDataEditor'; } from '../../../components/AccountDataEditor';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { CollapsibleCard } from '../../../components/CollapsibleCard';
type DeveloperToolsProps = { type DeveloperToolsProps = {
requestClose: () => void; requestClose: () => void;
@ -175,216 +176,166 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
} }
/> />
</SequenceCard> </SequenceCard>
<SequenceCard <CollapsibleCard
className={SequenceCardStyle} expand={expandState}
variant="SurfaceVariant" setExpand={setExpandState}
direction="Column" title="Room State"
gap="400" description="State events of the room."
> >
<SettingTile <Box direction="Column" gap="100">
title="Room State" <Box justifyContent="SpaceBetween">
description="State events of the room." <Text size="L400">Events</Text>
after={ <Text size="L400">Total: {roomState.size}</Text>
<Button </Box>
onClick={() => setExpandState(!expandState)} <CutoutCard>
variant="Secondary" <MenuItem
fill="Soft" onClick={() => setComposeEvent({ stateKey: '' })}
variant="Surface"
fill="None"
size="300" size="300"
radii="300" radii="0"
outlined before={<Icon size="50" src={Icons.Plus} />}
before={
<Icon
src={expandState ? Icons.ChevronTop : Icons.ChevronBottom}
size="100"
filled
/>
}
> >
<Text size="B300">{expandState ? 'Collapse' : 'Expand'}</Text> <Box grow="Yes">
</Button> <Text size="T200" truncate>
} Add New
/> </Text>
{expandState && ( </Box>
<Box direction="Column" gap="100"> </MenuItem>
<Box justifyContent="SpaceBetween"> {Array.from(roomState.keys())
<Text size="L400">Events</Text> .sort()
<Text size="L400">Total: {roomState.size}</Text> .map((eventType) => {
</Box> const expanded = eventType === expandStateType;
<CutoutCard> const stateKeyToEvents = roomState.get(eventType);
<MenuItem if (!stateKeyToEvents) return null;
onClick={() => setComposeEvent({ stateKey: '' })}
variant="Surface"
fill="None"
size="300"
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
>
<Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
</Box>
</MenuItem>
{Array.from(roomState.keys())
.sort()
.map((eventType) => {
const expanded = eventType === expandStateType;
const stateKeyToEvents = roomState.get(eventType);
if (!stateKeyToEvents) return null;
return ( return (
<Box id={eventType} key={eventType} direction="Column" gap="100"> <Box id={eventType} key={eventType} direction="Column" gap="100">
<MenuItem <MenuItem
onClick={() => onClick={() =>
setExpandStateType(expanded ? undefined : eventType) setExpandStateType(expanded ? undefined : eventType)
} }
variant="Surface" variant="Surface"
fill="None" fill="None"
size="300" size="300"
radii="0" radii="0"
before={ before={
<Icon <Icon
size="50" size="50"
src={expanded ? Icons.ChevronBottom : Icons.ChevronRight} src={expanded ? Icons.ChevronBottom : Icons.ChevronRight}
/> />
} }
after={<Text size="L400">{stateKeyToEvents.size}</Text>} after={<Text size="L400">{stateKeyToEvents.size}</Text>}
>
<Box grow="Yes">
<Text size="T200" truncate>
{eventType}
</Text>
</Box>
</MenuItem>
{expanded && (
<div
style={{
marginLeft: config.space.S400,
borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
> >
<Box grow="Yes"> <MenuItem
<Text size="T200" truncate> onClick={() =>
{eventType} setComposeEvent({ type: eventType, stateKey: '' })
</Text> }
</Box> variant="Surface"
</MenuItem> fill="None"
{expanded && ( size="300"
<div radii="0"
style={{ before={<Icon size="50" src={Icons.Plus} />}
marginLeft: config.space.S400,
borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
> >
<MenuItem <Box grow="Yes">
onClick={() => <Text size="T200" truncate>
setComposeEvent({ type: eventType, stateKey: '' }) Add New
} </Text>
variant="Surface" </Box>
fill="None" </MenuItem>
size="300" {Array.from(stateKeyToEvents.keys())
radii="0" .sort()
before={<Icon size="50" src={Icons.Plus} />} .map((stateKey) => (
> <MenuItem
<Box grow="Yes"> onClick={() => {
<Text size="T200" truncate> setOpenStateEvent({
Add New type: eventType,
</Text> stateKey,
</Box> });
</MenuItem> }}
{Array.from(stateKeyToEvents.keys()) key={stateKey}
.sort() variant="Surface"
.map((stateKey) => ( fill="None"
<MenuItem size="300"
onClick={() => { radii="0"
setOpenStateEvent({ after={<Icon size="50" src={Icons.ChevronRight} />}
type: eventType, >
stateKey, <Box grow="Yes">
}); <Text size="T200" truncate>
}} {stateKey ? `"${stateKey}"` : 'Default'}
key={stateKey} </Text>
variant="Surface" </Box>
fill="None" </MenuItem>
size="300" ))}
radii="0" </div>
after={<Icon size="50" src={Icons.ChevronRight} />} )}
> </Box>
<Box grow="Yes"> );
<Text size="T200" truncate> })}
{stateKey ? `"${stateKey}"` : 'Default'} </CutoutCard>
</Text> </Box>
</Box> </CollapsibleCard>
</MenuItem> <CollapsibleCard
))} expand={expandAccountData}
</div> setExpand={setExpandAccountData}
)} title="Account Data"
</Box> description="Private personalization data stored within room"
);
})}
</CutoutCard>
</Box>
)}
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
> >
<SettingTile <Box direction="Column" gap="100">
title="Account Data" <Box justifyContent="SpaceBetween">
description="Private personalization data stored within room." <Text size="L400">Events</Text>
after={ <Text size="L400">Total: {accountData.size}</Text>
<Button
onClick={() => setExpandAccountData(!expandAccountData)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
before={
<Icon
src={expandAccountData ? Icons.ChevronTop : Icons.ChevronBottom}
size="100"
filled
/>
}
>
<Text size="B300">{expandAccountData ? 'Collapse' : 'Expand'}</Text>
</Button>
}
/>
{expandAccountData && (
<Box direction="Column" gap="100">
<Box justifyContent="SpaceBetween">
<Text size="L400">Events</Text>
<Text size="L400">Total: {accountData.size}</Text>
</Box>
<CutoutCard>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
onClick={() => setAccountDataType(null)}
>
<Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
</Box>
</MenuItem>
{Array.from(accountData.keys())
.sort()
.map((type) => (
<MenuItem
key={type}
variant="Surface"
fill="None"
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={() => setAccountDataType(type)}
>
<Box grow="Yes">
<Text size="T200" truncate>
{type}
</Text>
</Box>
</MenuItem>
))}
</CutoutCard>
</Box> </Box>
)} <CutoutCard>
</SequenceCard> <MenuItem
variant="Surface"
fill="None"
size="300"
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
onClick={() => setAccountDataType(null)}
>
<Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
</Box>
</MenuItem>
{Array.from(accountData.keys())
.sort()
.map((type) => (
<MenuItem
key={type}
variant="Surface"
fill="None"
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={() => setAccountDataType(type)}
>
<Box grow="Yes">
<Text size="T200" truncate>
{type}
</Text>
</Box>
</MenuItem>
))}
</CutoutCard>
</Box>
</CollapsibleCard>
</Box> </Box>
)} )}
</Box> </Box>

View file

@ -7,8 +7,6 @@ import {
Chip, Chip,
color, color,
config, config,
Icon,
Icons,
Input, Input,
Spinner, Spinner,
Text, Text,
@ -33,6 +31,7 @@ import { useAlive } from '../../../hooks/useAlive';
import { StateEvent } from '../../../../types/matrix/room'; import { StateEvent } from '../../../../types/matrix/room';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import { getMxIdServer } from '../../../utils/matrix'; import { getMxIdServer } from '../../../utils/matrix';
import { CollapsibleCard } from '../../../components/CollapsibleCard';
type RoomPublishedAddressesProps = { type RoomPublishedAddressesProps = {
permissions: RoomPermissionsAPI; permissions: RoomPermissionsAPI;
@ -373,64 +372,40 @@ export function RoomLocalAddresses({ permissions }: { permissions: RoomPermissio
const { localAliasesState, addLocalAlias, removeLocalAlias } = useLocalAliases(room.roomId); const { localAliasesState, addLocalAlias, removeLocalAlias } = useLocalAliases(room.roomId);
return ( return (
<SequenceCard <CollapsibleCard
className={SequenceCardStyle} expand={expand}
variant="SurfaceVariant" setExpand={setExpand}
direction="Column" title="Local Addresses"
gap="400" description="Set local address so users can join through your homeserver."
> >
<SettingTile <CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
title="Local Addresses" {localAliasesState.status === AsyncStatus.Loading && (
description="Set local address so users can join through your homeserver." <Box gap="100">
after={ <Spinner variant="Secondary" size="100" />
<Button <Text size="T200">Loading...</Text>
type="button" </Box>
onClick={() => setExpand(!expand)} )}
size="300" {localAliasesState.status === AsyncStatus.Success &&
variant="Secondary" (localAliasesState.data.length === 0 ? (
fill="Soft" <Box direction="Column" gap="100">
outlined <Text size="L400">No Addresses</Text>
radii="300" </Box>
before={ ) : (
<Icon size="100" src={expand ? Icons.ChevronTop : Icons.ChevronBottom} filled /> <LocalAddressesList
} localAliases={localAliasesState.data}
> removeLocalAlias={removeLocalAlias}
<Text as="span" size="B300" truncate> canEditCanonical={canEditCanonical}
{expand ? 'Collapse' : 'Expand'} />
))}
{localAliasesState.status === AsyncStatus.Error && (
<Box gap="100">
<Text size="T200" style={{ color: color.Critical.Main }}>
{localAliasesState.error.message}
</Text> </Text>
</Button> </Box>
} )}
/> </CutoutCard>
{expand && (
<CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
{localAliasesState.status === AsyncStatus.Loading && (
<Box gap="100">
<Spinner variant="Secondary" size="100" />
<Text size="T200">Loading...</Text>
</Box>
)}
{localAliasesState.status === AsyncStatus.Success &&
(localAliasesState.data.length === 0 ? (
<Box direction="Column" gap="100">
<Text size="L400">No Addresses</Text>
</Box>
) : (
<LocalAddressesList
localAliases={localAliasesState.data}
removeLocalAlias={removeLocalAlias}
canEditCanonical={canEditCanonical}
/>
))}
{localAliasesState.status === AsyncStatus.Error && (
<Box gap="100">
<Text size="T200" style={{ color: color.Critical.Main }}>
{localAliasesState.error.message}
</Text>
</Box>
)}
</CutoutCard>
)}
{expand && <LocalAddressInput addLocalAlias={addLocalAlias} />} {expand && <LocalAddressInput addLocalAlias={addLocalAlias} />}
</SequenceCard> </CollapsibleCard>
); );
} }

View file

@ -199,7 +199,7 @@ export function RoomProfileEdit({
alt={name} alt={name}
renderFallback={() => ( renderFallback={() => (
<RoomIcon <RoomIcon
space={room.isSpaceRoom()} roomType={room.getType()}
size="400" size="400"
joinRule={joinRule?.join_rule ?? JoinRule.Invite} joinRule={joinRule?.join_rule ?? JoinRule.Invite}
filled filled
@ -342,7 +342,7 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
alt={name} alt={name}
renderFallback={() => ( renderFallback={() => (
<RoomIcon <RoomIcon
space={room.isSpaceRoom()} roomType={room.getType()}
size="400" size="400"
joinRule={joinRule?.join_rule ?? JoinRule.Invite} joinRule={joinRule?.join_rule ?? JoinRule.Invite}
filled filled

View file

@ -38,6 +38,8 @@ import {
RoomVersionSelector, RoomVersionSelector,
useAdditionalCreators, useAdditionalCreators,
} from '../../components/create-room'; } from '../../components/create-room';
import { RoomType, StateEvent } from '../../../types/matrix/room';
import { IPowerLevels } from '../../hooks/usePowerLevels';
const getCreateRoomKindToIcon = (kind: CreateRoomKind) => { const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
if (kind === CreateRoomKind.Private) return Icons.HashLock; if (kind === CreateRoomKind.Private) return Icons.HashLock;
@ -72,6 +74,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
useAdditionalCreators(); useAdditionalCreators();
const [federation, setFederation] = useState(true); const [federation, setFederation] = useState(true);
const [encryption, setEncryption] = useState(false); const [encryption, setEncryption] = useState(false);
const [callRoom, setCallRoom] = useState(false);
const [knock, setKnock] = useState(false); const [knock, setKnock] = useState(false);
const [advance, setAdvance] = useState(false); const [advance, setAdvance] = useState(false);
@ -116,8 +119,18 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
roomKnock = knock; roomKnock = knock;
} }
let roomType;
const powerOverrides: IPowerLevels = {
events: {},
};
if (callRoom) {
roomType = RoomType.Call;
powerOverrides.events![StateEvent.GroupCallMemberPrefix] = 0;
}
create({ create({
version: selectedRoomVersion, version: selectedRoomVersion,
type: roomType,
parent: space, parent: space,
kind, kind,
name: roomName, name: roomName,
@ -127,6 +140,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
knock: roomKnock, knock: roomKnock,
allowFederation: federation, allowFederation: federation,
additionalCreators: allowAdditionalCreators ? additionalCreators : undefined, additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
powerLevelContentOverrides: powerOverrides,
}).then((roomId) => { }).then((roomId) => {
if (alive()) { if (alive()) {
onCreate?.(roomId); onCreate?.(roomId);
@ -170,6 +184,20 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
disabled={disabled} disabled={disabled}
/> />
</Box> </Box>
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<SettingTile
title="Call Room"
description="Enable this to create a room optimized for voice calls."
after={
<Switch variant="Primary" value={callRoom} onChange={setCallRoom} disabled={disabled} />
}
/>
</SequenceCard>
{kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />} {kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />}

View file

@ -165,7 +165,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
<Box shrink="No"> <Box shrink="No">
<BackRouteHandler> <BackRouteHandler>
{(onBack) => ( {(onBack) => (
<IconButton onClick={onBack}> <IconButton fill="None" onClick={onBack}>
<Icon src={Icons.ArrowLeft} /> <Icon src={Icons.ArrowLeft} />
</IconButton> </IconButton>
)} )}
@ -218,7 +218,11 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
} }
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}> <IconButton
fill="None"
ref={triggerRef}
onClick={() => setPeopleDrawer((drawer) => !drawer)}
>
<Icon size="400" src={Icons.User} /> <Icon size="400" src={Icons.User} />
</IconButton> </IconButton>
)} )}
@ -235,7 +239,12 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
} }
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}> <IconButton
fill="None"
onClick={handleOpenMenu}
ref={triggerRef}
aria-pressed={!!menuAnchor}
>
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} /> <Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
</IconButton> </IconButton>
)} )}

View file

@ -175,6 +175,7 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf
type RoomProfileProps = { type RoomProfileProps = {
roomId: string; roomId: string;
roomType?: string;
name: string; name: string;
topic?: string; topic?: string;
avatarUrl?: string; avatarUrl?: string;
@ -185,6 +186,7 @@ type RoomProfileProps = {
}; };
function RoomProfile({ function RoomProfile({
roomId, roomId,
roomType,
name, name,
topic, topic,
avatarUrl, avatarUrl,
@ -200,9 +202,7 @@ function RoomProfile({
roomId={roomId} roomId={roomId}
src={avatarUrl} src={avatarUrl}
alt={name} alt={name}
renderFallback={() => ( renderFallback={() => <RoomIcon size="300" joinRule={joinRule} roomType={roomType} />}
<RoomIcon size="300" joinRule={joinRule ?? JoinRule.Restricted} filled />
)}
/> />
</Avatar> </Avatar>
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
@ -338,6 +338,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
{(localSummary) => ( {(localSummary) => (
<RoomProfile <RoomProfile
roomId={roomId} roomId={roomId}
roomType={localSummary.roomType}
name={localSummary.name} name={localSummary.name}
topic={localSummary.topic} topic={localSummary.topic}
avatarUrl={ avatarUrl={
@ -396,6 +397,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
{summary && ( {summary && (
<RoomProfile <RoomProfile
roomId={roomId} roomId={roomId}
roomType={summary.room_type}
name={summary.name || summary.canonical_alias || roomId} name={summary.name || summary.canonical_alias || roomId}
topic={summary.topic} topic={summary.topic}
avatarUrl={ avatarUrl={

View file

@ -29,7 +29,7 @@ import { SearchOrderBy } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { joinRuleToIconSrc } from '../../utils/room'; import { getRoomIconSrc } from '../../utils/room';
import { factoryRoomIdByAtoZ } from '../../utils/sort'; import { factoryRoomIdByAtoZ } from '../../utils/sort';
import { import {
SearchItemStrGetter, SearchItemStrGetter,
@ -274,9 +274,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
before={ before={
<Icon <Icon
size="50" size="50"
src={ src={getRoomIconSrc(Icons, room.getType(), room.getJoinRule())}
joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash
}
/> />
} }
> >
@ -392,10 +390,7 @@ export function SearchFilters({
onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))} onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))}
radii="Pill" radii="Pill"
before={ before={
<Icon <Icon size="50" src={getRoomIconSrc(Icons, room.getType(), room.getJoinRule())} />
size="50"
src={joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash}
/>
} }
after={<Icon size="50" src={Icons.Cross} />} after={<Icon size="50" src={Icons.Cross} />}
> >

View file

@ -0,0 +1,21 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
export const Actions = style({
padding: config.space.S200,
});
export const RoomButtonWrap = style({
minWidth: 0,
});
export const RoomButton = style({
width: '100%',
minWidth: 0,
padding: `0 ${config.space.S200}`,
});
export const RoomName = style({
flexGrow: 1,
minWidth: 0,
});

View file

@ -0,0 +1,129 @@
import {
Box,
Chip,
Icon,
IconButton,
Icons,
Line,
Spinner,
Text,
Tooltip,
TooltipProvider,
color,
} from 'folds';
import React from 'react';
import { useCallState } from '../../pages/client/call/CallProvider';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import * as css from './RoomCallNavStatus.css';
export function CallNavStatus() {
const {
activeCallRoomId,
isActiveCallReady,
isAudioEnabled,
isVideoEnabled,
toggleAudio,
toggleVideo,
hangUp,
} = useCallState();
const { navigateRoom } = useRoomNavigate();
const hasActiveCall = Boolean(activeCallRoomId);
const isConnected = hasActiveCall && isActiveCallReady;
const handleGoToCallRoom = () => {
if (activeCallRoomId) {
navigateRoom(activeCallRoomId);
}
};
return (
<Box direction="Column" shrink="No">
<Line variant="Surface" size="300" />
<Box className={css.Actions} direction="Row" alignItems="Center" gap="100">
<Box className={css.RoomButtonWrap} grow="Yes">
{hasActiveCall && (
<TooltipProvider
position="Top"
offset={4}
tooltip={
<Tooltip>
<Text>Go to Room</Text>
</Tooltip>
}
>
{(triggerRef) => (
<Chip
size="500"
fill="Soft"
as="button"
onClick={handleGoToCallRoom}
ref={triggerRef}
className={css.RoomButton}
>
{isConnected ? (
<Icon size="300" src={Icons.VolumeHigh} style={{ color: color.Success.Main }} />
) : (
<Spinner size="300" variant="Secondary" />
)}
<Text
as="span"
size="L400"
style={{ color: isConnected ? color.Success.Main : color.Warning.Main }}
>
{isConnected ? 'Connected' : 'Connecting'}
</Text>
</Chip>
)}
</TooltipProvider>
)}
</Box>
{hasActiveCall && (
<TooltipProvider
position="Top"
offset={4}
tooltip={
<Tooltip>
<Text>Hang Up</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton fill="None" size="300" ref={triggerRef} onClick={hangUp}>
<Icon src={Icons.Phone} />
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Top"
offset={4}
tooltip={
<Tooltip>
<Text>{!isAudioEnabled ? 'Unmute' : 'Mute'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton fill="None" size="300" ref={triggerRef} onClick={toggleAudio}>
<Icon src={!isAudioEnabled ? Icons.MicMute : Icons.Mic} />
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Top"
offset={4}
tooltip={
<Tooltip>
<Text>{!isVideoEnabled ? 'Video On' : 'Video Off'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton fill="None" size="300" ref={triggerRef} onClick={toggleVideo}>
<Icon src={!isVideoEnabled ? Icons.VideoCameraMute : Icons.VideoCamera} />
</IconButton>
)}
</TooltipProvider>
</Box>
</Box>
);
}

View file

@ -1,5 +1,5 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react'; import React, { MouseEventHandler, forwardRef, useState, MouseEvent } from 'react';
import { Room } from 'matrix-js-sdk'; import { EventType, Room } from 'matrix-js-sdk';
import { import {
Avatar, Avatar,
Box, Box,
@ -16,10 +16,13 @@ import {
RectCords, RectCords,
Badge, Badge,
Spinner, Spinner,
Tooltip,
TooltipProvider,
} from 'folds'; } from 'folds';
import { useFocusWithin, useHover } from 'react-aria'; import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; import { useNavigate } from 'react-router-dom';
import { NavButton, NavItem, NavItemContent, NavItemOptions } from '../../components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
@ -51,6 +54,12 @@ import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationS
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { useCallState } from '../../pages/client/call/CallProvider';
import { useCallMembers } from '../../hooks/useCallMemberships';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { RoomNavUser } from './RoomNavUser';
import { useRoomName } from '../../hooks/useRoomMeta';
type RoomNavItemMenuProps = { type RoomNavItemMenuProps = {
room: Room; room: Room;
@ -208,6 +217,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
); );
} }
); );
RoomNavItemMenu.displayName = 'RoomNavItemMenu';
type RoomNavItemProps = { type RoomNavItemProps = {
room: Room; room: Room;
@ -236,6 +246,32 @@ export function RoomNavItem({
(receipt) => receipt.userId !== mx.getUserId() (receipt) => receipt.userId !== mx.getUserId()
); );
const {
isActiveCallReady,
activeCallRoomId,
setActiveCallRoomId,
setViewedCallRoomId,
isChatOpen,
toggleChat,
hangUp,
} = useCallState();
const isActiveCall = isActiveCallReady && activeCallRoomId === room.roomId;
const callMemberships = useCallMembers(mx, room.roomId);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const roomName = useRoomName(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canJoinCall = permissions.event(EventType.GroupCallMemberPrefix, mx.getSafeUserId());
const { navigateRoom } = useRoomNavigate();
const navigate = useNavigate();
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => { const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
evt.preventDefault(); evt.preventDefault();
setMenuAnchor({ setMenuAnchor({
@ -250,109 +286,207 @@ export function RoomNavItem({
setMenuAnchor(evt.currentTarget.getBoundingClientRect()); setMenuAnchor(evt.currentTarget.getBoundingClientRect());
}; };
const handleNavItemClick: MouseEventHandler<HTMLElement> = (evt) => {
if (room.isCallRoom()) {
if (!isMobile) {
if (!isActiveCall && canJoinCall) {
hangUp();
setActiveCallRoomId(room.roomId);
} else {
navigateRoom(room.roomId);
}
} else {
evt.stopPropagation();
if (isChatOpen) toggleChat();
setViewedCallRoomId(room.roomId);
navigateRoom(room.roomId);
}
} else {
navigateRoom(room.roomId);
}
};
const handleChatButtonClick = (evt: MouseEvent<HTMLButtonElement>) => {
evt.stopPropagation();
if (!isChatOpen) toggleChat();
setViewedCallRoomId(room.roomId);
navigate(linkPath);
};
const optionsVisible = hover || !!menuAnchor; const optionsVisible = hover || !!menuAnchor;
const ariaLabel = [
roomName,
room.isCallRoom()
? [
'Call Room',
isActiveCall && 'Currently in Call',
callMemberships.length && `${callMemberships.length} in Call`,
]
: 'Text Room',
unread?.total && `${unread.total} Messages`,
]
.flat()
.filter(Boolean)
.join(', ');
return ( return (
<NavItem <Box direction="Column" grow="Yes">
variant="Background" <NavItem
radii="400" variant="Background"
highlight={unread !== undefined} radii="400"
aria-selected={selected} highlight={unread !== undefined}
data-hover={!!menuAnchor} aria-selected={selected}
onContextMenu={handleContextMenu} data-hover={!!menuAnchor}
{...hoverProps} onContextMenu={handleContextMenu}
{...focusWithinProps} {...hoverProps}
> {...focusWithinProps}
<NavLink to={linkPath}> >
<NavItemContent> <NavButton onClick={handleNavItemClick} aria-label={ariaLabel}>
<Box as="span" grow="Yes" alignItems="Center" gap="200"> <NavItemContent>
<Avatar size="200" radii="400"> <Box as="span" grow="Yes" alignItems="Center" gap="200">
{showAvatar ? ( <Avatar size="200" radii="400">
<RoomAvatar {showAvatar ? (
roomId={room.roomId} <RoomAvatar
src={ roomId={room.roomId}
direct src={
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) direct
: getRoomAvatarUrl(mx, room, 96, useAuthentication) ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
} : getRoomAvatarUrl(mx, room, 96, useAuthentication)
alt={room.name} }
renderFallback={() => ( alt={roomName}
<Text as="span" size="H6"> renderFallback={() => (
{nameInitials(room.name)} <Text as="span" size="H6">
</Text> {nameInitials(roomName)}
)} </Text>
/> )}
) : ( />
<RoomIcon ) : (
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }} <RoomIcon
filled={selected} style={{
size="100" opacity: unread || isActiveCall ? config.opacity.P500 : config.opacity.P300,
joinRule={room.getJoinRule()} }}
filled={selected || isActiveCall}
size="100"
joinRule={room.getJoinRule()}
roomType={room.getType()}
locked={room.isCallRoom() && !canJoinCall}
/>
)}
</Avatar>
<Box as="span" grow="Yes">
<Text
priority={unread || isActiveCall ? '500' : '300'}
as="span"
size="Inherit"
truncate
>
{roomName}
</Text>
</Box>
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
<TypingIndicator size="300" disableAnimation />
</Badge>
)}
{!optionsVisible && unread && (
<UnreadBadgeCenter>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</UnreadBadgeCenter>
)}
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
<Icon
size="50"
src={getRoomNotificationModeIcon(notificationMode)}
aria-label={notificationMode}
/> />
)} )}
</Avatar>
<Box as="span" grow="Yes">
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{room.name}
</Text>
</Box> </Box>
{!optionsVisible && !unread && !selected && typingMember.length > 0 && ( </NavItemContent>
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined> </NavButton>
<TypingIndicator size="300" disableAnimation /> {optionsVisible && (
</Badge> <NavItemOptions>
)} <PopOut
{!optionsVisible && unread && ( id={`menu-${room.roomId}`}
<UnreadBadgeCenter> aria-expanded={!!menuAnchor}
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} /> anchor={menuAnchor}
</UnreadBadgeCenter> offset={menuAnchor?.width === 0 ? 0 : undefined}
)} alignOffset={menuAnchor?.width === 0 ? 0 : -5}
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( position="Bottom"
<Icon size="50" src={getRoomNotificationModeIcon(notificationMode)} /> align={menuAnchor?.width === 0 ? 'Start' : 'End'}
)} content={
</Box> <FocusTrap
</NavItemContent> focusTrapOptions={{
</NavLink> initialFocus: false,
{optionsVisible && ( returnFocusOnDeactivate: false,
<NavItemOptions> onDeactivate: () => setMenuAnchor(undefined),
<PopOut clickOutsideDeactivates: true,
anchor={menuAnchor} isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
offset={menuAnchor?.width === 0 ? 0 : undefined} isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
alignOffset={menuAnchor?.width === 0 ? 0 : -5} escapeDeactivates: stopPropagation,
position="Bottom" }}
align={menuAnchor?.width === 0 ? 'Start' : 'End'} >
content={ <RoomNavItemMenu
<FocusTrap room={room}
focusTrapOptions={{ requestClose={() => setMenuAnchor(undefined)}
initialFocus: false, notificationMode={notificationMode}
returnFocusOnDeactivate: false, />
onDeactivate: () => setMenuAnchor(undefined), </FocusTrap>
clickOutsideDeactivates: true, }
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<RoomNavItemMenu
room={room}
requestClose={() => setMenuAnchor(undefined)}
notificationMode={notificationMode}
/>
</FocusTrap>
}
>
<IconButton
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
variant="Background"
fill="None"
size="300"
radii="300"
> >
<Icon size="50" src={Icons.VerticalDots} /> {room.isCallRoom() && (
</IconButton> <TooltipProvider
</PopOut> position="Bottom"
</NavItemOptions> offset={4}
tooltip={
<Tooltip>
<Text>Open Chat</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
data-testid="chat-button"
onClick={handleChatButtonClick}
aria-pressed={isChatOpen && selected}
aria-label="Open Chat"
variant="Background"
fill="None"
size="300"
radii="300"
>
<Icon size="50" src={Icons.Message} />
</IconButton>
)}
</TooltipProvider>
)}
<IconButton
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
aria-controls={`menu-${room.roomId}`}
aria-label="More Options"
variant="Background"
fill="None"
size="300"
radii="300"
>
<Icon size="50" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
</NavItemOptions>
)}
</NavItem>
{room.isCallRoom() && (
<Box direction="Column" style={{ paddingLeft: config.space.S200 }}>
{callMemberships.map((callMembership) => (
<RoomNavUser
key={callMembership.membershipID}
room={room}
callMembership={callMembership}
/>
))}
</Box>
)} )}
</NavItem> </Box>
); );
} }

View file

@ -0,0 +1,63 @@
import { Avatar, Box, Icon, Icons, Text } from 'folds';
import React from 'react';
import { Room } from 'matrix-js-sdk';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import { NavButton, NavItem, NavItemContent } from '../../components/nav';
import { UserAvatar } from '../../components/user-avatar';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useCallState } from '../../pages/client/call/CallProvider';
import { getMxIdLocalPart } from '../../utils/matrix';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
type RoomNavUserProps = {
room: Room;
callMembership: CallMembership;
};
export function RoomNavUser({ room, callMembership }: RoomNavUserProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const openProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const { isActiveCallReady, activeCallRoomId } = useCallState();
const isActiveCall = isActiveCallReady && activeCallRoomId === room.roomId;
const userId = callMembership.sender ?? '';
const avatarMxcUrl = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication)
: undefined;
const getName = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId);
const isCallParticipant = isActiveCall && userId !== mx.getUserId();
const handleNavUserClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
openProfile(room.roomId, space?.roomId, userId, evt.currentTarget.getBoundingClientRect());
};
const ariaLabel = isCallParticipant ? `Call Participant: ${getName}` : getName;
return (
<NavItem variant="Background" radii="400">
<NavButton onClick={handleNavUserClick} aria-label={ariaLabel}>
<NavItemContent as="div">
<Box direction="Column" grow="Yes" gap="200" justifyContent="Stretch">
<Box alignItems="Center" gap="200">
<Avatar size="200">
<UserAvatar
userId={userId}
src={avatarUrl ?? undefined}
alt={getName}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
<Text as="span" size="B400" priority="300" truncate>
{getName}
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
);
}

View file

@ -46,6 +46,19 @@ export const usePermissionGroups = (): PermissionGroup[] => {
], ],
}; };
const callSettingsGroup: PermissionGroup = {
name: 'Calls',
items: [
{
location: {
state: true,
key: StateEvent.GroupCallMemberPrefix,
},
name: 'Join Call',
},
],
};
const moderationGroup: PermissionGroup = { const moderationGroup: PermissionGroup = {
name: 'Moderation', name: 'Moderation',
items: [ items: [
@ -196,6 +209,7 @@ export const usePermissionGroups = (): PermissionGroup[] => {
return [ return [
messagesGroup, messagesGroup,
callSettingsGroup,
moderationGroup, moderationGroup,
roomOverviewGroup, roomOverviewGroup,
roomSettingsGroup, roomSettingsGroup,

View file

@ -13,6 +13,8 @@ import { useKeyDown } from '../../hooks/useKeyDown';
import { markAsRead } from '../../utils/notifications'; import { markAsRead } from '../../utils/notifications';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers'; import { useRoomMembers } from '../../hooks/useRoomMembers';
import { CallView } from '../call/CallView';
import { RoomViewHeader } from './RoomViewHeader';
export function Room() { export function Room() {
const { eventId } = useParams(); const { eventId } = useParams();
@ -23,7 +25,7 @@ export function Room() {
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId); const members = useRoomMembers(mx, room?.roomId);
useKeyDown( useKeyDown(
window, window,
@ -40,8 +42,17 @@ export function Room() {
return ( return (
<PowerLevelsContextProvider value={powerLevels}> <PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes"> <Box grow="Yes">
<RoomView room={room} eventId={eventId} /> <Box grow="Yes" direction="Column">
{screenSize === ScreenSize.Desktop && isDrawer && ( <RoomViewHeader />
<Box grow="Yes">
<CallView room={room} />
{room.isCallRoom() && screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<RoomView room={room} eventId={eventId} />
</Box>
</Box>
{!room.isCallRoom() && screenSize === ScreenSize.Desktop && isDrawer && (
<> <>
<Line variant="Background" direction="Vertical" size="300" /> <Line variant="Background" direction="Vertical" size="300" />
<MembersDrawer key={room.roomId} room={room} members={members} /> <MembersDrawer key={room.roomId} room={room} members={members} />

View file

@ -471,6 +471,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const permissions = useRoomPermissions(creators, powerLevels); const permissions = useRoomPermissions(creators, powerLevels);
const canRedact = permissions.action('redact', mx.getSafeUserId()); const canRedact = permissions.action('redact', mx.getSafeUserId());
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId()); const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId()); const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
const [editId, setEditId] = useState<string>(); const [editId, setEditId] = useState<string>();
@ -1047,7 +1048,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
collapse={collapse} collapse={collapse}
highlight={highlighted} highlight={highlighted}
edit={editId === mEventId} edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent} canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
@ -1129,7 +1130,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
collapse={collapse} collapse={collapse}
highlight={highlighted} highlight={highlighted}
edit={editId === mEventId} edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent} canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
@ -1247,7 +1248,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
messageLayout={messageLayout} messageLayout={messageLayout}
collapse={collapse} collapse={collapse}
highlight={highlighted} highlight={highlighted}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent} canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}

View file

@ -1,5 +1,5 @@
import React, { useCallback, useRef } from 'react'; import React, { useCallback, useRef } from 'react';
import { Box, Text, config } from 'folds'; import { Box, Text, config, toRem } from 'folds';
import { EventType, Room } from 'matrix-js-sdk'; import { EventType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
@ -15,13 +15,14 @@ import { RoomTombstone } from './RoomTombstone';
import { RoomInput } from './RoomInput'; import { RoomInput } from './RoomInput';
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
import { Page } from '../../components/page'; import { Page } from '../../components/page';
import { RoomViewHeader } from './RoomViewHeader';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { editableActiveElement } from '../../utils/dom'; import { editableActiveElement } from '../../utils/dom';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useCallState } from '../../pages/client/call/CallProvider';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
const FN_KEYS_REGEX = /^F\d+$/; const FN_KEYS_REGEX = /^F\d+$/;
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
@ -30,10 +31,8 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
return false; return false;
} }
// do not focus on F keys
if (FN_KEYS_REGEX.test(code)) return false; if (FN_KEYS_REGEX.test(code)) return false;
// do not focus on numlock/scroll lock
if ( if (
code.startsWith('OS') || code.startsWith('OS') ||
code.startsWith('Meta') || code.startsWith('Meta') ||
@ -61,6 +60,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
const roomViewRef = useRef<HTMLDivElement>(null); const roomViewRef = useRef<HTMLDivElement>(null);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext();
const { isChatOpen } = useCallState();
const { roomId } = room; const { roomId } = room;
const editor = useEditor(); const editor = useEditor();
@ -92,51 +93,59 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
); );
return ( return (
<Page ref={roomViewRef}> (!room.isCallRoom() || isChatOpen) && (
<RoomViewHeader /> <Page
<Box grow="Yes" direction="Column"> ref={roomViewRef}
<RoomTimeline style={
key={roomId} room.isCallRoom() && screenSize === ScreenSize.Desktop
room={room} ? { maxWidth: toRem(399), minWidth: toRem(399) }
eventId={eventId} : {}
roomInputRef={roomInputRef} }
editor={editor} >
/> <Box grow="Yes" direction="Column">
<RoomViewTyping room={room} /> <RoomTimeline
</Box> key={roomId}
<Box shrink="No" direction="Column"> room={room}
<div style={{ padding: `0 ${config.space.S400}` }}> eventId={eventId}
{tombstoneEvent ? ( roomInputRef={roomInputRef}
<RoomTombstone editor={editor}
roomId={roomId} />
body={tombstoneEvent.getContent().body} <RoomViewTyping room={room} />
replacementRoomId={tombstoneEvent.getContent().replacement_room} </Box>
/> <Box shrink="No" direction="Column">
) : ( <div style={{ padding: `0 ${config.space.S400}` }}>
<> {tombstoneEvent ? (
{canMessage && ( <RoomTombstone
<RoomInput roomId={roomId}
room={room} body={tombstoneEvent.getContent().body}
editor={editor} replacementRoomId={tombstoneEvent.getContent().replacement_room}
roomId={roomId} />
fileDropContainerRef={roomViewRef} ) : (
ref={roomInputRef} <>
/> {canMessage && (
)} <RoomInput
{!canMessage && ( room={room}
<RoomInputPlaceholder editor={editor}
style={{ padding: config.space.S200 }} roomId={roomId}
alignItems="Center" fileDropContainerRef={roomViewRef}
justifyContent="Center" ref={roomInputRef}
> />
<Text align="Center">You do not have permission to post in this room</Text> )}
</RoomInputPlaceholder> {!canMessage && (
)} <RoomInputPlaceholder
</> style={{ padding: config.space.S200 }}
)} alignItems="Center"
</div> justifyContent="Center"
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />} >
</Box> <Text align="Center">You do not have permission to post in this room</Text>
</Page> </RoomInputPlaceholder>
)}
</>
)}
</div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
</Box>
</Page>
)
); );
} }

View file

@ -23,8 +23,7 @@ import {
Spinner, Spinner,
} from 'folds'; } from 'folds';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { JoinRule, Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useStateEvent } from '../../hooks/useStateEvent'; import { useStateEvent } from '../../hooks/useStateEvent';
import { PageHeader } from '../../components/page'; import { PageHeader } from '../../components/page';
@ -33,7 +32,7 @@ import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer'; import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom'; import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { useSpaceOptionally } from '../../hooks/useSpace'; import { useSpaceOptionally } from '../../hooks/useSpace';
@ -48,7 +47,6 @@ import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { copyToClipboard } from '../../utils/dom'; import { copyToClipboard } from '../../utils/dom';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt'; import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta'; import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
import { mDirectAtom } from '../../state/mDirectList';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { getMatrixToRoom } from '../../plugins/matrix-to'; import { getMatrixToRoom } from '../../plugins/matrix-to';
@ -69,6 +67,8 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { useCallState } from '../../pages/client/call/CallProvider';
import { ContainerColor } from '../../styles/ContainerColor.css';
type RoomMenuProps = { type RoomMenuProps = {
room: Room; room: Room;
@ -263,12 +263,13 @@ export function RoomViewHeader() {
const space = useSpaceOptionally(); const space = useSpaceOptionally();
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>(); const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const mDirects = useAtomValue(mDirectAtom); const direct = useIsDirectRoom();
const { isChatOpen, toggleChat } = useCallState();
const pinnedEvents = useRoomPinnedEvents(room); const pinnedEvents = useRoomPinnedEvents(room);
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const ecryptedRoom = !!encryptionEvent; const ecryptedRoom = !!encryptionEvent;
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId)); const avatarMxc = useRoomAvatar(room, direct);
const name = useRoomName(room); const name = useRoomName(room);
const topic = useRoomTopic(room); const topic = useRoomTopic(room);
const avatarUrl = avatarMxc const avatarUrl = avatarMxc
@ -296,13 +297,16 @@ export function RoomViewHeader() {
}; };
return ( return (
<PageHeader balance={screenSize === ScreenSize.Mobile}> <PageHeader
className={ContainerColor({ variant: 'Surface' })}
balance={screenSize === ScreenSize.Mobile}
>
<Box grow="Yes" gap="300"> <Box grow="Yes" gap="300">
{screenSize === ScreenSize.Mobile && ( {screenSize === ScreenSize.Mobile && (
<BackRouteHandler> <BackRouteHandler>
{(onBack) => ( {(onBack) => (
<Box shrink="No" alignItems="Center"> <Box shrink="No" alignItems="Center">
<IconButton onClick={onBack}> <IconButton fill="None" onClick={onBack}>
<Icon src={Icons.ArrowLeft} /> <Icon src={Icons.ArrowLeft} />
</IconButton> </IconButton>
</Box> </Box>
@ -317,11 +321,7 @@ export function RoomViewHeader() {
src={avatarUrl} src={avatarUrl}
alt={name} alt={name}
renderFallback={() => ( renderFallback={() => (
<RoomIcon <RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} />
size="200"
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
filled
/>
)} )}
/> />
</Avatar> </Avatar>
@ -369,8 +369,9 @@ export function RoomViewHeader() {
)} )}
</Box> </Box>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
{!ecryptedRoom && ( {!ecryptedRoom && (!room.isCallRoom() || isChatOpen) && (
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"
offset={4} offset={4}
@ -381,69 +382,75 @@ export function RoomViewHeader() {
} }
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton ref={triggerRef} onClick={handleSearchClick}> <IconButton fill="None" ref={triggerRef} onClick={handleSearchClick}>
<Icon size="400" src={Icons.Search} /> <Icon size="400" src={Icons.Search} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
)} )}
<TooltipProvider {(!room.isCallRoom() || isChatOpen) && (
position="Bottom" <TooltipProvider
offset={4} position="Bottom"
tooltip={ offset={4}
<Tooltip> tooltip={
<Text>Pinned Messages</Text> <Tooltip>
</Tooltip> <Text>Pinned Messages</Text>
} </Tooltip>
> }
{(triggerRef) => ( >
<IconButton {(triggerRef) => (
style={{ position: 'relative' }} <IconButton
onClick={handleOpenPinMenu} fill="None"
ref={triggerRef} style={{ position: 'relative' }}
aria-pressed={!!pinMenuAnchor} onClick={handleOpenPinMenu}
> ref={triggerRef}
{pinnedEvents.length > 0 && ( aria-pressed={!!pinMenuAnchor}
<Badge >
style={{ {pinnedEvents.length > 0 && (
position: 'absolute', <Badge
left: toRem(3), style={{
top: toRem(3), position: 'absolute',
}} left: toRem(3),
variant="Secondary" top: toRem(3),
size="400" }}
fill="Solid" variant="Secondary"
radii="Pill" size="400"
> fill="Solid"
<Text as="span" size="L400"> radii="Pill"
{pinnedEvents.length} >
</Text> <Text as="span" size="L400">
</Badge> {pinnedEvents.length}
)} </Text>
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} /> </Badge>
</IconButton> )}
)} <Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
</TooltipProvider> </IconButton>
<PopOut )}
anchor={pinMenuAnchor} </TooltipProvider>
position="Bottom" )}
content={ {(!room.isCallRoom() || isChatOpen) && (
<FocusTrap <PopOut
focusTrapOptions={{ anchor={pinMenuAnchor}
initialFocus: false, position="Bottom"
returnFocusOnDeactivate: false, content={
onDeactivate: () => setPinMenuAnchor(undefined), <FocusTrap
clickOutsideDeactivates: true, focusTrapOptions={{
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', initialFocus: false,
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', returnFocusOnDeactivate: false,
escapeDeactivates: stopPropagation, onDeactivate: () => setPinMenuAnchor(undefined),
}} clickOutsideDeactivates: true,
> isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
<RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} /> isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
</FocusTrap> escapeDeactivates: stopPropagation,
} }}
/> >
{screenSize === ScreenSize.Desktop && ( <RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
</FocusTrap>
}
/>
)}
{!room.isCallRoom() && screenSize === ScreenSize.Desktop && (
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"
offset={4} offset={4}
@ -454,12 +461,35 @@ export function RoomViewHeader() {
} }
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}> <IconButton
fill="None"
ref={triggerRef}
onClick={() => setPeopleDrawer((drawer) => !drawer)}
>
<Icon size="400" src={Icons.User} /> <Icon size="400" src={Icons.User} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
)} )}
{room.isCallRoom() && !direct && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Chat</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={toggleChat}>
<Icon size="400" src={Icons.Message} filled={isChatOpen} />
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"
align="End" align="End"
@ -471,7 +501,12 @@ export function RoomViewHeader() {
} }
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}> <IconButton
fill="None"
onClick={handleOpenMenu}
ref={triggerRef}
aria-pressed={!!menuAnchor}
>
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} /> <Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
</IconButton> </IconButton>
)} )}

View file

@ -373,7 +373,7 @@ export function Search({ requestClose }: SearchProps) {
<RoomIcon <RoomIcon
size="100" size="100"
joinRule={room.getJoinRule()} joinRule={room.getJoinRule()}
space={room.isSpaceRoom()} roomType={room.getType()}
/> />
)} )}
</Avatar> </Avatar>

View file

@ -21,10 +21,9 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { Account } from './account'; import { Account } from './account';
import { useUserProfile } from '../../hooks/useUserProfile'; import { useUserProfile } from '../../hooks/useUserProfile';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../../components/user-avatar'; import { UserAvatar } from '../../components/user-avatar';
import { nameInitials } from '../../utils/common';
import { Notifications } from './notifications'; import { Notifications } from './notifications';
import { Devices } from './devices'; import { Devices } from './devices';
import { EmojisStickers } from './emojis-stickers'; import { EmojisStickers } from './emojis-stickers';
@ -99,9 +98,8 @@ type SettingsProps = {
export function Settings({ initialPage, requestClose }: SettingsProps) { export function Settings({ initialPage, requestClose }: SettingsProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const userId = mx.getUserId()!; const userId = mx.getUserId() as string;
const profile = useUserProfile(userId); const profile = useUserProfile(userId);
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined; : undefined;
@ -132,7 +130,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
<UserAvatar <UserAvatar
userId={userId} userId={userId}
src={avatarUrl} src={avatarUrl}
renderFallback={() => <Text size="H6">{nameInitials(displayName)}</Text>} renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/> />
</Avatar> </Avatar>
<Text size="H4" truncate> <Text size="H4" truncate>

View file

@ -1,324 +1,283 @@
import React, { import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react';
ChangeEventHandler, import { Box, Text, Button, config, Spinner, Line } from 'folds';
FormEventHandler, import { UserEvent, ValidatedAuthMetadata } from 'matrix-js-sdk';
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import {
Box,
Text,
IconButton,
Icon,
Icons,
Input,
Avatar,
Button,
Overlay,
OverlayBackdrop,
OverlayCenter,
Modal,
Dialog,
Header,
config,
Spinner,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile'; import { getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { UserAvatar } from '../../../components/user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { nameInitials } from '../../../utils/common'; import { UserHero, UserHeroName } from '../../../components/user-profile/UserHero';
import {
ExtendedProfile,
profileEditsAllowed,
useExtendedProfile,
} from '../../../hooks/useExtendedProfile';
import { ProfileFieldContext, ProfileFieldElementProps } from './fields/ProfileFieldContext';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useFilePicker } from '../../../hooks/useFilePicker'; import { CutoutCard } from '../../../components/cutout-card';
import { useObjectURL } from '../../../hooks/useObjectURL'; import { ServerChip, ShareChip, TimezoneChip } from '../../../components/user-profile/UserChips';
import { stopPropagation } from '../../../utils/keyboard'; import { SequenceCardStyle } from '../styles.css';
import { ImageEditor } from '../../../components/image-editor'; import { useUserProfile } from '../../../hooks/useUserProfile';
import { ModalWide } from '../../../styles/Modal.css'; import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
import { createUploadAtom, UploadSuccess } from '../../../state/upload'; import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { withSearchParam } from '../../../pages/pathUtils';
import { useCapabilities } from '../../../hooks/useCapabilities'; import { useCapabilities } from '../../../hooks/useCapabilities';
import { ProfileAvatar } from './fields/ProfileAvatar';
import { ProfileTextField } from './fields/ProfileTextField';
import { ProfilePronouns } from './fields/ProfilePronouns';
import { ProfileTimezone } from './fields/ProfileTimezone';
type ProfileProps = { function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAuthMetadata }) {
profile: UserProfile; const accountManagementActions = useAccountManagementActions();
userId: string;
};
function ProfileAvatar({ profile, userId }: ProfileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const capabilities = useCapabilities();
const [alertRemove, setAlertRemove] = useState(false);
const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const openProviderProfileSettings = useCallback(() => {
const avatarUrl = profile.avatarUrl const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined if (!authUrl) return;
: undefined;
const [imageFile, setImageFile] = useState<File>(); window.open(
const imageFileURL = useObjectURL(imageFile); withSearchParam(authUrl, {
const uploadAtom = useMemo(() => { action: accountManagementActions.profile,
if (imageFile) return createUploadAtom(imageFile); }),
return undefined; '_blank'
}, [imageFile]); );
}, [authMetadata, accountManagementActions]);
const pickFile = useFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
}, []);
const handleUploaded = useCallback(
(upload: UploadSuccess) => {
const { mxc } = upload;
mx.setAvatarUrl(mxc);
handleRemoveUpload();
},
[mx, handleRemoveUpload]
);
const handleRemoveAvatar = () => {
mx.setAvatarUrl('');
setAlertRemove(false);
};
return ( return (
<SettingTile <CutoutCard style={{ padding: config.space.S200 }} variant="Surface">
title={ <SettingTile
<Text as="span" size="L400"> after={
Avatar
</Text>
}
after={
<Avatar size="500" radii="300">
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
/>
</Avatar>
}
>
{uploadAtom ? (
<Box gap="200" direction="Column">
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded}
/>
</Box>
) : (
<Box gap="200">
<Button <Button
onClick={() => pickFile('image/*')}
size="300" size="300"
variant="Secondary" variant="Secondary"
fill="Soft" fill="Soft"
outlined
radii="300" radii="300"
disabled={disableSetAvatar} outlined
onClick={openProviderProfileSettings}
> >
<Text size="B300">Upload</Text> <Text size="B300">Open</Text>
</Button> </Button>
{avatarUrl && ( }
<Button >
size="300" <Text size="T200">Change profile settings in your homeserver&apos;s account dashboard.</Text>
variant="Critical" </SettingTile>
fill="None" </CutoutCard>
radii="300"
disabled={disableSetAvatar}
onClick={() => setAlertRemove(true)}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
)}
{imageFileURL && (
<Overlay open={false} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleRemoveUpload,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal className={ModalWide} variant="Surface" size="500">
<ImageEditor
name={imageFile?.name ?? 'Unnamed'}
url={imageFileURL}
requestClose={handleRemoveUpload}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
<Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setAlertRemove(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Remove Avatar</Text>
</Box>
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text priority="400">Are you sure you want to remove profile avatar?</Text>
</Box>
<Button variant="Critical" onClick={handleRemoveAvatar}>
<Text size="B400">Remove</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
</SettingTile>
); );
} }
function ProfileDisplayName({ profile, userId }: ProfileProps) { /// Context props which are passed to every field element.
const mx = useMatrixClient(); /// Right now this is only a flag for if the profile is being saved.
const capabilities = useCapabilities(); export type FieldContext = { busy: boolean };
const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; /// Field editor elements for the pre-MSC4133 profile fields. This should only
const [displayName, setDisplayName] = useState<string>(defaultDisplayName); /// ever contain keys for `displayname` and `avatar_url`.
const LEGACY_FIELD_ELEMENTS = {
avatar_url: ProfileAvatar,
displayname: (props: ProfileFieldElementProps<'displayname', FieldContext>) => (
<ProfileTextField label="Display Name" {...props} />
),
};
const [changeState, changeDisplayName] = useAsyncCallback( /// Field editor elements for MSC4133 extended profile fields.
useCallback((name: string) => mx.setDisplayName(name), [mx]) /// These will appear in the UI in the order they are defined in this map.
); const EXTENDED_FIELD_ELEMENTS = {
const changingDisplayName = changeState.status === AsyncStatus.Loading; 'io.fsky.nyx.pronouns': ProfilePronouns,
'us.cloke.msc4175.tz': ProfileTimezone,
useEffect(() => { };
setDisplayName(defaultDisplayName);
}, [defaultDisplayName]);
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const name = evt.currentTarget.value;
setDisplayName(name);
};
const handleReset = () => {
setDisplayName(defaultDisplayName);
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (changingDisplayName) return;
const target = evt.target as HTMLFormElement | undefined;
const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
const name = displayNameInput?.value;
if (!name) return;
changeDisplayName(name);
};
const hasChanges = displayName !== defaultDisplayName;
return (
<SettingTile
title={
<Text as="span" size="L400">
Display Name
</Text>
}
>
<Box direction="Column" grow="Yes" gap="100">
<Box
as="form"
onSubmit={handleSubmit}
gap="200"
aria-disabled={changingDisplayName || disableSetDisplayname}
>
<Box grow="Yes" direction="Column">
<Input
required
name="displayNameInput"
value={displayName}
onChange={handleChange}
variant="Secondary"
radii="300"
style={{ paddingRight: config.space.S200 }}
readOnly={changingDisplayName || disableSetDisplayname}
after={
hasChanges &&
!changingDisplayName && (
<IconButton
type="reset"
onClick={handleReset}
size="300"
radii="300"
variant="Secondary"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)
}
/>
</Box>
<Button
size="400"
variant={hasChanges ? 'Success' : 'Secondary'}
fill={hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges || changingDisplayName}
type="submit"
>
{changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
<Text size="B400">Save</Text>
</Button>
</Box>
</Box>
</SettingTile>
);
}
export function Profile() { export function Profile() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const userId = mx.getUserId()!; const userId = mx.getUserId() as string;
const profile = useUserProfile(userId); const server = getMxIdServer(userId);
const authMetadata = useAuthMetadata();
const accountManagementActions = useAccountManagementActions();
const useAuthentication = useMediaAuthentication();
const capabilities = useCapabilities();
const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
const extendedProfileSupported = extendedProfile !== null;
const legacyProfile = useUserProfile(userId);
// next-gen auth identity providers may provide profile settings if they want
const profileEditableThroughIDP =
authMetadata !== undefined &&
authMetadata.account_management_actions_supported?.includes(accountManagementActions.profile);
const [fieldElementConstructors, profileEditableThroughClient] = useMemo(() => {
const entries = Object.entries({
...LEGACY_FIELD_ELEMENTS,
// don't show the MSC4133 elements if the HS doesn't support them
...(extendedProfileSupported ? EXTENDED_FIELD_ELEMENTS : {}),
}).filter(([key]) =>
// don't show fields if the HS blocks them with capabilities
profileEditsAllowed(key, capabilities, extendedProfileSupported)
);
return [Object.fromEntries(entries), entries.length > 0];
}, [capabilities, extendedProfileSupported]);
const [fieldDefaults, setFieldDefaults] = useState<ExtendedProfile>({
displayname: legacyProfile.displayName,
avatar_url: legacyProfile.avatarUrl,
});
// this updates the field defaults when the extended profile data is (re)loaded.
// it has to be a layout effect to prevent flickering on saves.
// if MSC4133 isn't supported by the HS this does nothing
useLayoutEffect(() => {
// `extendedProfile` includes the old dn/av fields, so
// we don't have to add those here
if (extendedProfile) {
setFieldDefaults(extendedProfile);
}
}, [setFieldDefaults, extendedProfile]);
const [saveState, handleSave] = useAsyncCallback(
useCallback(
async (fields: ExtendedProfile) => {
if (extendedProfileSupported) {
await Promise.all(
Object.entries(fields).map(async ([key, value]) => {
if (value === undefined) {
await mx.deleteExtendedProfileProperty(key);
} else {
await mx.setExtendedProfileProperty(key, value);
}
})
);
// calling this will trigger the layout effect to update the defaults
// once the profile request completes
await refreshExtendedProfile();
// synthesize a profile update for ourselves to update our name and avatar in the rest
// of the UI. code copied from matrix-js-sdk
const user = mx.getUser(userId);
if (user) {
user.displayName = fields.displayname;
user.avatarUrl = fields.avatar_url;
user.emit(UserEvent.DisplayName, user.events.presence, user);
user.emit(UserEvent.AvatarUrl, user.events.presence, user);
}
} else {
await mx.setDisplayName(fields.displayname ?? '');
await mx.setAvatarUrl(fields.avatar_url ?? '');
// layout effect does nothing because `extendedProfile` is undefined
// so we have to update the defaults explicitly here
setFieldDefaults(fields);
}
},
[mx, userId, refreshExtendedProfile, extendedProfileSupported, setFieldDefaults]
)
);
const saving = saveState.status === AsyncStatus.Loading;
const loadingExtendedProfile = extendedProfile === undefined;
const busy = saving || loadingExtendedProfile;
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Profile</Text> <Text size="L400">Profile</Text>
<SequenceCard <SequenceCard
className={SequenceCardStyle} variant="Surface"
variant="SurfaceVariant" outlined
direction="Column" direction="Column"
gap="400" style={{
overflow: 'hidden',
}}
> >
<ProfileAvatar userId={userId} profile={profile} /> <ProfileFieldContext
<ProfileDisplayName userId={userId} profile={profile} /> fieldDefaults={fieldDefaults}
fieldElements={fieldElementConstructors}
context={{ busy }}
>
{(reset, hasChanges, fields, fieldElements) => {
const heroAvatarUrl =
(fields.avatar_url && mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ??
undefined;
return (
<>
<UserHero userId={userId} avatarUrl={heroAvatarUrl} />
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
<Box gap="400" alignItems="Start">
<UserHeroName
userId={userId}
displayName={fields.displayname as string}
extendedProfile={fields}
/>
</Box>
<Box alignItems="Center" gap="200" wrap="Wrap">
{server && <ServerChip server={server} />}
<ShareChip userId={userId} />
{fields['us.cloke.msc4175.tz'] && (
<TimezoneChip timezone={fields['us.cloke.msc4175.tz']} />
)}
</Box>
</Box>
<Line />
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
radii="0"
>
{profileEditableThroughIDP && (
<IdentityProviderSettings authMetadata={authMetadata} />
)}
{profileEditableThroughClient && (
<>
<Box gap="300" direction="Column">
{fieldElements}
</Box>
<Box gap="300" alignItems="Center">
<Button
type="submit"
size="300"
variant={!busy && hasChanges ? 'Success' : 'Secondary'}
fill={!busy && hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges || busy}
onClick={() => handleSave(fields)}
>
<Text size="B300">Save</Text>
</Button>
<Button
type="reset"
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
onClick={reset}
disabled={!hasChanges || busy}
>
<Text size="B300">Cancel</Text>
</Button>
{saving && <Spinner size="300" />}
</Box>
</>
)}
{!(profileEditableThroughClient || profileEditableThroughIDP) && (
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
<SettingTile>
<Box direction="Column" gap="200">
<Box gap="200" justifyContent="SpaceBetween">
<Text size="L400">Profile Editing Disabled</Text>
</Box>
<Box direction="Column">
<Text size="T200">
Your homeserver does not allow you to edit your profile.
</Text>
</Box>
</Box>
</SettingTile>
</CutoutCard>
)}
</SequenceCard>
</>
);
}}
</ProfileFieldContext>
</SequenceCard> </SequenceCard>
</Box> </Box>
); );

View file

@ -0,0 +1,118 @@
import FocusTrap from 'focus-trap-react';
import { Text, Box, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal } from 'folds';
import React, { useState, useMemo, useCallback } from 'react';
import { ImageEditor } from '../../../../components/image-editor';
import { SettingTile } from '../../../../components/setting-tile';
import { CompactUploadCardRenderer } from '../../../../components/upload-card';
import { useFilePicker } from '../../../../hooks/useFilePicker';
import { useMatrixClient } from '../../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../../hooks/useMediaAuthentication';
import { useObjectURL } from '../../../../hooks/useObjectURL';
import { createUploadAtom, UploadSuccess } from '../../../../state/upload';
import { stopPropagation } from '../../../../utils/keyboard';
import { mxcUrlToHttp } from '../../../../utils/matrix';
import { FieldContext } from '../Profile';
import { ProfileFieldElementProps } from './ProfileFieldContext';
import { ModalWide } from '../../../../styles/Modal.css';
export function ProfileAvatar({
busy, value, setValue,
}: ProfileFieldElementProps<'avatar_url', FieldContext>) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const avatarUrl = value
? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const disabled = busy;
const [imageFile, setImageFile] = useState<File>();
const imageFileURL = useObjectURL(imageFile);
const uploadAtom = useMemo(() => {
if (imageFile) return createUploadAtom(imageFile);
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
}, []);
const handleUploaded = useCallback(
(upload: UploadSuccess) => {
const { mxc } = upload;
setValue(mxc);
handleRemoveUpload();
},
[setValue, handleRemoveUpload]
);
const handleRemoveAvatar = () => {
setValue('');
};
return (
<SettingTile
title={<Text as="span" size="L400">
Avatar
</Text>}
>
{uploadAtom ? (
<Box gap="200" direction="Column">
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded} />
</Box>
) : (
<Box gap="200">
<Button
onClick={() => pickFile('image/*')}
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={disabled}
>
<Text size="B300">Upload Avatar</Text>
</Button>
{avatarUrl && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={disabled}
onClick={handleRemoveAvatar}
>
<Text size="B300">Remove Avatar</Text>
</Button>
)}
</Box>
)}
{imageFileURL && (
<Overlay open={false} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleRemoveUpload,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal className={ModalWide} variant="Surface" size="500">
<ImageEditor
name={imageFile?.name ?? 'Unnamed'}
url={imageFileURL}
requestClose={handleRemoveUpload} />
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</SettingTile>
);
}

View file

@ -0,0 +1,127 @@
import React, {
FunctionComponent,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { deepCompare } from 'matrix-js-sdk/lib/utils';
import { ExtendedProfile } from '../../../../hooks/useExtendedProfile';
/// These types ensure the element functions are actually able to manipulate
/// the profile fields they're mapped to. The <C> generic parameter represents
/// extra "context" props which are passed to every element.
// strip the index signature from ExtendedProfile using mapped type magic.
// keeping the index signature causes weird typechecking issues further down the line
// plus there should never be field elements passed with keys which don't exist in ExtendedProfile.
type ExtendedProfileKeys = keyof {
[Property in keyof ExtendedProfile as string extends Property
? never
: Property]: ExtendedProfile[Property];
};
// these are the props which all field elements must accept.
// this is split into `RawProps` and `Props` so we can type `V` instead of
// spraying `ExtendedProfile[K]` all over the place.
// don't use this directly, use the `ProfileFieldElementProps` type instead
type ProfileFieldElementRawProps<V, C> = {
defaultValue: V;
value: V;
setValue: (value: V) => void;
} & C;
export type ProfileFieldElementProps<
K extends ExtendedProfileKeys,
C
> = ProfileFieldElementRawProps<ExtendedProfile[K], C>;
// the map of extended profile keys to field element functions
type ProfileFieldElements<C> = {
[Property in ExtendedProfileKeys]?: FunctionComponent<ProfileFieldElementProps<Property, C>>;
};
type ProfileFieldContextProps<C> = {
fieldDefaults: ExtendedProfile;
fieldElements: ProfileFieldElements<C>;
children: (
reset: () => void,
hasChanges: boolean,
fields: ExtendedProfile,
fieldElements: ReactNode
) => ReactNode;
context: C;
};
/// This element manages the pending state of the profile field widgets.
/// It takes the default values of each field, as well as a map associating a profile field key
/// with an element _function_ (not a rendered element!) that will be used to edit that field.
/// It renders the editor elements internally using React.createElement and passes the rendered
/// elements into the child UI. This allows it to handle the pending state entirely by itself,
/// and provides strong typechecking.
export function ProfileFieldContext<C>({
fieldDefaults,
fieldElements: fieldElementConstructors,
children,
context,
}: ProfileFieldContextProps<C>): ReactNode {
const [fields, setFields] = useState<ExtendedProfile>(fieldDefaults);
// this callback also runs when fieldDefaults changes,
// which happens when the profile is saved and the pending fields become the new defaults
const reset = useCallback(() => {
setFields(fieldDefaults);
}, [fieldDefaults]);
// set the pending values to the defaults on the first render
useEffect(() => {
reset();
}, [reset]);
const setField = useCallback(
(key: string, value: unknown) => {
setFields({
...fields,
[key]: value,
});
},
[fields]
);
const hasChanges = useMemo(
() =>
Object.entries(fields).find(
([key, value]) =>
// deep comparison is necessary here because field values can be any JSON type
!deepCompare(fieldDefaults[key as keyof ExtendedProfile], value)
) !== undefined,
[fields, fieldDefaults]
);
const createElement = useCallback(
<K extends ExtendedProfileKeys>(key: K, element: ProfileFieldElements<C>[K]) => {
const props: ProfileFieldElementRawProps<ExtendedProfile[K], C> = {
...context,
defaultValue: fieldDefaults[key],
value: fields[key],
setValue: (value) => setField(key, value),
key,
};
// element can be undefined if the field defaults didn't include its key,
// which means the HS doesn't support setting that field
if (element !== undefined) {
return React.createElement(element, props);
}
return undefined;
},
[context, fieldDefaults, fields, setField]
);
const fieldElements = Object.entries(fieldElementConstructors).map(([key, element]) =>
// @ts-expect-error TypeScript doesn't quite understand the magic going on here
createElement(key, element)
);
return children(reset, hasChanges, fields, fieldElements);
}

View file

@ -0,0 +1,125 @@
import FocusTrap from 'focus-trap-react';
import { RectCords, Text, Box, Chip, Icon, Icons, PopOut, Menu, config, Input, Button } from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import React, { useState, FormEventHandler, KeyboardEventHandler, MouseEventHandler } from 'react';
import { SettingTile } from '../../../../components/setting-tile';
import { stopPropagation } from '../../../../utils/keyboard';
import { FieldContext } from '../Profile';
import { ProfileFieldElementProps } from './ProfileFieldContext';
export function ProfilePronouns({
value, setValue, busy,
}: ProfileFieldElementProps<'io.fsky.nyx.pronouns', FieldContext>) {
const disabled = busy;
const [menuCords, setMenuCords] = useState<RectCords>();
const [pendingPronoun, setPendingPronoun] = useState('');
const handleRemovePronoun = (index: number) => {
const newPronouns = [...(value ?? [])];
newPronouns.splice(index, 1);
if (newPronouns.length > 0) {
setValue(newPronouns);
} else {
setValue(undefined);
}
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
setMenuCords(undefined);
if (pendingPronoun.length > 0) {
setValue([...(value ?? []), { language: 'en', summary: pendingPronoun }]);
}
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (isKeyHotkey('escape', evt)) {
evt.stopPropagation();
setMenuCords(undefined);
}
};
const handleOpenMenu: MouseEventHandler<HTMLSpanElement> = (evt) => {
setPendingPronoun('');
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
return (
<SettingTile
title={<Text as="span" size="L400">
Pronouns
</Text>}
>
<Box alignItems="Center" gap="200" wrap="Wrap">
{value?.map(({ summary }, index) => (
<Chip
// eslint-disable-next-line react/no-array-index-key
key={index}
variant="Secondary"
radii="Pill"
after={<Icon src={Icons.Cross} size="100" />}
onClick={() => handleRemovePronoun(index)}
disabled={disabled}
>
<Text size="T200" truncate>
{summary}
</Text>
</Chip>
))}
<Chip
variant="Secondary"
radii="Pill"
disabled={disabled}
after={<Icon src={menuCords ? Icons.ChevronRight : Icons.Plus} size="100" />}
onClick={handleOpenMenu}
>
<Text size="T200">Add</Text>
</Chip>
</Box>
<PopOut
anchor={menuCords}
offset={5}
position="Right"
align="Center"
content={<FocusTrap
focusTrapOptions={{
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
variant="SurfaceVariant"
style={{
padding: config.space.S200,
}}
>
<Box as="form" onSubmit={handleSubmit} direction="Row" gap="200">
<Input
variant="Secondary"
placeholder="they/them"
inputSize={10}
radii="300"
size="300"
outlined
value={pendingPronoun}
onChange={(evt) => setPendingPronoun(evt.currentTarget.value)}
onKeyDown={handleKeyDown} />
<Button
type="submit"
size="300"
variant="Success"
radii="300"
before={<Icon size="100" src={Icons.Plus} />}
>
<Text size="B300">Add</Text>
</Button>
</Box>
</Menu>
</FocusTrap>} />
</SettingTile>
);
}

View file

@ -0,0 +1,63 @@
import { Text, Box, Input, IconButton, Icon, Icons } from 'folds';
import React, { ChangeEventHandler } from 'react';
import { FilterByValues } from '../../../../../types/utils';
import { SettingTile } from '../../../../components/setting-tile';
import { ExtendedProfile } from '../../../../hooks/useExtendedProfile';
import { FieldContext } from '../Profile';
import { ProfileFieldElementProps } from './ProfileFieldContext';
export function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string | undefined>>({
label, defaultValue, value, setValue, busy,
}: ProfileFieldElementProps<K, FieldContext> & { label: string; }) {
const disabled = busy;
const hasChanges = defaultValue !== value;
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const content = evt.currentTarget.value;
if (content.length > 0) {
setValue(evt.currentTarget.value);
} else {
setValue(undefined);
}
};
const handleReset = () => {
setValue(defaultValue);
};
return (
<SettingTile
title={<Text as="span" size="L400">
{label}
</Text>}
>
<Box direction="Column" grow="Yes" gap="100">
<Box gap="200" aria-disabled={disabled}>
<Box grow="Yes" direction="Column">
<Input
required
name="displayNameInput"
value={value ?? ''}
onChange={handleChange}
variant="Secondary"
radii="300"
disabled={disabled}
readOnly={disabled}
after={hasChanges &&
!busy && (
<IconButton
type="reset"
onClick={handleReset}
size="300"
radii="300"
variant="Secondary"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)} />
</Box>
</Box>
</Box>
</SettingTile>
);
}

View file

@ -0,0 +1,160 @@
import FocusTrap from 'focus-trap-react';
import { Text, Overlay, OverlayBackdrop, OverlayCenter, Dialog, Header, config, Box, IconButton, Icon, Icons, Input, toRem, MenuItem, Button } from 'folds';
import React, { useRef, useState, useMemo, useCallback, useEffect } from 'react';
import { CutoutCard } from '../../../../components/cutout-card';
import { SettingTile } from '../../../../components/setting-tile';
import { FieldContext } from '../Profile';
import { ProfileFieldElementProps } from './ProfileFieldContext';
export function ProfileTimezone({
value, setValue, busy,
}: ProfileFieldElementProps<'us.cloke.msc4175.tz', FieldContext>) {
const disabled = busy;
const inputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [overlayOpen, setOverlayOpen] = useState(false);
const [query, setQuery] = useState('');
// @ts-expect-error Intl.supportedValuesOf isn't in the types yet
const timezones = useMemo(() => Intl.supportedValuesOf('timeZone') as string[], []);
const filteredTimezones = timezones.filter(
(timezone) => query.length === 0 || timezone.toLowerCase().replace('_', ' ').includes(query.toLowerCase())
);
const handleSelect = useCallback(
(timezone: string) => {
setOverlayOpen(false);
setValue(timezone);
},
[setOverlayOpen, setValue]
);
useEffect(() => {
if (overlayOpen) {
const scrollView = scrollRef.current;
const focusedItem = scrollView?.querySelector(`[data-tz="${value}"]`);
if (value && focusedItem && scrollView) {
focusedItem.scrollIntoView({
block: 'center',
});
}
}
}, [scrollRef, value, overlayOpen]);
return (
<SettingTile
title={<Text as="span" size="L400">
Timezone
</Text>}
>
<Overlay open={overlayOpen} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: () => inputRef.current,
allowOutsideClick: true,
clickOutsideDeactivates: true,
onDeactivate: () => setOverlayOpen(false),
escapeDeactivates: (evt) => {
evt.stopPropagation();
return true;
},
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Choose a Timezone</Text>
</Box>
<IconButton size="300" onClick={() => setOverlayOpen(false)} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Input
ref={inputRef}
size="500"
variant="Background"
radii="400"
outlined
placeholder="Search"
before={<Icon size="200" src={Icons.Search} />}
value={query}
onChange={(evt) => setQuery(evt.currentTarget.value)} />
<CutoutCard ref={scrollRef} style={{ overflowY: 'scroll', height: toRem(300) }}>
{filteredTimezones.length === 0 && (
<Box
style={{ paddingTop: config.space.S700 }}
grow="Yes"
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
>
<Text size="H6" align="Center">
No Results
</Text>
</Box>
)}
{filteredTimezones.map((timezone) => (
<MenuItem
key={timezone}
data-tz={timezone}
variant={timezone === value ? 'Success' : 'Surface'}
fill={timezone === value ? 'Soft' : 'None'}
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={() => handleSelect(timezone)}
>
<Box grow="Yes">
<Text size="T200" truncate>
{timezone}
</Text>
</Box>
</MenuItem>
))}
</CutoutCard>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
<Box gap="200">
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
disabled={disabled}
onClick={() => setOverlayOpen(true)}
after={<Icon size="100" src={Icons.ChevronRight} />}
>
<Text size="B300">{value ?? 'Set Timezone'}</Text>
</Button>
{value && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={disabled}
onClick={() => setValue(undefined)}
>
<Text size="B300">Remove Timezone</Text>
</Button>
)}
</Box>
</SettingTile>
);
}

View file

@ -1,100 +0,0 @@
import React, { useCallback, useState } from 'react';
import { Box, Text, Icon, Icons, Button, MenuItem } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
import { CutoutCard } from '../../../components/cutout-card';
type AccountDataProps = {
expand: boolean;
onExpandToggle: (expand: boolean) => void;
onSelect: (type: string | null) => void;
};
export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataProps) {
const mx = useMatrixClient();
const [accountDataTypes, setAccountDataKeys] = useState(() =>
Array.from(mx.store.accountData.keys())
);
useAccountDataCallback(
mx,
useCallback(() => {
setAccountDataKeys(Array.from(mx.store.accountData.keys()));
}, [mx])
);
return (
<Box direction="Column" gap="100">
<Text size="L400">Account Data</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Global"
description="Data stored in your global account data."
after={
<Button
onClick={() => onExpandToggle(!expand)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
before={
<Icon src={expand ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
}
>
<Text size="B300">{expand ? 'Collapse' : 'Expand'}</Text>
</Button>
}
/>
{expand && (
<Box direction="Column" gap="100">
<Box justifyContent="SpaceBetween">
<Text size="L400">Events</Text>
<Text size="L400">Total: {accountDataTypes.length}</Text>
</Box>
<CutoutCard>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
onClick={() => onSelect(null)}
>
<Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
</Box>
</MenuItem>
{accountDataTypes.sort().map((type) => (
<MenuItem
key={type}
variant="Surface"
fill="None"
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={() => onSelect(type)}
>
<Box grow="Yes">
<Text size="T200" truncate>
{type}
</Text>
</Box>
</MenuItem>
))}
</CutoutCard>
</Box>
)}
</SequenceCard>
</Box>
);
}

View file

@ -0,0 +1,54 @@
import React from 'react';
import { Box, Text, Icon, Icons, MenuItem } from 'folds';
import { CutoutCard } from '../../../components/cutout-card';
type AccountDataListProps = {
types: string[];
onSelect: (type: string | null) => void;
};
export function AccountDataList({
types,
onSelect,
}: AccountDataListProps) {
return (
<Box direction="Column" gap="100">
<Box justifyContent="SpaceBetween">
<Text size="L400">Fields</Text>
<Text size="L400">Total: {types.length}</Text>
</Box>
<CutoutCard>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="0"
before={<Icon size="50" src={Icons.Plus} />}
onClick={() => onSelect(null)}
>
<Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
</Box>
</MenuItem>
{types.sort().map((type) => (
<MenuItem
key={type}
variant="Surface"
fill="None"
size="300"
radii="0"
after={<Icon size="50" src={Icons.ChevronRight} />}
onClick={() => onSelect(type)}
>
<Box grow="Yes">
<Text size="T200" truncate>
{type}
</Text>
</Box>
</MenuItem>
))}
</CutoutCard>
</Box>
);
}

View file

@ -1,5 +1,7 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds'; import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
import { AccountDataEvents } from 'matrix-js-sdk';
import { Feature, ServerSupport } from 'matrix-js-sdk/lib/feature';
import { Page, PageContent, PageHeader } from '../../../components/page'; import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
@ -8,117 +10,209 @@ import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { import {
AccountDataDeleteCallback,
AccountDataEditor, AccountDataEditor,
AccountDataSubmitCallback, AccountDataSubmitCallback,
} from '../../../components/AccountDataEditor'; } from '../../../components/AccountDataEditor';
import { copyToClipboard } from '../../../utils/dom'; import { copyToClipboard } from '../../../utils/dom';
import { AccountData } from './AccountData'; import { AccountDataList } from './AccountDataList';
import { useExtendedProfile } from '../../../hooks/useExtendedProfile';
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
import { CollapsibleCard } from '../../../components/CollapsibleCard';
type DeveloperToolsPage =
| { name: 'index' }
| { name: 'account-data'; type: string | null }
| { name: 'profile-field'; type: string | null };
type DeveloperToolsProps = { type DeveloperToolsProps = {
requestClose: () => void; requestClose: () => void;
}; };
export function DeveloperTools({ requestClose }: DeveloperToolsProps) { export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const userId = mx.getUserId() as string;
const [accountDataTypes, setAccountDataKeys] = useState(() =>
Array.from(mx.store.accountData.keys())
);
const accountDataDeletionSupported =
(mx.canSupport.get(Feature.AccountDataDeletion) ?? ServerSupport.Unsupported) !==
ServerSupport.Unsupported;
useAccountDataCallback(
mx,
useCallback(() => {
setAccountDataKeys(Array.from(mx.store.accountData.keys()));
}, [mx])
);
const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [expand, setExpend] = useState(false); const [page, setPage] = useState<DeveloperToolsPage>({ name: 'index' });
const [accountDataType, setAccountDataType] = useState<string | null>(); const [globalExpand, setGlobalExpand] = useState(false);
const [profileExpand, setProfileExpand] = useState(false);
const submitAccountData: AccountDataSubmitCallback = useCallback( const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => { async (type, content) => {
await mx.setAccountData(type, content); await mx.setAccountData(type as keyof AccountDataEvents, content);
}, },
[mx] [mx]
); );
if (accountDataType !== undefined) { const deleteAccountData: AccountDataDeleteCallback = useCallback(
return ( async (type) => {
<AccountDataEditor await mx.deleteAccountData(type as keyof AccountDataEvents);
type={accountDataType ?? undefined} },
content={accountDataType ? mx.getAccountData(accountDataType)?.getContent() : undefined} [mx]
submitChange={submitAccountData}
requestClose={() => setAccountDataType(undefined)}
/>
);
}
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Developer Tools
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<Box direction="Column" gap="100">
<Text size="L400">Options</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Enable Developer Tools"
after={
<Switch
variant="Primary"
value={developerTools}
onChange={setDeveloperTools}
/>
}
/>
</SequenceCard>
{developerTools && (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Access Token"
description="Copy access token to clipboard."
after={
<Button
onClick={() =>
copyToClipboard(mx.getAccessToken() ?? '<NO_ACCESS_TOKEN_FOUND>')
}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">Copy</Text>
</Button>
}
/>
</SequenceCard>
)}
</Box>
{developerTools && (
<AccountData
expand={expand}
onExpandToggle={setExpend}
onSelect={setAccountDataType}
/>
)}
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
); );
const submitProfileField: AccountDataSubmitCallback = useCallback(
async (type, content) => {
await mx.setExtendedProfileProperty(type, content);
await refreshExtendedProfile();
},
[mx, refreshExtendedProfile]
);
const deleteProfileField: AccountDataDeleteCallback = useCallback(
async (type) => {
await mx.deleteExtendedProfileProperty(type);
await refreshExtendedProfile();
},
[mx, refreshExtendedProfile]
);
const handleClose = useCallback(() => setPage({ name: 'index' }), [setPage]);
switch (page.name) {
case 'account-data':
return (
<AccountDataEditor
type={page.type ?? undefined}
content={
page.type
? mx.getAccountData(page.type as keyof AccountDataEvents)?.getContent()
: undefined
}
submitChange={submitAccountData}
submitDelete={accountDataDeletionSupported ? deleteAccountData : undefined}
requestClose={handleClose}
/>
);
case 'profile-field':
return (
<AccountDataEditor
type={page.type ?? undefined}
content={page.type ? extendedProfile?.[page.type] : undefined}
submitChange={submitProfileField}
submitDelete={deleteProfileField}
requestClose={handleClose}
/>
);
default:
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Developer Tools
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<Box direction="Column" gap="100">
<Text size="L400">Options</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Enable Developer Tools"
after={
<Switch
variant="Primary"
value={developerTools}
onChange={setDeveloperTools}
/>
}
/>
</SequenceCard>
{developerTools && (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Access Token"
description="Copy access token to clipboard."
after={
<Button
onClick={() =>
copyToClipboard(mx.getAccessToken() ?? '<NO_ACCESS_TOKEN_FOUND>')
}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
>
<Text size="B300">Copy</Text>
</Button>
}
/>
</SequenceCard>
)}
</Box>
{developerTools && (
<Box direction="Column" gap="100">
<Text size="L400">Account Data</Text>
<CollapsibleCard
expand={globalExpand}
setExpand={setGlobalExpand}
title="Account"
description="Private data stored in your account."
>
<AccountDataList
types={accountDataTypes}
onSelect={(type) => setPage({ name: 'account-data', type })}
/>
</CollapsibleCard>
{extendedProfile && (
<CollapsibleCard
expand={profileExpand}
setExpand={setProfileExpand}
title="Profile"
description="Public data attached to your Matrix profile."
>
<AccountDataList
types={Object.keys(extendedProfile)}
onSelect={(type) => setPage({ name: 'profile-field', type })}
/>
</CollapsibleCard>
)}
</Box>
)}
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}
} }

View file

@ -11,6 +11,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { decryptMegolmKeyFile, encryptMegolmKeyFile } from '../../../../util/cryptE2ERoomKeys'; import { decryptMegolmKeyFile, encryptMegolmKeyFile } from '../../../../util/cryptE2ERoomKeys';
import { useAlive } from '../../../hooks/useAlive'; import { useAlive } from '../../../hooks/useAlive';
import { useFilePicker } from '../../../hooks/useFilePicker'; import { useFilePicker } from '../../../hooks/useFilePicker';
import { CollapsibleCard } from '../../../components/CollapsibleCard';
function ExportKeys() { function ExportKeys() {
const mx = useMatrixClient(); const mx = useMatrixClient();
@ -121,37 +122,18 @@ function ExportKeys() {
); );
} }
function ExportKeysTile() { function ExportKeysCard() {
const [expand, setExpand] = useState(false); const [expand, setExpand] = useState(false);
return ( return (
<> <CollapsibleCard
<SettingTile expand={expand}
title="Export Messages Data" setExpand={setExpand}
description="Save password protected copy of encryption data on your device to decrypt messages later." title="Export Messages Data"
after={ description="Save password protected copy of encryption data on your device to decrypt messages later."
<Box> >
<Button <ExportKeys />
type="button" </CollapsibleCard>
onClick={() => setExpand(!expand)}
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
before={
<Icon size="100" src={expand ? Icons.ChevronTop : Icons.ChevronBottom} filled />
}
>
<Text as="span" size="B300" truncate>
{expand ? 'Collapse' : 'Expand'}
</Text>
</Button>
</Box>
}
/>
{expand && <ExportKeys />}
</>
); );
} }
@ -304,14 +286,7 @@ export function LocalBackup() {
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Local Backup</Text> <Text size="L400">Local Backup</Text>
<SequenceCard <ExportKeysCard />
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<ExportKeysTile />
</SequenceCard>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
variant="SurfaceVariant" variant="SurfaceVariant"

View file

@ -103,7 +103,7 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
alt={roomName} alt={roomName}
renderFallback={() => ( renderFallback={() => (
<RoomIcon <RoomIcon
space roomType={room.getType()}
size="50" size="50"
joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite} joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite}
filled filled

View file

@ -0,0 +1,34 @@
import { MatrixClient } from 'matrix-js-sdk';
import {
MatrixRTCSession,
MatrixRTCSessionEvent,
} from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import { useEffect, useState } from 'react';
export const useCallMembers = (mx: MatrixClient, roomId: string): CallMembership[] => {
const [memberships, setMemberships] = useState<CallMembership[]>([]);
const room = mx.getRoom(roomId);
useEffect(() => {
if (!room) {
setMemberships([]);
return undefined;
}
const mxr = mx.matrixRTC.getRoomSession(room);
const updateMemberships = () => {
if (!room.isCallRoom()) return;
setMemberships(MatrixRTCSession.callMembershipsForRoom(room));
};
updateMemberships();
mxr.on(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships);
return () => {
mxr.removeListener(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships);
};
}, [mx, room, roomId]);
return memberships;
};

View file

@ -9,6 +9,7 @@ export type ClientConfig = {
defaultHomeserver?: number; defaultHomeserver?: number;
homeserverList?: string[]; homeserverList?: string[];
allowCustomHomeservers?: boolean; allowCustomHomeservers?: boolean;
elementCallUrl?: string;
featuredCommunities?: { featuredCommunities?: {
openAsDefault?: boolean; openAsDefault?: boolean;

View file

@ -0,0 +1,112 @@
import { useCallback } from 'react';
import z from 'zod';
import { useQuery } from '@tanstack/react-query';
import { Capabilities } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useSpecVersions } from './useSpecVersions';
import { IProfileFieldsCapability } from '../../types/matrix/common';
const extendedProfile = z.looseObject({
displayname: z.string().optional(),
avatar_url: z.string().optional(),
'io.fsky.nyx.pronouns': z
.object({
language: z.string(),
summary: z.string(),
})
.array()
.optional()
.catch(undefined),
'us.cloke.msc4175.tz': z.string().optional().catch(undefined),
});
export type ExtendedProfile = z.infer<typeof extendedProfile>;
export function useExtendedProfileSupported(): boolean {
const { versions, unstable_features: unstableFeatures } = useSpecVersions();
return unstableFeatures?.['uk.tcpip.msc4133'] || versions.includes('v1.15');
}
/// Returns the user's MSC4133 extended profile, if our homeserver supports it.
/// This will return `undefined` while the request is in flight and `null` if the HS lacks support.
export function useExtendedProfile(
userId: string
): [ExtendedProfile | undefined | null, () => Promise<void>] {
const mx = useMatrixClient();
const extendedProfileSupported = useExtendedProfileSupported();
const { data, refetch } = useQuery({
queryKey: ['extended-profile', userId],
queryFn: useCallback(async () => {
if (extendedProfileSupported) {
return extendedProfile.parse(await mx.getExtendedProfile(userId));
}
return null;
}, [mx, userId, extendedProfileSupported]),
refetchOnMount: false,
});
return [
data,
async () => {
await refetch();
},
];
}
const LEGACY_FIELDS = ['displayname', 'avatar_url'];
/// Returns whether the given profile field may be edited by the user.
export function profileEditsAllowed(
field: string,
capabilities: Capabilities,
extendedProfileSupported: boolean
): boolean {
if (LEGACY_FIELDS.includes(field)) {
// this field might have a pre-msc4133 capability. check that first
if (capabilities[`m.set_${field}`]?.enabled === false) {
return false;
}
if (!extendedProfileSupported) {
// the homeserver only supports legacy fields
return true;
}
}
if (extendedProfileSupported) {
// the homeserver has msc4133 support
const extendedProfileCapability = capabilities[
'uk.tcpip.msc4133.profile_fields'
] as IProfileFieldsCapability;
if (extendedProfileCapability === undefined) {
// the capability is missing, assume modification is allowed
return true;
}
if (!extendedProfileCapability.enabled) {
// the capability is set to disable profile modifications
return false;
}
if (
extendedProfileCapability.allowed !== undefined &&
!extendedProfileCapability.allowed.includes(field)
) {
// the capability includes an allowlist and `field` isn't in it
return false;
}
if (extendedProfileCapability.disallowed?.includes(field)) {
// the capability includes an blocklist and `field` is in it
return false;
}
// the capability is enabled and `field` isn't blocked
return true;
}
// `field` is an extended profile key and the homeserver lacks msc4133 support
return false;
}

View file

@ -1,28 +1,35 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { Room } from 'matrix-js-sdk'; import { MatrixEvent, Room } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room'; import { StateEvent } from '../../types/matrix/room';
import { useMatrixClient } from './useMatrixClient';
import { useForceUpdate } from './useForceUpdate'; import { useForceUpdate } from './useForceUpdate';
import { useStateEventCallback } from './useStateEventCallback'; import { useStateEventCallback } from './useStateEventCallback';
import { getStateEvents } from '../utils/room';
export const useStateEvents = (room: Room, eventType: StateEvent) => { export const useStateEvents = (rooms: Room[], eventType: StateEvent): number => {
const mx = useMatrixClient();
const [updateCount, forceUpdate] = useForceUpdate(); const [updateCount, forceUpdate] = useForceUpdate();
useStateEventCallback( const relevantRoomIds = useMemo(() => {
room.client, const ids = new Set<string>();
useCallback( if (rooms && Array.isArray(rooms)) {
(event) => { rooms.forEach((room) => {
if (event.getRoomId() === room.roomId && event.getType() === eventType) { if (room?.roomId) {
forceUpdate(); ids.add(room.roomId);
} }
}, });
[room, eventType, forceUpdate] }
) return ids;
); }, [rooms]);
const handleEventCallback = useCallback(
return useMemo( (event: MatrixEvent) => {
() => getStateEvents(room, eventType), const eventRoomId = event.getRoomId();
// eslint-disable-next-line react-hooks/exhaustive-deps if (eventRoomId && event.getType() === eventType && relevantRoomIds.has(eventRoomId)) {
[room, eventType, updateCount] forceUpdate();
}
},
[eventType, relevantRoomIds, forceUpdate]
); );
useStateEventCallback(mx, handleEventCallback);
return updateCount;
}; };

View file

@ -68,6 +68,8 @@ import { Create } from './client/create';
import { CreateSpaceModalRenderer } from '../features/create-space'; import { CreateSpaceModalRenderer } from '../features/create-space';
import { SearchModalRenderer } from '../features/search'; import { SearchModalRenderer } from '../features/search';
import { getFallbackSession } from '../state/sessions'; import { getFallbackSession } from '../state/sessions';
import { CallProvider } from './client/call/CallProvider';
import { PersistentCallContainer } from './client/call/PersistentCallContainer';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig; const { hashRouter } = clientConfig;
@ -123,15 +125,19 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<ClientRoomsNotificationPreferences> <ClientRoomsNotificationPreferences>
<ClientBindAtoms> <ClientBindAtoms>
<ClientNonUIFeatures> <ClientNonUIFeatures>
<ClientLayout <CallProvider>
nav={ <ClientLayout
<MobileFriendlyClientNav> nav={
<SidebarNav /> <MobileFriendlyClientNav>
</MobileFriendlyClientNav> <SidebarNav />
} </MobileFriendlyClientNav>
> }
<Outlet /> >
</ClientLayout> <PersistentCallContainer>
<Outlet />
</PersistentCallContainer>
</ClientLayout>
</CallProvider>
<SearchModalRenderer /> <SearchModalRenderer />
<UserRoomProfileRenderer /> <UserRoomProfileRenderer />
<CreateRoomModalRenderer /> <CreateRoomModalRenderer />

View file

@ -0,0 +1,343 @@
import React, {
createContext,
useState,
useContext,
useMemo,
useCallback,
ReactNode,
useEffect,
} from 'react';
import {
WidgetApiToWidgetAction,
WidgetApiAction,
ClientWidgetApi,
IWidgetApiRequestData,
} from 'matrix-widget-api';
import { useParams } from 'react-router-dom';
import { SmallWidget } from '../../../features/call/SmallWidget';
interface MediaStatePayload {
data?: {
audio_enabled?: boolean;
video_enabled?: boolean;
};
}
const WIDGET_MEDIA_STATE_UPDATE_ACTION = 'io.element.device_mute';
const WIDGET_HANGUP_ACTION = 'im.vector.hangup';
const WIDGET_ON_SCREEN_ACTION = 'set_always_on_screen';
const WIDGET_JOIN_ACTION = 'io.element.join';
const WIDGET_TILE_UPDATE = 'io.element.tile_layout';
interface CallContextState {
activeCallRoomId: string | null;
setActiveCallRoomId: (roomId: string | null) => void;
viewedCallRoomId: string | null;
setViewedCallRoomId: (roomId: string | null) => void;
hangUp: () => void;
activeClientWidgetApi: ClientWidgetApi | null;
activeClientWidget: SmallWidget | null;
registerActiveClientWidgetApi: (
roomId: string | null,
clientWidgetApi: ClientWidgetApi | null,
clientWidget: SmallWidget,
activeClientIframeRef: HTMLIFrameElement
) => void;
sendWidgetAction: <T extends IWidgetApiRequestData = IWidgetApiRequestData>(
action: WidgetApiToWidgetAction | string,
data: T
) => Promise<void>;
isAudioEnabled: boolean;
isVideoEnabled: boolean;
isChatOpen: boolean;
isActiveCallReady: boolean;
toggleAudio: () => Promise<void>;
toggleVideo: () => Promise<void>;
toggleChat: () => Promise<void>;
}
const CallContext = createContext<CallContextState | undefined>(undefined);
interface CallProviderProps {
children: ReactNode;
}
const DEFAULT_AUDIO_ENABLED = true;
const DEFAULT_VIDEO_ENABLED = false;
const DEFAULT_CHAT_OPENED = false;
export function CallProvider({ children }: CallProviderProps) {
const [activeCallRoomId, setActiveCallRoomIdState] = useState<string | null>(null);
const [viewedCallRoomId, setViewedCallRoomIdState] = useState<string | null>(null);
const [activeClientWidgetApi, setActiveClientWidgetApiState] = useState<ClientWidgetApi | null>(
null
);
const [activeClientWidget, setActiveClientWidget] = useState<SmallWidget | null>(null);
const [activeClientWidgetApiRoomId, setActiveClientWidgetApiRoomId] = useState<string | null>(
null
);
const [activeClientWidgetIframeRef, setActiveClientWidgetIframeRef] =
useState<HTMLIFrameElement | null>(null);
const [isAudioEnabled, setIsAudioEnabledState] = useState<boolean>(DEFAULT_AUDIO_ENABLED);
const [isVideoEnabled, setIsVideoEnabledState] = useState<boolean>(DEFAULT_VIDEO_ENABLED);
const [isChatOpen, setIsChatOpenState] = useState<boolean>(DEFAULT_CHAT_OPENED);
const [isActiveCallReady, setIsActiveCallReady] = useState<boolean>(false);
const { roomIdOrAlias: viewedRoomId } = useParams<{ roomIdOrAlias: string }>();
const setActiveCallRoomId = useCallback((roomId: string | null) => {
setActiveCallRoomIdState(roomId);
}, []);
const setViewedCallRoomId = useCallback(
(roomId: string | null) => {
setViewedCallRoomIdState(roomId);
},
[setViewedCallRoomIdState]
);
const setActiveClientWidgetApi = useCallback(
(
clientWidgetApi: ClientWidgetApi | null,
clientWidget: SmallWidget | null,
roomId: string | null,
clientWidgetIframeRef: HTMLIFrameElement | null
) => {
setActiveClientWidgetApiState(clientWidgetApi);
setActiveClientWidget(clientWidget);
setActiveClientWidgetApiRoomId(roomId);
setActiveClientWidgetIframeRef(clientWidgetIframeRef);
},
[]
);
const registerActiveClientWidgetApi = useCallback(
(
roomId: string | null,
clientWidgetApi: ClientWidgetApi | null,
clientWidget: SmallWidget | null,
clientWidgetIframeRef: HTMLIFrameElement | null
) => {
if (roomId && clientWidgetApi) {
setActiveClientWidgetApi(clientWidgetApi, clientWidget, roomId, clientWidgetIframeRef);
} else if (roomId === activeClientWidgetApiRoomId || roomId === null) {
setActiveClientWidgetApi(null, null, null, null);
}
},
[activeClientWidgetApiRoomId, setActiveClientWidgetApi]
);
const hangUp = useCallback(() => {
setActiveClientWidgetApi(null, null, null, null);
setActiveCallRoomIdState(null);
activeClientWidgetApi?.transport.send(`${WIDGET_HANGUP_ACTION}`, {});
setIsActiveCallReady(false);
}, [activeClientWidgetApi?.transport, setActiveClientWidgetApi]);
const sendWidgetAction = useCallback(
async <T extends IWidgetApiRequestData = IWidgetApiRequestData>(
action: WidgetApiToWidgetAction | string,
data: T
): Promise<void> => {
if (!activeClientWidgetApi) {
return Promise.reject(new Error('No active call clientWidgetApi'));
}
if (!activeClientWidgetApiRoomId || activeClientWidgetApiRoomId !== activeCallRoomId) {
return Promise.reject(new Error('Mismatched active call clientWidgetApi'));
}
await activeClientWidgetApi.transport.send(action as WidgetApiAction, data);
return Promise.resolve();
},
[activeClientWidgetApi, activeCallRoomId, activeClientWidgetApiRoomId]
);
const toggleAudio = useCallback(async () => {
const newState = !isAudioEnabled;
setIsAudioEnabledState(newState);
if (isActiveCallReady) {
try {
await sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, {
audio_enabled: newState,
video_enabled: isVideoEnabled,
});
} catch (error) {
setIsAudioEnabledState(!newState);
throw error;
}
}
}, [isAudioEnabled, isVideoEnabled, sendWidgetAction, isActiveCallReady]);
const toggleVideo = useCallback(async () => {
const newState = !isVideoEnabled;
setIsVideoEnabledState(newState);
if (isActiveCallReady) {
try {
await sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, {
audio_enabled: isAudioEnabled,
video_enabled: newState,
});
} catch (error) {
setIsVideoEnabledState(!newState);
throw error;
}
}
}, [isVideoEnabled, isAudioEnabled, sendWidgetAction, isActiveCallReady]);
useEffect(() => {
if (!activeCallRoomId && !viewedCallRoomId) {
return;
}
if (!activeClientWidgetApi) {
return;
}
const handleHangup = (ev: CustomEvent) => {
ev.preventDefault();
if (isActiveCallReady && ev.detail.widgetId === activeClientWidgetApi.widget.id) {
activeClientWidgetApi.transport.reply(ev.detail, {});
}
};
const handleMediaStateUpdate = (ev: CustomEvent<MediaStatePayload>) => {
if (!isActiveCallReady) return;
ev.preventDefault();
/* eslint-disable camelcase */
const { audio_enabled, video_enabled } = ev.detail.data ?? {};
if (typeof audio_enabled === 'boolean' && audio_enabled !== isAudioEnabled) {
setIsAudioEnabledState(audio_enabled);
}
if (typeof video_enabled === 'boolean' && video_enabled !== isVideoEnabled) {
setIsVideoEnabledState(video_enabled);
}
/* eslint-enable camelcase */
};
const handleOnScreenStateUpdate = (ev: CustomEvent) => {
ev.preventDefault();
activeClientWidgetApi.transport.reply(ev.detail, {});
};
const handleOnTileLayout = (ev: CustomEvent) => {
ev.preventDefault();
activeClientWidgetApi.transport.reply(ev.detail, {});
};
const handleJoin = (ev: CustomEvent) => {
ev.preventDefault();
activeClientWidgetApi.transport.reply(ev.detail, {});
const iframeDoc =
activeClientWidgetIframeRef?.contentWindow?.document ||
activeClientWidgetIframeRef?.contentDocument;
if (iframeDoc) {
const observer = new MutationObserver(() => {
const button = iframeDoc.querySelector('[data-testid="incall_leave"]');
if (button) {
button.addEventListener('click', () => {
hangUp();
});
}
observer.disconnect();
});
observer.observe(iframeDoc, { childList: true, subtree: true });
}
setIsActiveCallReady(true);
};
void sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, {
audio_enabled: isAudioEnabled,
video_enabled: isVideoEnabled,
}).catch(() => {
// Widget transport may reject while call/session setup is still in progress.
});
activeClientWidgetApi.on(`action:${WIDGET_HANGUP_ACTION}`, handleHangup);
activeClientWidgetApi.on(`action:${WIDGET_MEDIA_STATE_UPDATE_ACTION}`, handleMediaStateUpdate);
activeClientWidgetApi.on(`action:${WIDGET_TILE_UPDATE}`, handleOnTileLayout);
activeClientWidgetApi.on(`action:${WIDGET_ON_SCREEN_ACTION}`, handleOnScreenStateUpdate);
activeClientWidgetApi.on(`action:${WIDGET_JOIN_ACTION}`, handleJoin);
}, [
activeClientWidgetIframeRef,
activeClientWidgetApi,
activeCallRoomId,
activeClientWidgetApiRoomId,
hangUp,
isChatOpen,
isAudioEnabled,
isVideoEnabled,
isActiveCallReady,
viewedRoomId,
viewedCallRoomId,
setViewedCallRoomId,
activeClientWidget?.iframe?.contentDocument,
activeClientWidget?.iframe?.contentWindow?.document,
sendWidgetAction,
]);
const toggleChat = useCallback(async () => {
const newState = !isChatOpen;
setIsChatOpenState(newState);
}, [isChatOpen]);
const contextValue = useMemo<CallContextState>(
() => ({
activeCallRoomId,
setActiveCallRoomId,
viewedCallRoomId,
setViewedCallRoomId,
hangUp,
activeClientWidgetApi,
registerActiveClientWidgetApi,
activeClientWidget,
sendWidgetAction,
isChatOpen,
isAudioEnabled,
isVideoEnabled,
isActiveCallReady,
toggleAudio,
toggleVideo,
toggleChat,
}),
[
activeCallRoomId,
setActiveCallRoomId,
viewedCallRoomId,
setViewedCallRoomId,
hangUp,
activeClientWidgetApi,
registerActiveClientWidgetApi,
activeClientWidget,
sendWidgetAction,
isChatOpen,
isAudioEnabled,
isVideoEnabled,
isActiveCallReady,
toggleAudio,
toggleVideo,
toggleChat,
]
);
return <CallContext.Provider value={contextValue}>{children}</CallContext.Provider>;
}
export function useCallState(): CallContextState {
const context = useContext(CallContext);
if (context === undefined) {
throw new Error('useCallState must be used within a CallProvider');
}
return context;
}

View file

@ -0,0 +1,186 @@
import React, { createContext, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
import { ClientWidgetApi } from 'matrix-widget-api';
import { Box } from 'folds';
import { useCallState } from './CallProvider';
import {
createVirtualWidget,
SmallWidget,
getWidgetData,
getWidgetUrl,
} from '../../../features/call/SmallWidget';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { ThemeKind, useTheme } from '../../../hooks/useTheme';
interface PersistentCallContainerProps {
children: ReactNode;
}
export const CallRefContext =
createContext<React.MutableRefObject<HTMLIFrameElement | null> | null>(null);
export function PersistentCallContainer({ children }: PersistentCallContainerProps) {
const callIframeRef = useRef<HTMLIFrameElement | null>(null);
const callWidgetApiRef = useRef<ClientWidgetApi | null>(null);
const callSmallWidgetRef = useRef<SmallWidget | null>(null);
const {
activeCallRoomId,
viewedCallRoomId,
isChatOpen,
isActiveCallReady,
registerActiveClientWidgetApi,
activeClientWidget,
} = useCallState();
const mx = useMatrixClient();
const clientConfig = useClientConfig();
const screenSize = useScreenSizeContext();
const theme = useTheme();
const isMobile = screenSize === ScreenSize.Mobile;
/* eslint-disable no-param-reassign */
const setupWidget = useCallback(
(
widgetApiRef: React.MutableRefObject<ClientWidgetApi | null>,
smallWidgetRef: React.MutableRefObject<SmallWidget | null>,
iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
skipLobby: boolean,
themeKind: ThemeKind | null
) => {
if (mx?.getUserId()) {
if (activeCallRoomId && !isActiveCallReady) {
const roomIdToSet = activeCallRoomId;
const widgetId = `element-call-${roomIdToSet}-${Date.now()}`;
const newUrl = getWidgetUrl(
mx,
roomIdToSet,
clientConfig.elementCallUrl ?? '',
widgetId,
{
skipLobby: skipLobby.toString(),
returnToLobby: 'true',
perParticipantE2EE: 'true',
theme: themeKind,
}
);
if (
callSmallWidgetRef.current?.roomId &&
activeClientWidget?.roomId &&
activeClientWidget.roomId === callSmallWidgetRef.current?.roomId
) {
return;
}
if (
iframeRef.current &&
(!iframeRef.current.src || iframeRef.current.src !== newUrl.toString())
) {
iframeRef.current.src = newUrl.toString();
}
const iframeElement = iframeRef.current;
if (!iframeElement) {
return;
}
const userId = mx.getUserId() ?? '';
const app = createVirtualWidget(
mx,
widgetId,
userId,
'Element Call',
'm.call',
newUrl,
true,
getWidgetData(mx, roomIdToSet, {}, { skipLobby: true }),
roomIdToSet
);
const smallWidget = new SmallWidget(app);
smallWidgetRef.current = smallWidget;
const widgetApiInstance = smallWidget.startMessaging(iframeElement);
widgetApiRef.current = widgetApiInstance;
registerActiveClientWidgetApi(
roomIdToSet,
widgetApiRef.current,
smallWidget,
iframeElement
);
}
}
},
[
mx,
activeCallRoomId,
isActiveCallReady,
clientConfig.elementCallUrl,
activeClientWidget,
registerActiveClientWidgetApi,
]
);
useEffect(() => {
if (activeCallRoomId) {
setupWidget(callWidgetApiRef, callSmallWidgetRef, callIframeRef, true, theme.kind);
}
}, [
theme,
setupWidget,
callWidgetApiRef,
callSmallWidgetRef,
callIframeRef,
registerActiveClientWidgetApi,
activeCallRoomId,
viewedCallRoomId,
isActiveCallReady,
]);
const memoizedIframeRef = useMemo(() => callIframeRef, [callIframeRef]);
return (
<CallRefContext.Provider value={memoizedIframeRef}>
<Box grow="No">
<Box
direction="Column"
style={{
position: 'relative',
zIndex: 0,
display: isMobile && isChatOpen ? 'none' : 'flex',
width: isMobile && isChatOpen ? '0%' : '100%',
height: isMobile && isChatOpen ? '0%' : '100%',
}}
>
<Box
grow="Yes"
style={{
position: 'relative',
}}
>
<iframe
ref={callIframeRef}
style={{
position: 'absolute',
top: 0,
left: 0,
display: 'flex',
width: '100%',
height: '100%',
border: 'none',
}}
title="Persistent Element Call"
sandbox="allow-forms allow-scripts allow-same-origin allow-popups allow-modals allow-downloads"
allow="microphone; camera; display-capture; autoplay; clipboard-write;"
src="about:blank"
/>
</Box>
</Box>
</Box>
{children}
</CallRefContext.Provider>
);
}

View file

@ -51,6 +51,7 @@ import {
useRoomsNotificationPreferencesContext, useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences'; } from '../../../hooks/useRoomsNotificationPreferences';
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected'; import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
import { CallNavStatus } from '../../../features/room-nav/RoomCallNavStatus';
type DirectMenuProps = { type DirectMenuProps = {
requestClose: () => void; requestClose: () => void;
@ -275,6 +276,7 @@ export function Direct() {
</Box> </Box>
</PageNavContent> </PageNavContent>
)} )}
<CallNavStatus />
</PageNav> </PageNav>
); );
} }

View file

@ -65,6 +65,7 @@ import {
import { UseStateProvider } from '../../../components/UseStateProvider'; import { UseStateProvider } from '../../../components/UseStateProvider';
import { JoinAddressPrompt } from '../../../components/join-address-prompt'; import { JoinAddressPrompt } from '../../../components/join-address-prompt';
import { _RoomSearchParams } from '../../paths'; import { _RoomSearchParams } from '../../paths';
import { CallNavStatus } from '../../../features/room-nav/RoomCallNavStatus';
type HomeMenuProps = { type HomeMenuProps = {
requestClose: () => void; requestClose: () => void;
@ -357,6 +358,7 @@ export function Home() {
</Box> </Box>
</PageNavContent> </PageNavContent>
)} )}
<CallNavStatus />
</PageNav> </PageNav>
); );
} }

View file

@ -1,10 +1,9 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Text } from 'folds'; import { Icon, Icons } from 'folds';
import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar'; import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar';
import { UserAvatar } from '../../../components/user-avatar'; import { UserAvatar } from '../../../components/user-avatar';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { mxcUrlToHttp } from '../../../utils/matrix';
import { nameInitials } from '../../../utils/common';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Settings } from '../../../features/settings'; import { Settings } from '../../../features/settings';
import { useUserProfile } from '../../../hooks/useUserProfile'; import { useUserProfile } from '../../../hooks/useUserProfile';
@ -13,12 +12,11 @@ import { Modal500 } from '../../../components/Modal500';
export function SettingsTab() { export function SettingsTab() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const userId = mx.getUserId()!; const userId = mx.getUserId() as string;
const profile = useUserProfile(userId); const profile = useUserProfile(userId);
const [settings, setSettings] = useState(false); const [settings, setSettings] = useState(false);
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined; : undefined;
@ -34,7 +32,7 @@ export function SettingsTab() {
<UserAvatar <UserAvatar
userId={userId} userId={userId}
src={avatarUrl} src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>} renderFallback={() => <Icon size="400" src={Icons.User} filled />}
/> />
</SidebarAvatar> </SidebarAvatar>
)} )}

View file

@ -84,6 +84,8 @@ import { ContainerColor } from '../../../styles/ContainerColor.css';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { BreakWord } from '../../../styles/Text.css'; import { BreakWord } from '../../../styles/Text.css';
import { InviteUserPrompt } from '../../../components/invite-user-prompt'; import { InviteUserPrompt } from '../../../components/invite-user-prompt';
import { CallNavStatus } from '../../../features/room-nav/RoomCallNavStatus';
import { useCallState } from '../call/CallProvider';
type SpaceMenuProps = { type SpaceMenuProps = {
room: Room; room: Room;
@ -296,7 +298,7 @@ function SpaceHeader() {
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
}} }}
> >
<SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} /> {space && <SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />}
</FocusTrap> </FocusTrap>
} }
/> />
@ -387,15 +389,15 @@ export function Space() {
const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationPreferences = useRoomsNotificationPreferencesContext();
const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone); const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone);
const selectedRoomId = useSelectedRoom(); const selectedRoomId = useSelectedRoom();
const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias);
const searchSelected = useSpaceSearchSelected(spaceIdOrAlias); const searchSelected = useSpaceSearchSelected(spaceIdOrAlias);
const { isActiveCallReady, activeCallRoomId } = useCallState();
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const getRoom = useCallback( const getRoom = useCallback(
(rId: string) => { (rId: string): Room | undefined => {
if (allJoinedRooms.has(rId)) { if (allJoinedRooms.has(rId)) {
return mx.getRoom(rId) ?? undefined; return mx.getRoom(rId) ?? undefined;
} }
@ -412,11 +414,20 @@ export function Space() {
if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) {
return false; return false;
} }
const showRoom = roomToUnread.has(roomId) || roomId === selectedRoomId; const showRoomAnyway =
if (showRoom) return false; roomToUnread.has(roomId) ||
return true; roomId === selectedRoomId ||
(isActiveCallReady && activeCallRoomId === roomId);
return !showRoomAnyway;
}, },
[space.roomId, closedCategories, roomToUnread, selectedRoomId] [
space.roomId,
closedCategories,
roomToUnread,
selectedRoomId,
activeCallRoomId,
isActiveCallReady,
]
), ),
useCallback( useCallback(
(sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)),
@ -427,7 +438,7 @@ export function Space() {
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: hierarchy.length, count: hierarchy.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
estimateSize: () => 0, estimateSize: () => 32,
overscan: 10, overscan: 10,
}); });
@ -534,6 +545,7 @@ export function Space() {
</NavCategory> </NavCategory>
</Box> </Box>
</PageNavContent> </PageNavContent>
<CallNavStatus />
</PageNav> </PageNav>
); );
} }

View file

@ -160,7 +160,8 @@ export const getOrphanParents = (roomToParents: RoomToParents, roomId: string):
}; };
export const isMutedRule = (rule: IPushRule) => export const isMutedRule = (rule: IPushRule) =>
rule.actions[0] === 'dont_notify' && rule.conditions?.[0]?.kind === 'event_match'; // Check for empty actions (new spec) or dont_notify (deprecated)
(rule.actions.length === 0 || rule.actions[0] === 'dont_notify') && rule.conditions?.[0]?.kind === 'event_match';
export const findMutedRule = (overrideRules: IPushRule[], roomId: string) => export const findMutedRule = (overrideRules: IPushRule[], roomId: string) =>
overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule)); overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule));
@ -256,24 +257,60 @@ export const getUnreadInfos = (mx: MatrixClient): UnreadInfo[] => {
return unreadInfos; return unreadInfos;
}; };
export const joinRuleToIconSrc = ( export const getRoomIconSrc = (
icons: Record<IconName, IconSrc>, icons: Record<IconName, IconSrc>,
joinRule: JoinRule, roomType?: string,
space: boolean joinRule?: JoinRule,
): IconSrc | undefined => { locked?: boolean
if (joinRule === JoinRule.Restricted) { ): IconSrc => {
return space ? icons.Space : icons.Hash; type RoomIcons = {
base: IconSrc;
locked: IconSrc;
public: IconSrc;
};
const roomTypeIcons: Record<string, RoomIcons> = {
[RoomType.Call]: {
base: icons.VolumeHigh,
locked: icons.Lock,
public: icons.VolumeHigh,
},
[RoomType.Space]: {
base: icons.Space,
locked: icons.SpaceLock,
public: icons.SpaceGlobe,
},
default: {
base: icons.Hash,
locked: icons.HashLock,
public: icons.HashGlobe,
},
};
const roomIcons = roomTypeIcons[roomType ?? 'default'] ?? roomTypeIcons.default;
let roomIcon = roomIcons.base;
if (locked) {
roomIcon = roomIcons.locked;
} else {
switch (joinRule) {
case JoinRule.Invite:
case JoinRule.Knock:
roomIcon = roomIcons.locked;
break;
case JoinRule.Restricted:
roomIcon = roomIcons.base;
break;
case JoinRule.Public:
roomIcon = roomIcons.public;
break;
default:
break;
}
} }
if (joinRule === JoinRule.Knock) {
return space ? icons.SpaceLock : icons.HashLock; return roomIcon;
}
if (joinRule === JoinRule.Invite) {
return space ? icons.SpaceLock : icons.HashLock;
}
if (joinRule === JoinRule.Public) {
return space ? icons.SpaceGlobe : icons.HashGlobe;
}
return undefined;
}; };
export const getRoomAvatarUrl = ( export const getRoomAvatarUrl = (

View file

@ -3,7 +3,8 @@ import { MsgType } from 'matrix-js-sdk';
export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash'; export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash';
export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler'; export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler';
export const MATRIX_SPOILER_REASON_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler.reason'; export const MATRIX_SPOILER_REASON_PROPERTY_NAME =
'page.codeberg.everypizza.msc4193.spoiler.reason';
export type IImageInfo = { export type IImageInfo = {
w?: number; w?: number;
@ -88,3 +89,9 @@ export type ILocationContent = {
geo_uri?: string; geo_uri?: string;
info?: IThumbnailContent; info?: IThumbnailContent;
}; };
export type IProfileFieldsCapability = {
enabled?: boolean;
allowed?: string[];
disallowed?: string[];
};

View file

@ -32,6 +32,8 @@ export enum StateEvent {
RoomGuestAccess = 'm.room.guest_access', RoomGuestAccess = 'm.room.guest_access',
RoomServerAcl = 'm.room.server_acl', RoomServerAcl = 'm.room.server_acl',
RoomTombstone = 'm.room.tombstone', RoomTombstone = 'm.room.tombstone',
GroupCallPrefix = 'org.matrix.msc3401.call',
GroupCallMemberPrefix = 'org.matrix.msc3401.call.member',
SpaceChild = 'm.space.child', SpaceChild = 'm.space.child',
SpaceParent = 'm.space.parent', SpaceParent = 'm.space.parent',
@ -50,6 +52,7 @@ export enum MessageEvent {
export enum RoomType { export enum RoomType {
Space = 'm.space', Space = 'm.space',
Call = 'org.matrix.msc3417.call',
} }
export type MSpaceChildContent = { export type MSpaceChildContent = {

View file

@ -1,3 +1,8 @@
export type WithRequiredProp<Type extends object, Key extends keyof Type> = Type & { export type WithRequiredProp<Type extends object, Key extends keyof Type> = Type & {
[Property in Key]-?: Type[Property]; [Property in Key]-?: Type[Property];
}; };
// Represents a subset of T containing only the keys whose values extend V
export type FilterByValues<T extends object, V> = {
[Property in keyof T as T[Property] extends V ? Property : never]: T[Property];
};

View file

@ -13,6 +13,10 @@ import buildConfig from './build.config';
const copyFiles = { const copyFiles = {
targets: [ targets: [
{
src: 'node_modules/@element-hq/element-call-embedded/dist/*',
dest: 'public/element-call',
},
{ {
src: 'node_modules/pdfjs-dist/build/pdf.worker.min.mjs', src: 'node_modules/pdfjs-dist/build/pdf.worker.min.mjs',
dest: '', dest: '',
@ -47,7 +51,10 @@ function serverMatrixSdkCryptoWasm(wasmFilePath) {
configureServer(server) { configureServer(server) {
server.middlewares.use((req, res, next) => { server.middlewares.use((req, res, next) => {
if (req.url === wasmFilePath) { if (req.url === wasmFilePath) {
const resolvedPath = path.join(path.resolve(), "/node_modules/@matrix-org/matrix-sdk-crypto-wasm/pkg/matrix_sdk_crypto_wasm_bg.wasm"); const resolvedPath = path.join(
path.resolve(),
'/node_modules/@matrix-org/matrix-sdk-crypto-wasm/pkg/matrix_sdk_crypto_wasm_bg.wasm'
);
if (fs.existsSync(resolvedPath)) { if (fs.existsSync(resolvedPath)) {
res.setHeader('Content-Type', 'application/wasm'); res.setHeader('Content-Type', 'application/wasm');
@ -102,8 +109,8 @@ export default defineConfig({
}, },
devOptions: { devOptions: {
enabled: true, enabled: true,
type: 'module' type: 'module',
} },
}), }),
], ],
optimizeDeps: { optimizeDeps: {