Compare commits

...

73 Commits

Author SHA1 Message Date
root 9253fc33fd feat: resizable PiP call window
Add 4 corner resize handles to the PiP window (SE/SW/NE/NW).
Each handle shows a 3-dot grip indicator and sets the appropriate
resize cursor. Resize handles sit above the drag overlay (zIndex 2)
and stop propagation so they do not trigger drag-to-move.

On resize start the element is normalised to top/left positioning
so math is consistent regardless of whether bottom/right was active.
Minimum size 200x112px. Viewport clamped.
2026-05-14 23:07:29 -04:00
root bd9dbb5e83 fix: correct settings and reactions button selectors for EC 0.19.3
EC 0.19.3 changed the toolbar layout. The old previousElementSibling
traversal from the leave button pointed at wrong elements:
- settingsButton was finding the raise-hand button
- reactionsButton was finding the screenshare button

Fix: use stable selectors instead:
- settingsButton: data-testid=settings-bottom-center (new in EC 0.19.3)
- reactionsButton: [class*=raiseHand] (CSS module class, consistent in 0.19.x)
2026-05-14 23:00:05 -04:00
root 70eb0edc47 fix: pass room to startCall in DM call button
onClick={startCall} was passing the MouseEvent as the room argument,
causing getLiveTimeline is not a function crash in getRoomSession.
2026-05-14 22:51:36 -04:00
root 75a05cf83d feat: draggable PiP call window
Drag the PiP window anywhere on screen to move it out of the way.
Click (without dragging) still navigates back to the call room.
5px movement threshold distinguishes drag from click.
Mouse and touch both supported. Cursor shows grab/grabbing cues.
2026-05-14 22:50:20 -04:00
root 44b48b05b4 docs: update README with recent feature additions
- EC upgraded to 0.19.3
- Auto-revert spotlight on screenshare
- DM calls (phone button + Room.tsx layout switch)
- Picture-in-picture call window
- PTT badge terminal theming
- GIF picker (Giphy SDK, FocusTrap close, terminal theme)
- Technical notes for CallEmbedProvider, GifPicker, useClientConfig
2026-05-14 22:45:04 -04:00
root 9f6220b1bb fix: PTT input guard, listener stability, focus restore mute, single badge 2026-05-14 20:14:06 -04:00
root 20abfc0342 fix: PTT iframe focus, folds-native PTT badge styling 2026-05-14 19:37:19 -04:00
root 94722c8a97 fix: PTT blur/unmute, EC button hiding robustness, PTT status indicator 2026-05-14 19:29:45 -04:00
root 69091bc055 fix: re-apply desired device state after EC joins, support mid-call PTT 2026-05-14 19:14:29 -04:00
root c37220eb21 fix: pass audio/video URL params to EC for correct initial device state
- Camera no longer starts enabled when user disables it in prescreen
- When PTT mode is enabled, call starts muted so PTT works immediately
  without requiring a manual mute first
- CallControlState also updated to match the forced-off audio for PTT

EC 0.16.x ignores io.element.device_mute for initial state at startup,
so audio= and video= URL params are the only reliable way to set the
initial device state before the call begins.
2026-05-14 18:54:09 -04:00
root f3c2babd4b fix(call): show mic-denied error before joining instead of crashing
Check navigator.permissions for microphone state before the call starts.
If the user has blocked microphone access, disable the Join button and
show an inline message explaining how to fix it in browser settings.
Subscribes to permission change events so the UI updates if they grant
access without refreshing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:56:38 -04:00
root 7b5fbb7e3b fix: set config.json to matrix.lotusguild.org by default
Remove upstream Cinny homeserver list and set Lotus Guild homeserver
as the only default. Prevents deploying with wrong homeserver on fresh builds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:47:22 -04:00
root 109eac91f9 docs: replace upstream README with Lotus Chat changes diff
Lists all differences from upstream Cinny: branding, TDS dark/light themes,
chat backgrounds, call improvements (PTT/deafen/screenshare confirm/noise
suppression/camera default), new settings section, and technical changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:27:05 -04:00
root a23851d4a6 feat(call): PTT, deafen label, camera default off, screenshare confirm, noise suppression setting
- Push to Talk: keydown/keyup binds mic to configurable key (default Space)
  with visual PTT indicator and key-binding UI in Settings > General > Calls
- Camera always defaults OFF on join; cameraOnJoin setting for explicit opt-in
- Deafen button tooltip corrected to Deafen/Undeafen instead of Turn Off/On Sound
- Screenshare confirmation dialog before broadcasting to call participants
- Noise suppression toggle wired from settings through CallEmbed URL params
- CallControl.setMicrophone() public method for programmatic mic control
- Calls settings section added to General settings page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:14:04 -04:00
root 2dfdda5d8c Add TDS light mode: LotusTerminalLightTheme, light CSS vars, no CRT effects 2026-05-14 09:25:39 -04:00
root cfe52d623a Audit fixes: Lotus URLs, branding, editor toolbar setting, dynamic version 2026-05-13 23:03:14 -04:00
root 01781554a2 Fix welcome logo, real hex grid, Matrix boot messages, deeper TDS coverage
- WelcomePage: use official Lotus.png instead of generated SVG
- Hex Grid background: proper pointy-top hexagons via SVG data URI (was
  just triangles from linear-gradient trick)
- Boot sequence: Matrix-specific messages (TLS cert, E2EE Olm/Megolm,
  cross-signing, media proxy, /help hint)
- Terminal mode CSS: nav right border, header bottom glow, kbd TDS key
  style, abbr cyan underline, time amber color, img hover cyan outline,
  explicit body color anchor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:52:33 -04:00
root c6b1a9d75f Deepen TDS integration: full terminal CSS coverage + 3 new backgrounds
Terminal Mode:
- Text selection: orange highlight rgba(255,107,0,0.28)
- Links: cyan (#00D4FF) with orange hover glow (#FF6B00)
- Code/pre: TDS green (#00FF88) on terminal bg, left green border
- Strong/b → orange, em/i → cyan, mark → amber, del → red
- Blockquote: orange left border (matches chat reply quotes)
- HR: cyan border with dim glow
- Input/textarea/[contenteditable] focus: orange glow ring
- Tables: TDS headers (orange+uppercase), cyan borders, hover rows
- List markers: cyan ▸ for ul, orange for ol
- Boot box-drawing alignment fixed (51→52 ═)
- data-theme=\"dark\" set on html element when terminal active
- Updated description: correct TDS palette names
- ▶ Boot replay button in settings (visible when terminal on)

Chat backgrounds (+3):
- Tactical: LotusGuild TDS exact cyan dot-grid (28px)
- Circuit: green grid + node dots on dark terminal bg
- Hex Grid: isometric cyan hexagonal outlines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:44:34 -04:00
root 9ebce5b00c Implement LotusGuild TDS v1.2 terminal mode
- Rewrite lotus-terminal.css.ts: TDS-exact dot-grid bg, scanlines, vignette,
  glitch keyframes, orange caret, cyan scrollbars, all --lt-* CSS vars
- Fix lotusTerminalTheme in colors.css.ts: full TDS color palette
  (Orange primary, Cyan secondary, Green success, Amber warning, Red critical)
- Add lotus-boot.ts: matrix boot sequence at 65ms intervals, green phosphor glow
- Update ThemeManager.tsx: call runLotusBootSequence on terminal mode activate,
  UnAuthRouteThemeManager now supports lotusTerminal setting
- Update index.html: add JetBrains Mono + VT323 from Google Fonts
2026-05-13 22:36:48 -04:00
root 185eb160e7 Add Lotus Terminal Mode + fix all remaining Cinny branding
- New: Lotus Terminal Mode toggle in Appearance settings
  - Red phosphor color scheme (bg #0a0000, primary #ff3300, accent #00dd66)
  - Monospace font override (JetBrains Mono / Fira Code / Cascadia Code)
  - Retro CRT scanline overlay via CSS pseudo-element
  - Wired into ThemeManager with dedicated lotusTerminalBodyClass
- Branding: replace all user-visible Cinny references with Lotus Chat
  - WelcomePage, AuthLayout, SplashScreen, index.html meta tags
  - Device display names in login/register/token flows
  - System notification brand field
  - (Preserved internal Matrix protocol CinnySpaces event type)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:22:06 -04:00
root 2e12c742fb Add chevron, polka, triangles, plaid backgrounds 2026-05-13 22:01:16 -04:00
root f2bcd65a9b Backgrounds: theme-aware patterns and visual preview grid 2026-05-13 21:51:19 -04:00
root 77f0c0d4ca Redesign chat backgrounds: blueprint, carbon, stars, topographic, herringbone, crosshatch 2026-05-13 21:42:12 -04:00
root 13df48c658 Phase 2+3: Chat backgrounds and per-message profiles settings 2026-05-13 21:17:59 -04:00
root 9b68b4ae53 Replace generated SVG icons with official Lotus.png variants 2026-05-13 19:58:52 -04:00
root f914b59c07 Use official Lotus.png logo in About and Auth pages 2026-05-13 19:53:29 -04:00
root 1d086dda77 Phase 1: Lotus Chat branding — title, favicon, logo, meta tags, SVG icons, all icon sizes 2026-05-13 17:56:39 -04:00
Krishan 6a05ff5840 chore(release): v4.11.1 [skip ci] (#2765)
* chore(release): 4.11.0 [skip ci]

* chore(release): 4.11.1 [skip ci]
2026-03-11 23:07:37 +11:00
Krishan 919fe8381b chore(deps): Update slate deps to 0.123.0 (#2764)
Update slate deps to latest
2026-03-11 23:06:53 +11:00
Krishan b76ad3caaf chore(release): 4.11.0 [skip ci] 2026-03-11 04:03:48 +00:00
Ajay Bura 409d45857d chore(deps): update folds to 2.6.2 (#2762)
update folds to 2.6.2
2026-03-10 23:03:03 +11:00
Ajay Bura 0d1566977a fix: call ui imorovements (#2749)
* fix member button tooltip in call room header

* hide sticker button in room input based on chat window width

* render camera on off data instead of duplicate join messages

* hide duplicate call member changes instead of rendering as video status

* fix prescreen message spacing
2026-03-10 22:45:26 +11:00
Krishan 0cbfbab5ad chore: add semantic commits to renovate configuration (#2760)
* Add semantic commits to Renovate configuration

* Fix formatting issue in renovate.json
2026-03-10 14:57:20 +11:00
Krishan 37e0c2aaac chore(deps): continue action if login fails (#2758)
chore(action): continue action if login fails
2026-03-10 12:26:55 +11:00
Krishan 296249de32 chore: enable semantic check on PR title (#2447)
* Enable semantic check on PR title

* Update semantic pull request action version

* Update PR trigger types in workflow configuration

Removed 'reopened' and 'synchronize' types from pull request triggers.
2026-03-10 12:26:25 +11:00
Ajay Bura 4449e7c6e8 Show call support error and disable join button (#2748)
* allow user to end call if error when loading

* show call support missing error if livekit server is not provided

* prevent joining from nav item double click if no livekit support
2026-03-09 21:39:58 +11:00
Ajay Bura 2eb5a9a616 Fix crash with bad location uri (#2746)
fix crash with bad location uri
2026-03-09 18:17:15 +11:00
Ajay Bura d679e68501 Fix recent emoji does not persist (#2722)
Fix recent emoji are not getting saved

Refactor recent emoji retrieval to ensure structured cloning and proper type checking. The sdk was not updating account data because we are mutating the original and it compare and early return if found same.
2026-03-09 17:34:44 +11:00
Ajay Bura bc6caddcc8 Add own control buttons for element-call (#2744)
* add mutation observer hok

* add hook to read speaking member by observing iframe content

* display speaking member name in call status bar and improve layout

* fix shrining

* add joined call control bar

* remove chat toggle from room header

* change member speaking icon to mic

* fix joined call control appear in other

* show spinner on end call button

* hide call statusbar for mobile view when room is selected

* make call statusbar more mobile friendly

* fix call status bar item align
2026-03-09 08:34:48 +05:30
Ajay Bura 55e8306576 Display call member speaking status on bottom bar (#2742)
* add mutation observer hok

* add hook to read speaking member by observing iframe content

* display speaking member name in call status bar and improve layout

* fix shrining
2026-03-08 22:00:35 +11:00
Ajay Bura 7953ec80e5 Apply deafen state when call member changes (#2737)
* fix deafen not working

* apply deafen state when call member changes

* remove unnecessary condition
2026-03-08 14:22:11 +11:00
Ajay Bura c6bb4915bc Downgrade matrix-widget-api from 1.17.0 to 1.13.0 (#2736)
Some user was having Disconnection Error
2026-03-07 20:47:45 +11:00
Krishan b050cd01f9 feat: Add voice/video room support (#2680)
* Add users on the nav to showcase call activity and who is in the call

* add check to prevent DCing from the call you're currently in...

* Add avatar and username for the space (needs to be moved into RoomNavItem proper)

* Add background variant to buttons

* Update hook to keep method signature (accepting an array of Rooms instead) to support multiple room event tracking of the same event

* Add state listener so the call activity is real time updated on joins/leaves within the space

* Add RoomNavUser for displaying the user avatar + name in the nav for a visual of call activity and participants

* rename CallNavBottom to CallNavStatus

* Rename callnavbottom and fix linking implementation to actually be correct

* temp fix to allow the status to be cleared in some way

* re-add background to active call link button

* prepare to feed this to child elements for visibility handling

* loosely provide nav handling for testing refactoring

* Add CallView

* 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)

* update client layout to funnel outlet the iframes for the call container

* funnel through just iframe for now for testing sake

* Update room to use CallView

* Pass forward the backupIframeRef now

* remove unused params

* Add backupIframeRef so we can re-add the lobby screen for non-joined calls (for viewing their text channels)

* Remove unused imports and restructure to support being parent to clientlayout

* Re-add layout as we're no longer oddly passing outlet context

* swap to using ref provider context from to connect to persistentcallcontainer more directly

* Revert to original code as we've moved calling to be more inline with design

* Revert to original code as we've moved the outlet context passing out and made more direct use of the ref

* Fix unexpected visibility in non-room areas

* correctly provide visibility

* re-add mobile chat handling

* Improve call room view stability

* split into two refs

* add ViewedRoom usage

* Disable

* add roomViewId and related

* (broken) juggle the iframe states proper... still needs fixing

* Conditionals to manage the active iframe state better

* add navigateRoom to be in both conditions for the nav button

* Fix the view to correctly display the active iframe based on which is currently hosting the active call (juggling views)

* Testing the iframe juggling. Seems to work for the first and second joins... so likely on the right path with this

* add url as a param for widget url

* fix backup iframe visibility

* Much closer to the call state handling we want w/ hangups and joins

* Fix the position of the member drawer to its correct location

* Ensure drawer doesn't appear in call room

* Better handling of the isCallActive in the join handler

* Add ideal call room join behavior where text rooms to call room simply joins, but doesn't swap current view

* Fix mobile call room default behavior from auto-join to displaying lobby

* swap call status to be bound to call state and not active call id

* Remove clean room ID and add default handler for if no active call has existed yet, but user clicks on show chat

* 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)

* Remove superfluous comments and Date.now() that was causing loading... bug when widgetId desynced

* Remove Date.now() that was causing widgetId desync

* add listener clearing, camel case es lint rule exception, remove unneeded else statements

* Remove unused

* Add widgetId as a getWidgetUrl param

* Remove no longer needed files

* revert ternary expression change and add to dependency array

* add widgetId to correct pos in getWidgetUrl usage

* Remove CallActivation

* Move and rename RoomCallNavStatus

* update imports and dependency array

* Rename and clean up

* Moved CallProvider

* Fix spelling mistake

* Fix to use shorthand prop

* Remove unneeded logger.errors

* Fixes element-call embedded support (but it seems to run poorly)

* null the default url so that we fallback to the embedded version (would recommend hosting it until performance issue is determined)

* Fix vite build to place element-call correctly for embedded npm package support

* add vite preview as an npm script

* Move files to more correct location

* Add package-lock changes

* Set dep version to exact

* Fix path issue from moving file locations

* Sets initial states so the iframes don't cause the other to fail with the npm embedded package

* Revert navitem change

* Just check for state on both which should only occur at initial

* Fixes call initializing by default on mobile

* Provides correct behavior when call isn't active and no activeClientWidgetApi exists yet

* Corrects the state for the situations where both iframes are "active" (not necessarily visible)

* Reduce code reuse in handleJoin

* Seems to sort out the hangup status button bug the occurred after joining a call via lobby

* Re-add the default view current active room behavior

* Remove repetitive check

* Add storing widget for comparing with (since we already store room id and the clientWidgetApi anyway)

* 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)

* Prevent null rooms from ever rendering

* This seems to manage the hangup state with the status bar button well enough that black screens should never be encountered

* Remove viewed room setting here and pass the room to hang up (seems state doesn't update fast enough otherwise)

* Remove unused

* Properly declare new hangup method sig

* 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)

* 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)

* Re-add intended switching behavior

* More correct filter (viewedRoom can return false on that compare in some cases)

* Seems to shore up the remaining state issues with the status bar hangup

* Fix formatting

* In widget hang up button should be handled correct now

* Solves the CHCH sequence issue, CLJH remains

* Fixes CLJH, found CCH

* Solves CCH. Looks like CLCH left

* 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.

* Fix dependency array

* Technically corrects the hangup button in the widget, should be more precise though

* Bind the on messaging iframe for easier access in hangup/join handling

* 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.

* Fixes a bug where if you left a call then went to a lobby and joined it didn't update the actual activeCallRoomId

* Fixes complaints of null contentDocument in iframe

* Update to use new icons (thank you)

* Remove unneeded prop

* 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)

* Invert icons to show the state instead of the action they will perform (more visual clarity)

* Update src/app/features/room-nav/RoomCallNavStatus.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomCallNavStatus.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomCallNavStatus.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomCallNavStatus.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomCallNavStatus.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomCallNavStatus.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavItem.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room/RoomViewHeader.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavItem.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavItem.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavItem.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavItem.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavItem.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavItem.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavUser.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavUser.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavUser.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/pages/client/space/Space.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/call/CallView.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/call/CallView.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/call/CallView.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room/RoomView.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room/RoomView.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* adjust room header for calling

* Remove No Active Call text when not in a call

* update element-call version

* Update src/app/features/room/RoomViewHeader.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room/RoomViewHeader.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room/RoomViewHeader.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room/RoomViewHeader.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room/RoomViewHeader.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Revert most changes to Space.tsx

* Show call room even if category is collapsed

* changes to RoomNavItem, RoomNavUser and add useCallMembers

* Rename file, sprinkle in the magic one line for matrixRTCSession. and remove comment block

* swap userId to callMembership as a prop and add a nullchecked userId that uses the membership sender

* update references to use callMembership instead

* Simplify RoomNavUser

Discard future functionality since it probably won't exist in time for merging this PR

* 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.

* Update Room.tsx to accomodate restructuring of Room, RoomView and CallView

* Update RoomView.tsx to accomodate restructuring of Room, RoomView and CallView

* Update CallView.tsx to accomodate restructuring of Room, RoomView and CallView + suggested changes

* add call related permissions to room permissions

* 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

* update text spacing

* redo roomcallnavstatus ui, force user preferred mute/video states when first joining calls, update variable names and remove unnecessary logic

* set default mic state to enabled

* clean up ts/eslint errors

* remove debug logs

* format using prettier rules from project prettierrc

* fix: show call nav status while active call is ongoing

* fix: clean up call nav/call view console warnings

* fix: keep call media controls visible before joining

* fix: restore header icon button fill behavior

Fixes regression from b074d421b66eb4d8b600dfa55b967e6c4f783044.

* style: blend header and room input button styles in call nav

* fix page header background color on room view header

* 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

* feat: show connected/connecting call status

* fix: preserve navigation context when opening non-call rooms

* fix: reset room name state when room instance changes

* feat: Disable webcam by default using callIntent='audio'

* Add channel type selecor

* Add option for voice rooms, which for now sets the default selected
option in the creation modal

* Add proper support for room selection from the enu

* Move enums to `types.ts` and change icons selection to use
`getRoomIconSrc`

* fix: group duplicate conditions into one

* fix: typo

* refactor: rename kind/voice to access/type and simplify room creation

- rename CreateRoomVoice to CreateRoomType and modal voice state to type
- rename CreateRoomKind to CreateRoomAccess and KindSelector to AccessSelector
- propagate access/defaultAccess through create room and create space forms
- set voice room power levels via createRoom power_level_content_override

* refactor: unify join rule icon mapping and update call/space icons

- bump folds from 2.5.0 to 2.6.0
- replace separate room/space join-rule icon hooks with useJoinRuleIcons(roomType)
- route join-rule icons through getRoomIconSrc for consistent room type handling
- simplify getRoomIconSrc by removing the locked override path
- use VolumeHighGlobe for public call rooms and VolumeHighLock for private call rooms

* chore(deps): bump matrix-widget-api to 1.17 and remove react-sdk-module-api

* fix: adapt SmallWidget to matrix-widget-api 1.17.0 API

* fix: render call room chat only when chat panel is open

* fix(permissions): show call settings permissions only for call rooms

* refactor: remove redundant room-nav props/guards and minor naming cleanup

* fix: use PhoneDown icon for hang up action

* chore(hooks): remove unused useStateEvents hook

* fix(room): enable members drawer toggle in desktop call rooms

- show filled User icon when the drawer is open

* Revert "fix: adapt SmallWidget to matrix-widget-api 1.17.0 API"

This reverts commit a4c34eff8a.

* fix: semi-revert matrix-widget-api 1.17 bump and migrate to 1.13 API

* fix(call): wait for Element Call contentLoaded before widget handshake

- fixes not working on firefox

* fix missing imports

* improve create room type design and add beta badge for voice room

* add beta badge for voice room in space lobby

* fix create room modal title

* pass missing roomType param to roomicon component

* add roomtype

* Add deafen functionality (#2695)

* feat:(deafen functionality)

* feat:(reworked voice controls for deafen)

* ref:(use muted instead of volume for deafen)

* fix:(backpedal audio_enabled rename)

* ref:(renaming of deafened vars)

* add stack avatar component

* add call status bar - WIP

* remove call status from navigation drawer

* fix deprecated method use in use call members hook

* render new call status bar

* move call widget driver to plugins

* remove old status bar usage from navigation drawer

* add call session and joined hook

* remove unknown changes

* upgrade widget api

* add element call embed plugin

* remove unknown change

* add call embed atom

* add call embed hooks and context

* add call embed provider

* replace old call implementation

* stop joining other call on second click if already in a call

* refactor embed placement hook

* add merge border prop to sequence card

* add call preferences

* add prescreen to call view - WIP

* prevent joining new call if already in call

* make call layout adaptive

* render call chat as right panel

* show call members in prescreen

* render call join leave event in timeline

* remove unknown rewrite in docker-nginx file

* render call event without hidden event enable

---------

Co-authored-by: Gigiaj <gigiaboone@yahoo.com>
Co-authored-by: Jaggar <18173108+GigiaJ@users.noreply.github.com>
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
Co-authored-by: Gimle Larpes <gimlelarpes@gmail.com>
Co-authored-by: YoJames2019 <jamesclark1700@gmail.com>
Co-authored-by: YoJames2019 <yobiscuit0@gmail.com>
Co-authored-by: hazre <mail@haz.re>
Co-authored-by: haz <37149950+hazre@users.noreply.github.com>
Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Co-authored-by: James <49845975+YoJames2019@users.noreply.github.com>
Co-authored-by: James Reilly <jreilly1821@gmail.com>
Co-authored-by: Tymek <vonautymek@gmail.com>
Co-authored-by: Thedustbuster <92692948+Thedustbustr@users.noreply.github.com>
2026-03-07 18:03:32 +11:00
renovate[bot] 730670cf52 chore(deps): update actions/setup-node action to v6.3.0 (#2727)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-04 20:10:28 +11:00
Krishan c5c8703699 Update deploy PR workflow name and run name (#2728) 2026-03-04 20:07:16 +11:00
Krishan 2bd1570d6b Update deploy PR workflow name to include PR number (#2726) 2026-03-04 19:30:36 +11:00
dependabot[bot] 68b6a09697 Bump dawidd6/action-download-artifact from 15 to 16 (#2719)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 15 to 16.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](https://github.com/dawidd6/action-download-artifact/compare/fe9d59ce33ce92db8a6ac90b2c8be6b6d90417c8...2536c51d3d126276eb39f74d6bc9c72ac6ef30d3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 19:08:23 +11:00
dependabot[bot] 7b52c921d5 Bump actions/upload-artifact from 6.0.0 to 7.0.0 (#2718)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6.0.0 to 7.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6.0.0...v7.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 18:50:22 +11:00
Krishan bb8b9ab6da Add more docker related action checks (#2724)
* Pin all the action deps to SHA

* Add more docker related action checks

* Limit Docker build platforms to linux/amd64

Updated Docker build action to target only linux/amd64 platform.
2026-03-04 18:31:54 +11:00
Krishan 971f312b46 Pin all the action deps to SHA (#2725) 2026-03-04 12:31:36 +11:00
Tulir Asokan e0d5c63dc5 Fix invalid matrix.to event link generation (#2717) 2026-03-03 20:12:45 +11:00
renovate[bot] 85fcbd84fe chore(deps): update thollander/actions-comment-pull-request from 2.5.0 to 3.0.1 (#2698)
* chore(deps): update thollander/actions-comment-pull-request digest to e4a76dd

* pin to v3.0.1

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Krishan <33421343+kfiven@users.noreply.github.com>
2026-02-24 18:29:40 +11:00
dependabot[bot] 221bc04754 Bump dawidd6/action-download-artifact from 11 to 15 (#2694)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 11 to 15.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](https://github.com/dawidd6/action-download-artifact/compare/ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5...fe9d59ce33ce92db8a6ac90b2c8be6b6d90417c8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 17:54:57 +11:00
Krishan 6347640a35 Release v4.10.5 (#2692) 2026-02-23 23:05:01 +11:00
renovate[bot] f2d8ad0b6b chore(deps): update docker/metadata-action action to v5.10.0 (#2690)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 22:58:20 +11:00
renovate[bot] 739786d9ab chore(deps): update docker/setup-qemu-action action to v3.7.0 (#2691)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 22:57:44 +11:00
renovate[bot] f642809939 chore(deps): update docker/login-action action to v3.7.0 (#2689)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 22:56:52 +11:00
Krishan 02106a99b9 Release v4.10.4 (#2688) 2026-02-23 22:32:06 +11:00
dependabot[bot] df3a3ba789 Bump docker/setup-buildx-action from 3.11.1 to 3.12.0 (#2641)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.11.1 to 3.12.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.11.1...v3.12.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 18:18:01 +11:00
dependabot[bot] cd80d4c9e8 Bump docker/build-push-action from 6.18.0 to 6.19.2 (#2642)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.18.0 to 6.19.2.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.18.0...v6.19.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 18:13:17 +11:00
Krishan dab44edef2 Add prod-deploy.yml to Docker PR workflow paths (#2687) 2026-02-23 18:12:37 +11:00
Ajay Bura ed0ad61bc4 Verify SSO window message origin (#2686) 2026-02-23 18:08:25 +11:00
Ajay Bura b2cb717178 fix noreferrer typo in url preview link (#2685) 2026-02-23 17:56:14 +11:00
dependabot[bot] 7a9f6d2223 Bump linkifyjs and linkify-react from 4.1.3 to 4.3.2 (#2682)
* Bump linkifyjs from 4.1.3 to 4.3.2

Bumps [linkifyjs](https://github.com/nfrasser/linkifyjs/tree/HEAD/packages/linkifyjs) from 4.1.3 to 4.3.2.
- [Release notes](https://github.com/nfrasser/linkifyjs/releases)
- [Changelog](https://github.com/nfrasser/linkifyjs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/nfrasser/linkifyjs/commits/v4.3.2/packages/linkifyjs)

---
updated-dependencies:
- dependency-name: linkifyjs
  dependency-version: 4.3.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* update linkify react

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
2026-02-23 17:43:15 +11:00
Ajay Bura a9022184fc Set message power to moderator in space (#2684) 2026-02-23 16:57:39 +11:00
renovate[bot] 826b3c2997 chore(deps): update actions/setup-node action to v6 (#2681)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-22 18:57:23 +11:00
dependabot[bot] 2e6c5f7c04 Bump actions/upload-artifact from 4.6.2 to 6.0.0 (#2644)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 6.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.6.2...v6.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-22 18:26:54 +11:00
dependabot[bot] 2d6730de56 Bump actions/checkout from 4.2.0 to 6.0.2 (#2640)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.0 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.2.0...v6.0.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-22 18:26:08 +11:00
Krishan b6cc0e3077 Update node to v24.13.1 LTS (#2622)
* Update node to v24.13.1 LTS

* Fix dockerfile node version

* Simplify node and nginx version, bump nginx

* Fix casing
2026-02-22 18:15:23 +11:00
Ajay Bura 91c8731940 Add permission for managing emojis & stickers (#2678)
add permission for managing emojis & stickers
2026-02-22 15:48:23 +11:00
renovate[bot] 1f03891b25 fix(deps): update dependency folds to v2.6.1 (#2679)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-22 15:43:30 +11:00
Ajay Bura 9ff15b8b03 fix space lobby / search selected hook not working (#2675) 2026-02-22 15:14:04 +11:00
Ajay Bura 170f5cd473 Request session info from sw if missing (#2664)
* request session info from sw if missing

* fix async session request in fetch

* respond fetch synchronously and add early check for non media requests  (#2670)

* make sure we call respondWith synchronously

* simplify isMediaRequest in sw

* improve naming in sw

* get back baseUrl check into validMediaRequest

* pass original request into fetch in sw

* extract mediaPath util and performs checks properly

---------

Co-authored-by: mmmykhailo <35040944+mmmykhailo@users.noreply.github.com>
2026-02-21 17:51:27 +11:00
161 changed files with 6504 additions and 699 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
"basename": "/"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
"basename": "/"
}
}
+5 -1
View File
@@ -1,6 +1,10 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", ":dependencyDashboardApproval"], "extends": [
"config:recommended",
":dependencyDashboardApproval",
":semanticCommits"
],
"labels": ["Dependencies"], "labels": ["Dependencies"],
"packageRules": [ "packageRules": [
{ {
+6 -6
View File
@@ -12,12 +12,12 @@ jobs:
PR_NUMBER: ${{github.event.number}} PR_NUMBER: ${{github.event.number}}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup node - name: Setup node
uses: actions/setup-node@v4.4.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 20.12.2 node-version-file: ".node-version"
cache: 'npm' package-manager-cache: false
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Build app - name: Build app
@@ -25,7 +25,7 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=4096' NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build run: npm run build
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: preview name: preview
path: dist path: dist
@@ -33,7 +33,7 @@ jobs:
- name: Save pr number - name: Save pr number
run: echo ${PR_NUMBER} > ./pr.txt run: echo ${PR_NUMBER} > ./pr.txt
- name: Upload pr number - name: Upload pr number
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: pr name: pr
path: ./pr.txt path: ./pr.txt
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
- name: 'CLA Assistant' - name: 'CLA Assistant'
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release # Beta Release
uses: cla-assistant/github-action@v2.6.1 uses: cla-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret # the below token should have repo scope and must be manually added by you in the repository's secret
+8 -7
View File
@@ -1,4 +1,5 @@
name: Deploy PR to Netlify name: Deploy PR to Netlify
run-name: "Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})"
on: on:
workflow_run: workflow_run:
@@ -15,7 +16,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps: steps:
- name: Download pr number - name: Download pr number
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
with: with:
workflow: ${{ github.event.workflow.id }} workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }} run_id: ${{ github.event.workflow_run.id }}
@@ -24,7 +25,7 @@ jobs:
id: pr id: pr
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
- name: Download artifact - name: Download artifact
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
with: with:
workflow: ${{ github.event.workflow.id }} workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }} run_id: ${{ github.event.workflow_run.id }}
@@ -32,7 +33,7 @@ jobs:
path: dist path: dist
- name: Deploy to Netlify - name: Deploy to Netlify
id: netlify id: netlify
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
with: with:
publish-dir: dist publish-dir: dist
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}" deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
@@ -45,12 +46,12 @@ jobs:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
timeout-minutes: 1 timeout-minutes: 1
- name: Comment preview on PR - name: Comment preview on PR
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b #v3.0.1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
with: with:
pr_number: ${{ steps.pr.outputs.id }} pr-number: ${{ steps.pr.outputs.id }}
comment_tag: ${{ steps.pr.outputs.id }} comment-tag: ${{ steps.pr.outputs.id }}
message: | message: |
Preview: ${{ steps.netlify.outputs.deploy-url }} Preview: ${{ steps.netlify.outputs.deploy-url }}
⚠️ Exercise caution. Use test accounts. ⚠️ ⚠️ Exercise caution. Use test accounts. ⚠️
+47 -3
View File
@@ -5,15 +5,59 @@ on:
paths: paths:
- 'Dockerfile' - 'Dockerfile'
- '.github/workflows/docker-pr.yml' - '.github/workflows/docker-pr.yml'
- '.github/workflows/prod-deploy.yml'
jobs: jobs:
docker-build: docker-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Build Docker image
uses: docker/build-push-action@v6.18.0 - name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to Docker Hub #Do not update this action from a outside PR
if: github.event.pull_request.head.repo.fork == false
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
continue-on-error: true
- name: Login to the Github Container registry #Do not update this action from a outside PR
if: github.event.pull_request.head.repo.fork == false
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
- name: Extract metadata (tags, labels) for Docker, GHCR
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: |
ajbura/cinny
ghcr.io/${{ github.repository }}
- name: Build Docker image (no push)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with: with:
context: . context: .
platforms: linux/amd64
push: false push: false
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Show Docker images
run: docker images
+2 -2
View File
@@ -14,9 +14,9 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.2.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: NPM Lockfile Changes - name: NPM Lockfile Changes
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891 uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891 # v1.0.0
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
# Optional inputs, can be deleted safely if you are happy with default values. # Optional inputs, can be deleted safely if you are happy with default values.
+5 -5
View File
@@ -11,12 +11,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup node - name: Setup node
uses: actions/setup-node@v4.4.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 20.12.2 node-version-file: ".node-version"
cache: 'npm' package-manager-cache: false
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Build app - name: Build app
@@ -24,7 +24,7 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=4096' NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build run: npm run build
- name: Deploy to Netlify - name: Deploy to Netlify
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
with: with:
publish-dir: dist publish-dir: dist
deploy-message: 'Dev deploy ${{ github.sha }}' deploy-message: 'Dev deploy ${{ github.sha }}'
+15
View File
@@ -0,0 +1,15 @@
name: Check PR title
on:
pull_request_target:
types:
- opened
- edited
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+17 -17
View File
@@ -10,12 +10,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup node - name: Setup node
uses: actions/setup-node@v4.4.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 20.12.2 node-version-file: ".node-version"
cache: 'npm' package-manager-cache: false
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Build app - name: Build app
@@ -23,7 +23,7 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=4096' NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build run: npm run build
- name: Deploy to Netlify - name: Deploy to Netlify
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
with: with:
publish-dir: dist publish-dir: dist
deploy-message: 'Prod deploy ${{ github.ref_name }}' deploy-message: 'Prod deploy ${{ github.ref_name }}'
@@ -52,45 +52,45 @@ jobs:
gpg --export | xxd -p gpg --export | xxd -p
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
- name: Upload tagged release - name: Upload tagged release
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
with: with:
files: | files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz cinny-${{ steps.vars.outputs.tag }}.tar.gz
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
publish-image: publish-image:
name: Push Docker image to Docker Hub, ghcr name: Push Docker image to Docker Hub, GHCR
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write packages: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.6.0 uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.11.1 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to Docker Hub - name: Login to Docker Hub #Do not update this action from a outside PR
uses: docker/login-action@v3.6.0 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to the Container registry - name: Login to the Github Container registry #Do not update this action from a outside PR
uses: docker/login-action@v3.6.0 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker, GHCR
id: meta id: meta
uses: docker/metadata-action@v5.8.0 uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with: with:
images: | images: |
${{ secrets.DOCKER_USERNAME }}/cinny ${{ secrets.DOCKER_USERNAME }}/cinny
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6.18.0 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
+1
View File
@@ -0,0 +1 @@
24.13.1
+2 -2
View File
@@ -1,5 +1,5 @@
## Builder ## Builder
FROM node:20.12.2-alpine3.18 as builder FROM node:24.13.1-alpine AS builder
WORKDIR /src WORKDIR /src
@@ -11,7 +11,7 @@ RUN npm run build
## App ## App
FROM nginx:1.29.3-alpine FROM nginx:1.29.5-alpine
COPY --from=builder /src/dist /app COPY --from=builder /src/dist /app
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
+49 -98
View File
@@ -1,111 +1,62 @@
# Cinny # Lotus Chat
<p>
<a href="https://github.com/ajbura/cinny/releases">
<img alt="GitHub release downloads" src="https://img.shields.io/github/downloads/ajbura/cinny/total?logo=github&style=social"></a>
<a href="https://hub.docker.com/r/ajbura/cinny">
<img alt="DockerHub downloads" src="https://img.shields.io/docker/pulls/ajbura/cinny?logo=docker&style=social"></a>
<a href="https://fosstodon.org/@cinnyapp">
<img alt="Follow on Mastodon" src="https://img.shields.io/mastodon/follow/106845779685925461?domain=https%3A%2F%2Ffosstodon.org&logo=mastodon&style=social"></a>
<a href="https://twitter.com/intent/follow?screen_name=cinnyapp">
<img alt="Follow on Twitter" src="https://img.shields.io/twitter/follow/cinnyapp?logo=twitter&style=social"></a>
<a href="https://cinny.in/#sponsor">
<img alt="Sponsor Cinny" src="https://img.shields.io/opencollective/all/cinny?logo=opencollective&style=social"></a>
</p>
A Matrix client focusing primarily on simple, elegant and secure interface. The main goal is to have an instant messaging application that is easy on people and has a modern touch. A Matrix client for [Lotus Guild](https://lotusguild.org) — forked from [Cinny](https://github.com/cinnyapp/cinny) v4.11.1.
- [Roadmap](https://github.com/orgs/cinnyapp/projects/1)
- [Contributing](./CONTRIBUTING.md)
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380"> Deployed at [chat.lotusguild.org](https://chat.lotusguild.org).
## Getting started ---
The web app is available at [app.cinny.in](https://app.cinny.in/) and gets updated on each new release. The `dev` branch is continuously deployed at [dev.cinny.in](https://dev.cinny.in) but keep in mind that it could have things broken.
You can also download our desktop app from the [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop). ## Changes from upstream Cinny
## Self-hosting ### Branding & Identity
To host Cinny on your own, simply download the tarball from [GitHub releases](https://github.com/cinnyapp/cinny/releases/latest), and serve the files from `dist/` using your preferred webserver. Alternatively, you can just pull the docker image from [DockerHub](https://hub.docker.com/r/ajbura/cinny) or [GitHub Container Registry](https://github.com/cinnyapp/cinny/pkgs/container/cinny).
* The default homeservers and explore pages are defined in [`config.json`](config.json). - Package renamed to `lotus-chat`, description updated to "Lotus Chat — Matrix client for Lotus Guild"
- App title changed from "Cinny" to "Lotus Chat" throughout
- Favicon, PWA icons, and all icon sizes (57×57 → 180×180 Apple touch icons) replaced with Lotus.png variants
- Logo in About dialog and Auth page replaced with official Lotus.png
- Auth footer rewritten: shows dynamic version from `package.json`, links to lotusguild.org, chat.lotusguild.org, and matrix.lotusguild.org
- Welcome page tagline changed from "Yet another matrix client" to "A Matrix client for Lotus Guild"
- Encryption key export filename changed from `cinny-keys.txt` to `lotus-keys.txt`
- `manifest.json` updated with Lotus name, description, and branding colors
* You need to set up redirects to serve the assests. Example configurations; [netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), [caddy](contrib/caddy/caddyfile). ### LotusGuild Terminal Design System (TDS) v1.2
* If you have trouble configuring redirects you can [enable hash routing](config.json#L35) — the url in the browser will have a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/` instead of `app.cinny.in/home/`) but you won't have to configure your webserver.
* To deploy on subdirectory, you need to rebuild the app youself after updating the `base` path in [`build.config.ts`](build.config.ts). A full custom theme engine layered on top of Cinny's vanilla-extract theming:
* For example, if you want to deploy on `https://cinny.in/app`, then set `base: '/app'`.
<details><summary><b>PGP Public Key to verify tarball</b></summary> **Dark mode** (`LotusTerminalTheme`):
- CRT terminal aesthetic: scanline overlay, vignette, phosphor glow
- Palette: bg `#030508`, orange `#FF6B00`, cyan `#00D4FF`, green `#00FF88`, text `#c4d9ee`
- Monospace font stack, terminal-style scrollbars
- Custom hex-grid and circuit-board CSS background patterns
- Matrix-style boot messages on the welcome page
- CSS variables: `--lt-*` family covering colors, glow effects, borders, animations
``` **Light mode** (`LotusTerminalLightTheme`):
-----BEGIN PGP PUBLIC KEY BLOCK----- - Full light palette: bg `#edf0f5`, orange `#c44e00`, cyan `#0062b8`, green `#006d35`, text `#111827`
- No CRT effects (scanlines, vignette disabled)
- Light-mode scrollbars, adjusted code block colors, semantic color overrides
- Scoped to `html[data-theme="light"] body.lotusTerminalBodyClass`
- `ThemeManager.tsx` sets `data-theme` attribute based on active theme kind
mQGNBGJw/g0BDAC8qQeLqDMzYzfPyOmRlHVEoguVTo+eo1aVdQH2X7OELdjjBlyj **Chat Backgrounds** (18+ custom patterns, all TDS-aware):
6d6c1adv/uF2g83NNMoQY7GEeHjRnXE4m8kYSaarb840pxrYUagDc0dAbJOGaCBY - Blueprint grid, carbon fiber, starfield, topographic contours, herringbone, crosshatch
FKTo7U1Kvg0vdiaRuus0pvc1NVdXSxRNQbFXBSwduD+zn66TI3HfcEHNN62FG1cE - Chevron, polka dots, triangles, plaid
K1jWDwLAU0P3kKmj8+CAc3h9ZklPu0k/+t5bf/LJkvdBJAUzGZpehbPL5f3u3BZ0 - All patterns use CSS custom properties — adapt to both TDS dark and light themes
leZLIrR8uV7PiV5jKFahxlKR5KQHld8qQm+qVhYbUzpuMBGmh419I6UvTzxuRcvU - Settings toggle for showing per-message sender profiles
Frn9ttCEzV55Y+so4X2e4ZnB+5gOnNw+ecifGVdj/+UyWnqvqqDvLrEjjK890nLb
Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm
jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG
4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J
AdQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQSRri2MHidaaZv+
vvuUMwx6UK/M8wUCZqEDwAUJFvwIswAKCRCUMwx6UK/M877qC/4lxXOQIoWnLLkK
YiRCTkGsH6NdxgeYr6wpXT4xuQ45ZxCytwHpOGQmO/5up5961TxWW8D1frRIJHjj
AZGoRCL3EKEuY8nt3D99fpf3DvZrs1uoVAhiyn737hRlZAg+QsJheeGCmdSJ0hX5
Yud8SE+9zxLS1+CEjMrsUd/RGre/phme+wNXfaHfREAC9ewolgVChPIbMxG2f+vs
K8Xv52BFng7ta9fgsl1XuOjpuaSbQv6g+4ONk/lxKF0SmnhEGM3dmIYPONxW47Yf
atnIjRra/YhPTNwrNBGMmG4IFKaOsMbjW/eakjWTWOVKKJNBMoDdRcYYWIMCpLy8
AQUrMtQEsHSnqCwrw818S5A6rrhcfVGk36RGm0nOy6LS5g5jmqaYsvbCcBGY9B2c
SUAVNm17oo7TtEajk8hcSXoZod1t++pyjcVKEmSn3nFK7v5m3V+cPhNTxZMK459P
3x1Ucqj/kTqrxKw6s2Uknuk0ajmw0ljV+BQwgL6maguo9BKgCNW5AY0EYnD+DQEM
ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+
5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0
Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq
QPqfGDpowBwRkkOsGz/XVcesJ1Pzg4bKivTS9kZjZSyT9RRSY8As0sVUN57AwYul
s1+eh00n/tVpi2Jj9pCm7S0csSXvXj8v2OTdK1jt4YjpzR0/rwh4+/xlOjDjZEqH
vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c
LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx
H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx
NwARAQABiQG8BBgBCAAmAhsMFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmahA9IF
CRb8CMUACgkQlDMMelCvzPPQgQv/d5/z+fxgKqgfhQX+V49X4WgTVxZ/CzztDoJ1
XAq1dzTNEy8AFguXIo6eVXPSpMxec7ZreN3+UPQBnCf3eR5YxWNYOYKmk0G4E8D2
KGUJept7TSA42/8N2ov6tToXFg4CgzKZj0fYLwgutly7K8eiWmSU6ptaO8aEQBHB
gTGIOO3h6vJMGVycmoeRnHjv4wV84YWSVFSoJ7cY0he4Z9UznJBbE/KHZjrkXsPo
N+Gg5lDuOP5xjKzM5SogV9lhxBAhMWAg3URUF15yruZBiA8uV1FOK8sal/9C1G7V
M6ygA6uOZqXlZtcdA94RoSsW2pZ9eLVPsxz2B3Zko7tu11MpNP/wYmfGTI3KxZBj
n/eodvwjJSgHpGOFSmbNzvPJo3to5nNlp7wH1KxIMc6Uuu9hgfDfwkFZgV2bnFIa
Q6gyF548Ub48z7Dz83+WwLgbX19ve4oZx+dqSdczP6ILHRQomtrzrkkP2LU52oI5
mxFo+ioe/ABCufSmyqFye0psX3Sp
=WtqZ
-----END PGP PUBLIC KEY BLOCK-----
```
</details>
## Local development ### Voice / Video Call Improvements
> [!TIP]
> We recommend using a version manager as versions change very quickly. You will likely need to switch between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Iron LTS (v20).
Execute the following commands to start a development server: - **Element Call 0.19.3**: Upgraded from 0.16.3. Dist copied to `public/element-call/` by vite at build time.
```sh - **Camera default OFF**: Camera no longer persists across sessions via localStorage. Always starts disabled. Optional `cameraOnJoin` setting for explicit opt-in.
npm ci # Installs all dependencies - **Deafen button**: Tooltip corrected to "Deafen" / "Undeafen" (was "Turn Off Sound" / "Turn On Sound")
npm start # Serve a development version - **Screenshare confirmation**: A confirm dialog appears before screenshare is broadcast to call participants
``` - **Auto-revert spotlight on screenshare**: When someone starts screensharing, EC normally forces all participants into spotlight view. Patched in `CallControl.ts` `onControlMutation()` — detects the screenshare button going `primary` and clicks `gridButton` after 600ms to revert to grid layout. Participants choose to watch screenshare manually.
- **Push to Talk (PTT)**:
To build the app: - Configurable keybind (default: Space) via Settings > General > Calls
```sh - Mic activates on keydown, deactivates on keyup; mic muted on tab blur/focus to prevent stuck-on mic
npm run build # Compiles the app into the dist/ directory - Visual indicator: plain folds `Chip` by default; when LotusGuild TDS is active: orange `PTT — Hold SPACE` / green `● LIVE` in JetBrains Mono
``` - Listens on both main window and EC iframe `contentWindow` for reliable key capture
- Implemented via `CallControl.setMicrophone()` public method on the widget bridge
### Running with Docker - **Noise suppression toggle**: Settings > General > Calls — passes `noiseSuppression` URL parameter to the embedded Element Call widget
This repository includes a Dockerfile, which builds the application from source and serves it with Nginx on port 80. To - **DM calls**: Phone button in DM room header starts a 1-on-1 call via `useCallStart(true)`. `Room.tsx` switches to CallView layout when the DM has an active call embed. Standard voice rooms were already supported; this extends call support to all direct messages.
use this locally, you can build the container like so: - **Picture-in-picture (PiP)**: When navigating away from a call room while in an active call, the call embed shrinks to a 280x158px floating window in the bottom-right corner. The PiP window is **draggable** — drag it anywhere on screen to move it out of the way. Clicking (without dragging) navigates back to the call room. Drag vs click distinguished by a 5px movement threshold; touch drag supported. Imperative style overrides on `callEmbedRef.current` via `useEffect` — a wrapper div cannot be used because `useCallEmbedPlacementSync` writes `top/left/width/height` directly onto that element.
```
docker build -t cinny:latest .
```
You can then run the container you've built with a command similar to this:
```
docker run -p 8080:80 cinny:latest
```
This will forward your `localhost` port 8080 to the container's port 80. You can visit the app in your browser by navigating to `http://localhost:8080`.
+6 -26
View File
@@ -1,35 +1,15 @@
{ {
"defaultHomeserver": 1, "defaultHomeserver": 0,
"homeserverList": [ "homeserverList": [
"converser.eu", "matrix.lotusguild.org"
"matrix.org",
"mozilla.org",
"unredacted.org",
"xmr.se"
], ],
"allowCustomHomeservers": true, "allowCustomHomeservers": false,
"featuredCommunities": { "featuredCommunities": {
"openAsDefault": false, "openAsDefault": false,
"spaces": [ "spaces": [],
"#cinny-space:matrix.org", "rooms": [],
"#community:matrix.org", "servers": []
"#space:unredacted.org",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
],
"rooms": [
"#cinny:matrix.org",
"#freesoftware:matrix.org",
"#pcapdroid:matrix.org",
"#gentoo:matrix.org",
"#PrivSec.dev:arcticfoxes.net",
"#disroot:aria-net.org"
],
"servers": [ "matrix.org", "mozilla.org", "unredacted.org" ]
}, },
"hashRouter": { "hashRouter": {
"enabled": false, "enabled": false,
"basename": "/" "basename": "/"
+14 -11
View File
@@ -4,33 +4,36 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>Cinny</title> <title>Lotus Chat</title>
<meta name="name" content="Cinny" /> <meta name="name" content="Lotus Chat" />
<meta name="author" content="Ajay Bura" /> <meta name="author" content="Lotus Guild" />
<meta <meta
name="description" name="description"
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source." content="Lotus Chat — the Lotus Guild Matrix client. Secure, fast, and built for our community."
/> />
<meta <meta
name="keywords" name="keywords"
content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element" content="lotus chat, lotus guild, matrix, matrix client"
/> />
<meta property="og:title" content="Cinny" /> <meta property="og:title" content="Lotus Chat" />
<meta property="og:url" content="https://cinny.in" /> <meta property="og:url" content="https://chat.lotusguild.org" />
<meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png" /> <meta property="og:image" content="https://chat.lotusguild.org/public/res/android/android-chrome-192x192.png" />
<meta <meta
property="og:description" property="og:description"
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source." content="Lotus Chat — the Lotus Guild Matrix client. Secure, fast, and built for our community."
/> />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" /> <link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="application-name" content="Cinny" /> <meta name="application-name" content="Lotus Chat" />
<meta name="apple-mobile-web-app-title" content="Cinny" /> <meta name="apple-mobile-web-app-title" content="Lotus Chat" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+48 -58
View File
@@ -1,12 +1,12 @@
{ {
"name": "cinny", "name": "cinny",
"version": "4.10.3", "version": "4.11.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cinny", "name": "cinny",
"version": "4.10.3", "version": "4.11.1",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
@@ -32,7 +32,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.5.0", "folds": "2.6.2",
"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",
@@ -41,9 +41,10 @@
"immer": "9.0.16", "immer": "9.0.16",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.6.0", "jotai": "2.6.0",
"linkify-react": "4.1.3", "linkify-react": "4.3.2",
"linkifyjs": "4.1.3", "linkifyjs": "4.3.2",
"matrix-js-sdk": "38.2.0", "matrix-js-sdk": "38.2.0",
"matrix-widget-api": "1.13.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",
@@ -58,13 +59,14 @@
"react-range": "1.8.14", "react-range": "1.8.14",
"react-router-dom": "6.30.3", "react-router-dom": "6.30.3",
"sanitize-html": "2.12.1", "sanitize-html": "2.12.1",
"slate": "0.112.0", "slate": "0.123.0",
"slate-dom": "0.112.2", "slate-dom": "0.123.0",
"slate-history": "0.110.3", "slate-history": "0.113.1",
"slate-react": "0.112.1", "slate-react": "0.123.0",
"ua-parser-js": "1.0.35" "ua-parser-js": "1.0.35"
}, },
"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 +1651,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",
@@ -7158,9 +7166,9 @@
} }
}, },
"node_modules/folds": { "node_modules/folds": {
"version": "2.5.0", "version": "2.6.2",
"resolved": "https://registry.npmjs.org/folds/-/folds-2.5.0.tgz", "resolved": "https://registry.npmjs.org/folds/-/folds-2.6.2.tgz",
"integrity": "sha512-UJhvXAQ1XnZ9w10KJwSW+frvzzWE/zcF0dH3fDVCD70RFHAxwEi0UkkVS8CaZGxZF2Wvt3qTJyTS5LW3LwwUAw==", "integrity": "sha512-1HemxxSnBm8/U5kq1pDQrFkpltWgQN90DmWCZWkZb7D2pe8BhOJSwIRLjk9WxHcw6nn69oz2XNYIXtSw0LvX1w==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peerDependencies": { "peerDependencies": {
"@vanilla-extract/css": "1.9.2", "@vanilla-extract/css": "1.9.2",
@@ -8492,18 +8500,20 @@
} }
}, },
"node_modules/linkify-react": { "node_modules/linkify-react": {
"version": "4.1.3", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.1.3.tgz", "resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.3.2.tgz",
"integrity": "sha512-rhI3zM/fxn5BfRPHfi4r9N7zgac4vOIxub1wHIWXLA5ENTMs+BGaIaFO1D1PhmxgwhIKmJz3H7uCP0Dg5JwSlA==", "integrity": "sha512-mi744h1hf+WDsr+paJgSBBgYNLMWNSHyM9V9LVUo03RidNGdw1VpI7Twnt+K3pEh3nIzB4xiiAgZxpd61ItKpQ==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"linkifyjs": "^4.0.0", "linkifyjs": "^4.0.0",
"react": ">= 15.0.0" "react": ">= 15.0.0"
} }
}, },
"node_modules/linkifyjs": { "node_modules/linkifyjs": {
"version": "4.1.3", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz", "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
"integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg==" "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
}, },
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
@@ -8664,9 +8674,9 @@
} }
}, },
"node_modules/matrix-widget-api": { "node_modules/matrix-widget-api": {
"version": "1.13.1", "version": "1.13.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.13.0.tgz",
"integrity": "sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==", "integrity": "sha512-+LrvwkR1izL4h2euX8PDrvG/3PZZDEd6As+lmnR3jAVwbFJtU5iTnwmZGnCca9ddngCvXvAHkcpJBEPyPTZneQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@types/events": "^3.0.0", "@types/events": "^3.0.0",
@@ -10281,20 +10291,15 @@
} }
}, },
"node_modules/slate": { "node_modules/slate": {
"version": "0.112.0", "version": "0.123.0",
"resolved": "https://registry.npmjs.org/slate/-/slate-0.112.0.tgz", "resolved": "https://registry.npmjs.org/slate/-/slate-0.123.0.tgz",
"integrity": "sha512-PRnfFgDA3tSop4OH47zu4M1R4Uuhm/AmASu29Qp7sGghVFb713kPBKEnSf1op7Lx/nCHkRlCa3ThfHtCBy+5Yw==", "integrity": "sha512-Oon3HR/QzJQBjuOUJT1jGGlp8Ff7t3Bkr/rJ2lDqxNT4H+cBnXpEVQ/si6hn1ZCHhD2xY/2N91PQoH/rD7kxTg==",
"license": "MIT", "license": "MIT"
"dependencies": {
"immer": "^10.0.3",
"is-plain-object": "^5.0.0",
"tiny-warning": "^1.0.3"
}
}, },
"node_modules/slate-dom": { "node_modules/slate-dom": {
"version": "0.112.2", "version": "0.123.0",
"resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.112.2.tgz", "resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.123.0.tgz",
"integrity": "sha512-cozITMlpcBxrov854reM6+TooiHiqpfM/nZPrnjpN1wSiDsAQmYbWUyftC+jlwcpFj80vywfDHzlG6hXIc5h6A==", "integrity": "sha512-OUinp4tvSrAlt64JL9y20Xin08jgnnj1gJmIuPdGvU5MELKXRNZh17a7EKKNOS6OZPAE8Dk9NI1MAIS/Qz0YBw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@juggle/resize-observer": "^3.4.0", "@juggle/resize-observer": "^3.4.0",
@@ -10306,13 +10311,13 @@
"tiny-invariant": "1.3.1" "tiny-invariant": "1.3.1"
}, },
"peerDependencies": { "peerDependencies": {
"slate": ">=0.99.0" "slate": ">=0.121.0"
} }
}, },
"node_modules/slate-history": { "node_modules/slate-history": {
"version": "0.110.3", "version": "0.113.1",
"resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.110.3.tgz", "resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.113.1.tgz",
"integrity": "sha512-sgdff4Usdflmw5ZUbhDkxFwCBQ2qlDKMMkF93w66KdV48vHOgN2BmLrf+2H8SdX8PYIpP/cTB0w8qWC2GwhDVA==", "integrity": "sha512-J9NSJ+UG2GxoW0lw5mloaKcN0JI0x2IA5M5FxyGiInpn+QEutxT1WK7S/JneZCMFJBoHs1uu7S7e6pxQjubHmQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-plain-object": "^5.0.0" "is-plain-object": "^5.0.0"
@@ -10322,15 +10327,14 @@
} }
}, },
"node_modules/slate-react": { "node_modules/slate-react": {
"version": "0.112.1", "version": "0.123.0",
"resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.112.1.tgz", "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.123.0.tgz",
"integrity": "sha512-V9b+waxPweXqAkSQmKQ1afG4Me6nVQACPpxQtHPIX02N7MXa5f5WilYv+bKt7vKKw+IZC2F0Gjzhv5BekVgP/A==", "integrity": "sha512-nQwXL1FEacrY9ZFmatRhoBnsySNUX2x6qB77V3oNHd7wWxBJWuzz4GMrBXcVoRE8Gac7Angf8xaNGzb6zcPlHg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@juggle/resize-observer": "^3.4.0", "@juggle/resize-observer": "^3.4.0",
"direction": "^1.0.4", "direction": "^1.0.4",
"is-hotkey": "^0.2.0", "is-hotkey": "^0.2.0",
"is-plain-object": "^5.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"scroll-into-view-if-needed": "^3.1.0", "scroll-into-view-if-needed": "^3.1.0",
"tiny-invariant": "1.3.1" "tiny-invariant": "1.3.1"
@@ -10338,18 +10342,8 @@
"peerDependencies": { "peerDependencies": {
"react": ">=18.2.0", "react": ">=18.2.0",
"react-dom": ">=18.2.0", "react-dom": ">=18.2.0",
"slate": ">=0.99.0", "slate": ">=0.121.0",
"slate-dom": ">=0.110.2" "slate-dom": ">=0.119.1"
}
},
"node_modules/slate/node_modules/immer": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
} }
}, },
"node_modules/smob": { "node_modules/smob": {
@@ -10719,11 +10713,6 @@
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.10", "version": "0.2.10",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz",
@@ -10907,6 +10896,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"
+13 -10
View File
@@ -1,7 +1,7 @@
{ {
"name": "cinny", "name": "lotus-chat",
"version": "4.10.3", "version": "4.11.1",
"description": "Yet another matrix client", "description": "Lotus Chat \u2014 Matrix client for Lotus Guild",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
"engines": { "engines": {
@@ -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 .",
@@ -43,7 +44,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.5.0", "folds": "2.6.2",
"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",
@@ -52,9 +53,10 @@
"immer": "9.0.16", "immer": "9.0.16",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.6.0", "jotai": "2.6.0",
"linkify-react": "4.1.3", "linkify-react": "4.3.2",
"linkifyjs": "4.1.3", "linkifyjs": "4.3.2",
"matrix-js-sdk": "38.2.0", "matrix-js-sdk": "38.2.0",
"matrix-widget-api": "1.13.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",
@@ -69,13 +71,14 @@
"react-range": "1.8.14", "react-range": "1.8.14",
"react-router-dom": "6.30.3", "react-router-dom": "6.30.3",
"sanitize-html": "2.12.1", "sanitize-html": "2.12.1",
"slate": "0.112.0", "slate": "0.123.0",
"slate-dom": "0.112.2", "slate-dom": "0.123.0",
"slate-history": "0.110.3", "slate-history": "0.113.1",
"slate-react": "0.112.1", "slate-react": "0.123.0",
"ua-parser-js": "1.0.35" "ua-parser-js": "1.0.35"
}, },
"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",
+17
View File
@@ -0,0 +1,17 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"matrix.lotusguild.org"
],
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
"basename": "/"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 631 B

+24 -5
View File
@@ -1,14 +1,14 @@
{ {
"name": "Cinny", "name": "Lotus Chat",
"short_name": "Cinny", "short_name": "Lotus Chat",
"description": "Yet another matrix client", "description": "Lotus Chat \u2014 the Lotus Guild Matrix client",
"dir": "auto", "dir": "auto",
"lang": "en-US", "lang": "en-US",
"display": "standalone", "display": "standalone",
"orientation": "portrait", "orientation": "portrait",
"start_url": "./", "start_url": "./",
"background_color": "#fff", "background_color": "#0a0a0a",
"theme_color": "#fff", "theme_color": "#980000",
"icons": [ "icons": [
{ {
"src": "./public/android/android-chrome-36x36.png", "src": "./public/android/android-chrome-36x36.png",
@@ -55,5 +55,24 @@
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
} }
],
"categories": [
"social",
"communication",
"productivity"
],
"shortcuts": [
{
"name": "New Message",
"short_name": "DM",
"description": "Open a new direct message",
"url": "/",
"icons": [
{
"src": "res/android/android-chrome-96x96.png",
"sizes": "96x96"
}
]
}
] ]
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+13 -12
View File
@@ -1,13 +1,14 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2707_1961)"> <g fill="#980000" fill-opacity="0.88">
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/>
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/>
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#45B83B"/> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/>
</g> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/>
<defs> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/>
<clipPath id="clip0_2707_1961"> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/>
<rect width="18" height="18" fill="white"/> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/>
</clipPath> </g>
</defs> <circle cx="9" cy="9" r="2.2" fill="#cc2000"/>
<circle cx="14.5" cy="14.5" r="3" fill="#45B83B"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 841 B

+13 -12
View File
@@ -1,13 +1,14 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2707_2015)"> <g fill="#980000" fill-opacity="0.88">
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/>
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/>
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#989898"/> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/>
</g> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/>
<defs> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/>
<clipPath id="clip0_2707_2015"> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/>
<rect width="18" height="18" fill="white"/> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/>
</clipPath> </g>
</defs> <circle cx="9" cy="9" r="2.2" fill="#cc2000"/>
<circle cx="14.5" cy="14.5" r="3" fill="#989898"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 841 B

+12 -18
View File
@@ -1,19 +1,13 @@
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In --> <svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg version="1.1" <g fill="#980000" fill-opacity="0.88">
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/" <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(0,9,9)"/>
x="0px" y="0px" width="18px" height="18px" viewBox="0 0 18 18" enable-background="new 0 0 18 18" xml:space="preserve"> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(45,9,9)"/>
<defs> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(90,9,9)"/>
</defs> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(135,9,9)"/>
<g> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(180,9,9)"/>
<g> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(225,9,9)"/>
<circle fill="#FFFFFF" cx="9" cy="9" r="8.5"/> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(270,9,9)"/>
</g> <path d="M9,9 Q6.4,4.8 9,1.2 Q11.6,4.8 9,9Z" transform="rotate(315,9,9)"/>
<g> </g>
<path d="M9,0C4,0,0,4,0,9c0,5,4,9,9,9c5,0,9-4,9-9C18,4,14,0,9,0z M1.2,10.8l3.5-2.3c0-0.1,0-0.2,0-0.3c0-1.8,1.3-3.2,3.1-3.4 <circle cx="9" cy="9" r="2.2" fill="#cc2000"/>
c0.1,0,0.2,0,0.4,0c1.2,0,2.3,0.6,2.9,1.6c0.3-0.1,0.6-0.1,0.9-0.1c0.4,0,0.8,0,1.2,0.1c0.7,0.2,1.4,0.5,2,0.9
C14.6,7.1,14,7,13.3,7c-1.2,0-2.2,0.4-2.9,1.4c-0.7,0.9-1.1,2-1.1,3.2c0,1.5-0.4,2.9-1.3,4.2c-0.3,0.4-0.5,0.7-0.8,1
C4.2,16.1,1.9,13.8,1.2,10.8z"/>
<circle cx="9.5" cy="6.4" r="0.5"/>
</g>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 871 B

After

Width:  |  Height:  |  Size: 788 B

+374
View File
@@ -0,0 +1,374 @@
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { config } from 'folds';
import {
CallEmbedContextProvider,
CallEmbedRefContextProvider,
useCallHangupEvent,
useCallJoined,
useCallThemeSync,
useCallMemberSoundSync,
} from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { CallEmbed } from '../plugins/call';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { useRoomNavigate } from '../hooks/useRoomNavigate';
const PIP_MIN_W = 200;
const PIP_MIN_H = 112; // keeps roughly 16:9 at minimum
function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
useCallMemberSoundSync(embed);
useCallThemeSync(embed);
useCallHangupEvent(
embed,
useCallback(() => {
setCallEmbed(undefined);
}, [setCallEmbed])
);
return null;
}
type Corner = 'se' | 'sw' | 'ne' | 'nw';
/** Normalise the element to top/left positioning so resize math is uniform. */
function normaliseToTopLeft(el: HTMLDivElement) {
const rect = el.getBoundingClientRect();
el.style.top = `${rect.top}px`;
el.style.left = `${rect.left}px`;
el.style.right = 'auto';
el.style.bottom = 'auto';
el.style.width = `${rect.width}px`;
el.style.height = `${rect.height}px`;
}
type CallEmbedProviderProps = {
children?: ReactNode;
};
export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const callEmbed = useAtomValue(callEmbedAtom);
const callEmbedRef = useRef<HTMLDivElement>(null);
const joined = useCallJoined(callEmbed);
const selectedRoom = useSelectedRoom();
const chat = useAtomValue(callChatAtom);
const screenSize = useScreenSizeContext();
const { navigateRoom } = useRoomNavigate();
const chatOnlyView = chat && screenSize !== ScreenSize.Desktop;
const inCallRoom = callEmbed && selectedRoom === callEmbed.roomId;
const callActive = callEmbed && joined;
const callVisible = inCallRoom && callActive && !chatOnlyView;
const pipMode = callActive && !inCallRoom;
const pipDragRef = useRef<{
startX: number;
startY: number;
origLeft: number;
origTop: number;
dragged: boolean;
} | null>(null);
// useCallEmbedPlacementSync writes top/left/width/height directly on this element.
// Override those in PiP mode; clear them when returning so it can take back control.
useEffect(() => {
const el = callEmbedRef.current;
if (!el) return;
if (pipMode) {
el.style.top = 'auto';
el.style.left = 'auto';
el.style.bottom = '72px';
el.style.right = '16px';
el.style.width = '280px';
el.style.height = '158px';
el.style.borderRadius = '12px';
el.style.overflow = 'hidden';
el.style.zIndex = '99';
el.style.boxShadow = '0 8px 32px rgba(0,0,0,0.55)';
el.style.border = '1px solid rgba(255,255,255,0.1)';
el.style.visibility = 'visible';
} else {
el.style.top = '';
el.style.left = '';
el.style.bottom = '';
el.style.right = '';
el.style.width = '';
el.style.height = '';
el.style.borderRadius = '';
el.style.overflow = '';
el.style.zIndex = '';
el.style.boxShadow = '';
el.style.border = '';
el.style.visibility = callVisible ? '' : 'hidden';
}
}, [pipMode, callVisible]);
// ── Drag to move ────────────────────────────────────────────────────────────
const handlePipMouseDown = (e: React.MouseEvent) => {
const el = callEmbedRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
pipDragRef.current = {
startX: e.clientX,
startY: e.clientY,
origLeft: rect.left,
origTop: rect.top,
dragged: false,
};
const onMouseMove = (ev: MouseEvent) => {
if (!pipDragRef.current || !el) return;
const dx = ev.clientX - pipDragRef.current.startX;
const dy = ev.clientY - pipDragRef.current.startY;
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
pipDragRef.current.dragged = true;
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
}
if (pipDragRef.current.dragged) {
const newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx));
const newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy));
el.style.left = `${newLeft}px`;
el.style.top = `${newTop}px`;
el.style.right = 'auto';
el.style.bottom = 'auto';
}
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
setTimeout(() => {
if (pipDragRef.current) pipDragRef.current.dragged = false;
}, 0);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
const handlePipTouchStart = (e: React.TouchEvent) => {
const el = callEmbedRef.current;
if (!el || e.touches.length !== 1) return;
const touch = e.touches[0];
const rect = el.getBoundingClientRect();
pipDragRef.current = {
startX: touch.clientX,
startY: touch.clientY,
origLeft: rect.left,
origTop: rect.top,
dragged: false,
};
const onTouchMove = (ev: TouchEvent) => {
if (!pipDragRef.current || !el || ev.touches.length !== 1) return;
ev.preventDefault();
const t = ev.touches[0];
const dx = t.clientX - pipDragRef.current.startX;
const dy = t.clientY - pipDragRef.current.startY;
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
pipDragRef.current.dragged = true;
}
if (pipDragRef.current.dragged) {
const newLeft = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx));
const newTop = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy));
el.style.left = `${newLeft}px`;
el.style.top = `${newTop}px`;
el.style.right = 'auto';
el.style.bottom = 'auto';
}
};
const onTouchEnd = () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
setTimeout(() => {
if (pipDragRef.current) pipDragRef.current.dragged = false;
}, 0);
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
};
const handlePipClick = (roomId: string) => {
if (pipDragRef.current?.dragged) return;
navigateRoom(roomId);
};
// ── Resize from corner handles ───────────────────────────────────────────────
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
e.stopPropagation();
e.preventDefault();
const el = callEmbedRef.current;
if (!el) return;
normaliseToTopLeft(el);
const startX = e.clientX;
const startY = e.clientY;
const startW = el.offsetWidth;
const startH = el.offsetHeight;
const startL = parseFloat(el.style.left);
const startT = parseFloat(el.style.top);
document.body.style.cursor = `${corner}-resize`;
document.body.style.userSelect = 'none';
const onMouseMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
let w = startW;
let h = startH;
let l = startL;
let t = startT;
if (corner === 'se') { w = startW + dx; h = startH + dy; }
if (corner === 'sw') { w = startW - dx; h = startH + dy; l = startL + startW - Math.max(PIP_MIN_W, w); }
if (corner === 'ne') { w = startW + dx; h = startH - dy; t = startT + startH - Math.max(PIP_MIN_H, h); }
if (corner === 'nw') { w = startW - dx; h = startH - dy; l = startL + startW - Math.max(PIP_MIN_W, w); t = startT + startH - Math.max(PIP_MIN_H, h); }
w = Math.max(PIP_MIN_W, Math.min(w, window.innerWidth));
h = Math.max(PIP_MIN_H, Math.min(h, window.innerHeight));
l = Math.max(0, Math.min(l, window.innerWidth - PIP_MIN_W));
t = Math.max(0, Math.min(t, window.innerHeight - PIP_MIN_H));
el.style.width = `${w}px`;
el.style.height = `${h}px`;
el.style.left = `${l}px`;
el.style.top = `${t}px`;
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
// Corner handle style helper
const cornerHandle = (corner: Corner): React.CSSProperties => {
const s = corner.includes('s');
const e = corner.includes('e');
return {
position: 'absolute',
width: '18px',
height: '18px',
[s ? 'bottom' : 'top']: 0,
[e ? 'right' : 'left']: 0,
cursor: `${corner}-resize`,
zIndex: 2,
};
};
// Grip dot pattern rendered inside each handle
const gripDots = (corner: Corner) => {
const s = corner.includes('s');
const e = corner.includes('e');
// 3 dots arranged in an L toward the active corner
const dots: React.CSSProperties[] = [];
const r = 2; // dot radius px
const gap = 5;
const base = 3;
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (i + j < 2) continue; // only the 3 corner-most dots
dots.push({
position: 'absolute',
width: r * 2,
height: r * 2,
borderRadius: '50%',
background: 'rgba(255,255,255,0.45)',
[s ? 'bottom' : 'top']: base + i * gap,
[e ? 'right' : 'left']: base + j * gap,
});
}
}
return dots;
};
return (
<CallEmbedContextProvider value={callEmbed}>
{callEmbed && <CallUtils embed={callEmbed} />}
<CallEmbedRefContextProvider value={callEmbedRef}>{children}</CallEmbedRefContextProvider>
<div
data-call-embed-container
ref={callEmbedRef}
style={{
visibility: callVisible ? undefined : 'hidden',
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '50%',
}}
>
{pipMode && callEmbed && (
<>
{/* Drag-to-move overlay (zIndex 1, sits over the iframe) */}
<div
role="button"
tabIndex={0}
aria-label="Return to call"
onMouseDown={handlePipMouseDown}
onTouchStart={handlePipTouchStart}
onClick={() => handlePipClick(callEmbed.roomId)}
onKeyDown={(e) => e.key === 'Enter' && handlePipClick(callEmbed.roomId)}
style={{
position: 'absolute',
inset: 0,
zIndex: 1,
background: 'transparent',
cursor: 'grab',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: '6px',
}}
>
<div
style={{
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(4px)',
borderRadius: '6px',
padding: '3px 8px',
color: '#fff',
fontSize: '11px',
fontWeight: 600,
pointerEvents: 'none',
}}
>
Return to call
</div>
</div>
{/* Corner resize handles (zIndex 2, above the drag overlay) */}
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => (
<div
key={corner}
style={cornerHandle(corner)}
onMouseDown={(e) => handleResizeMouseDown(e, corner)}
onClick={(e) => e.stopPropagation()}
>
{gripDots(corner).map((style, i) => (
<div key={i} style={style} />
))}
</div>
))}
</>
)}
</div>
</CallEmbedContextProvider>
);
}
+10 -20
View File
@@ -16,34 +16,24 @@ import {
import { JoinRule } from 'matrix-js-sdk'; import { JoinRule } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard'; import { stopPropagation } from '../utils/keyboard';
import { getRoomIconSrc } from '../utils/room';
export type ExtraJoinRules = 'knock_restricted'; export type ExtraJoinRules = 'knock_restricted';
export type ExtendedJoinRules = JoinRule | ExtraJoinRules; export type ExtendedJoinRules = JoinRule | ExtraJoinRules;
type JoinRuleIcons = Record<ExtendedJoinRules, IconSrc>; type JoinRuleIcons = Record<ExtendedJoinRules, IconSrc>;
export const useRoomJoinRuleIcon = (): JoinRuleIcons =>
export const useJoinRuleIcons = (roomType?: string): JoinRuleIcons =>
useMemo( useMemo(
() => ({ () => ({
[JoinRule.Invite]: Icons.HashLock, [JoinRule.Invite]: getRoomIconSrc(Icons, roomType, JoinRule.Invite),
[JoinRule.Knock]: Icons.HashLock, [JoinRule.Knock]: getRoomIconSrc(Icons, roomType, JoinRule.Knock),
knock_restricted: Icons.Hash, knock_restricted: getRoomIconSrc(Icons, roomType, JoinRule.Restricted),
[JoinRule.Restricted]: Icons.Hash, [JoinRule.Restricted]: getRoomIconSrc(Icons, roomType, JoinRule.Restricted),
[JoinRule.Public]: Icons.HashGlobe, [JoinRule.Public]: getRoomIconSrc(Icons, roomType, JoinRule.Public),
[JoinRule.Private]: Icons.HashLock, [JoinRule.Private]: getRoomIconSrc(Icons, roomType, JoinRule.Private),
}), }),
[] [roomType]
);
export const useSpaceJoinRuleIcon = (): JoinRuleIcons =>
useMemo(
() => ({
[JoinRule.Invite]: Icons.SpaceLock,
[JoinRule.Knock]: Icons.SpaceLock,
knock_restricted: Icons.Space,
[JoinRule.Restricted]: Icons.Space,
[JoinRule.Public]: Icons.SpaceGlobe,
[JoinRule.Private]: Icons.SpaceLock,
}),
[]
); );
type JoinRuleLabels = Record<ExtendedJoinRules, string>; type JoinRuleLabels = Record<ExtendedJoinRules, string>;
@@ -2,43 +2,39 @@ import React from 'react';
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds'; import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
import { SequenceCard } from '../sequence-card'; import { SequenceCard } from '../sequence-card';
import { SettingTile } from '../setting-tile'; import { SettingTile } from '../setting-tile';
import { CreateRoomAccess } from './types';
export enum CreateRoomKind { type CreateRoomAccessSelectorProps = {
Private = 'private', value?: CreateRoomAccess;
Restricted = 'restricted', onSelect: (value: CreateRoomAccess) => void;
Public = 'public',
}
type CreateRoomKindSelectorProps = {
value?: CreateRoomKind;
onSelect: (value: CreateRoomKind) => void;
canRestrict?: boolean; canRestrict?: boolean;
disabled?: boolean; disabled?: boolean;
getIcon: (kind: CreateRoomKind) => IconSrc; getIcon: (access: CreateRoomAccess) => IconSrc;
}; };
export function CreateRoomKindSelector({ export function CreateRoomAccessSelector({
value, value,
onSelect, onSelect,
canRestrict, canRestrict,
disabled, disabled,
getIcon, getIcon,
}: CreateRoomKindSelectorProps) { }: CreateRoomAccessSelectorProps) {
return ( return (
<Box shrink="No" direction="Column" gap="100"> <Box shrink="No" direction="Column" gap="100">
{canRestrict && ( {canRestrict && (
<SequenceCard <SequenceCard
style={{ padding: config.space.S300 }} style={{ padding: config.space.S300 }}
variant={value === CreateRoomKind.Restricted ? 'Primary' : 'SurfaceVariant'} variant={value === CreateRoomAccess.Restricted ? 'Primary' : 'SurfaceVariant'}
direction="Column" direction="Column"
gap="100" gap="100"
as="button" as="button"
type="button" type="button"
aria-pressed={value === CreateRoomKind.Restricted} aria-pressed={value === CreateRoomAccess.Restricted}
onClick={() => onSelect(CreateRoomKind.Restricted)} onClick={() => onSelect(CreateRoomAccess.Restricted)}
disabled={disabled} disabled={disabled}
> >
<SettingTile <SettingTile
before={<Icon size="400" src={getIcon(CreateRoomKind.Restricted)} />} before={<Icon size="400" src={getIcon(CreateRoomAccess.Restricted)} />}
after={value === CreateRoomKind.Restricted && <Icon src={Icons.Check} />} after={value === CreateRoomAccess.Restricted && <Icon src={Icons.Check} />}
> >
<Text size="H6">Restricted</Text> <Text size="H6">Restricted</Text>
<Text size="T300" priority="300"> <Text size="T300" priority="300">
@@ -49,18 +45,18 @@ export function CreateRoomKindSelector({
)} )}
<SequenceCard <SequenceCard
style={{ padding: config.space.S300 }} style={{ padding: config.space.S300 }}
variant={value === CreateRoomKind.Private ? 'Primary' : 'SurfaceVariant'} variant={value === CreateRoomAccess.Private ? 'Primary' : 'SurfaceVariant'}
direction="Column" direction="Column"
gap="100" gap="100"
as="button" as="button"
type="button" type="button"
aria-pressed={value === CreateRoomKind.Private} aria-pressed={value === CreateRoomAccess.Private}
onClick={() => onSelect(CreateRoomKind.Private)} onClick={() => onSelect(CreateRoomAccess.Private)}
disabled={disabled} disabled={disabled}
> >
<SettingTile <SettingTile
before={<Icon size="400" src={getIcon(CreateRoomKind.Private)} />} before={<Icon size="400" src={getIcon(CreateRoomAccess.Private)} />}
after={value === CreateRoomKind.Private && <Icon src={Icons.Check} />} after={value === CreateRoomAccess.Private && <Icon src={Icons.Check} />}
> >
<Text size="H6">Private</Text> <Text size="H6">Private</Text>
<Text size="T300" priority="300"> <Text size="T300" priority="300">
@@ -70,18 +66,18 @@ export function CreateRoomKindSelector({
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard
style={{ padding: config.space.S300 }} style={{ padding: config.space.S300 }}
variant={value === CreateRoomKind.Public ? 'Primary' : 'SurfaceVariant'} variant={value === CreateRoomAccess.Public ? 'Primary' : 'SurfaceVariant'}
direction="Column" direction="Column"
gap="100" gap="100"
as="button" as="button"
type="button" type="button"
aria-pressed={value === CreateRoomKind.Public} aria-pressed={value === CreateRoomAccess.Public}
onClick={() => onSelect(CreateRoomKind.Public)} onClick={() => onSelect(CreateRoomAccess.Public)}
disabled={disabled} disabled={disabled}
> >
<SettingTile <SettingTile
before={<Icon size="400" src={getIcon(CreateRoomKind.Public)} />} before={<Icon size="400" src={getIcon(CreateRoomAccess.Public)} />}
after={value === CreateRoomKind.Public && <Icon src={Icons.Check} />} after={value === CreateRoomAccess.Public && <Icon src={Icons.Check} />}
> >
<Text size="H6">Public</Text> <Text size="H6">Public</Text>
<Text size="T300" priority="300"> <Text size="T300" priority="300">
@@ -0,0 +1,75 @@
import React from 'react';
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
import { SequenceCard } from '../sequence-card';
import { SettingTile } from '../setting-tile';
import { CreateRoomType } from './types';
import { BetaNoticeBadge } from '../BetaNoticeBadge';
type CreateRoomTypeSelectorProps = {
value?: CreateRoomType;
onSelect: (value: CreateRoomType) => void;
disabled?: boolean;
getIcon: (type: CreateRoomType) => IconSrc;
};
export function CreateRoomTypeSelector({
value,
onSelect,
disabled,
getIcon,
}: CreateRoomTypeSelectorProps) {
return (
<Box shrink="No" direction="Column" gap="100">
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomType.TextRoom ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomType.TextRoom}
onClick={() => onSelect(CreateRoomType.TextRoom)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomType.TextRoom)} />}
after={value === CreateRoomType.TextRoom && <Icon src={Icons.Check} />}
>
<Box gap="200" alignItems="Baseline">
<Text size="H6" style={{ flexShrink: 0 }}>
Chat Room
</Text>
<Text size="T300" priority="300" truncate>
- Messages, photos, and videos.
</Text>
</Box>
</SettingTile>
</SequenceCard>
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomType.VoiceRoom ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomType.VoiceRoom}
onClick={() => onSelect(CreateRoomType.VoiceRoom)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomType.VoiceRoom)} />}
after={value === CreateRoomType.VoiceRoom && <Icon src={Icons.Check} />}
>
<Box gap="200" alignItems="Baseline">
<Text size="H6" style={{ flexShrink: 0 }}>
Voice Room
</Text>
<Text size="T300" priority="300" truncate>
- Live audio and video conversations.
</Text>
<BetaNoticeBadge />
</Box>
</SettingTile>
</SequenceCard>
</Box>
);
}
+2 -1
View File
@@ -1,5 +1,6 @@
export * from './CreateRoomKindSelector'; export * from './CreateRoomAccessSelector';
export * from './CreateRoomAliasInput'; export * from './CreateRoomAliasInput';
export * from './RoomVersionSelector'; export * from './RoomVersionSelector';
export * from './utils'; export * from './utils';
export * from './AdditionalCreatorInput'; export * from './AdditionalCreatorInput';
export * from './types';
+10
View File
@@ -0,0 +1,10 @@
export enum CreateRoomType {
TextRoom = 'text',
VoiceRoom = 'voice',
}
export enum CreateRoomAccess {
Private = 'private',
Restricted = 'restricted',
Public = 'public',
}
+32 -6
View File
@@ -7,10 +7,10 @@ import {
Room, Room,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
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 { CreateRoomAccess } from './types';
export const createRoomCreationContent = ( export const createRoomCreationContent = (
type: RoomType | undefined, type: RoomType | undefined,
@@ -32,7 +32,7 @@ export const createRoomCreationContent = (
}; };
export const createRoomJoinRulesState = ( export const createRoomJoinRulesState = (
kind: CreateRoomKind, access: CreateRoomAccess,
parent: Room | undefined, parent: Room | undefined,
knock: boolean knock: boolean
) => { ) => {
@@ -40,13 +40,13 @@ export const createRoomJoinRulesState = (
join_rule: knock ? JoinRule.Knock : JoinRule.Invite, join_rule: knock ? JoinRule.Knock : JoinRule.Invite,
}; };
if (kind === CreateRoomKind.Public) { if (access === CreateRoomAccess.Public) {
content = { content = {
join_rule: JoinRule.Public, join_rule: JoinRule.Public,
}; };
} }
if (kind === CreateRoomKind.Restricted && parent) { if (access === CreateRoomAccess.Restricted && parent) {
content = { content = {
join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted, join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted,
allow: [ allow: [
@@ -74,6 +74,10 @@ export const createRoomParentState = (parent: Room) => ({
}, },
}); });
const createSpacePowerLevelsOverride = () => ({
events_default: 50,
});
export const createRoomEncryptionState = () => ({ export const createRoomEncryptionState = () => ({
type: 'm.room.encryption', type: 'm.room.encryption',
state_key: '', state_key: '',
@@ -82,11 +86,23 @@ export const createRoomEncryptionState = () => ({
}, },
}); });
export const createRoomCallState = () => ({
type: 'org.matrix.msc3401.call',
state_key: '',
content: {},
});
export const createVoiceRoomPowerLevelsOverride = () => ({
events: {
[StateEvent.GroupCallMemberPrefix]: 0,
},
});
export type CreateRoomData = { export type CreateRoomData = {
version: string; version: string;
type?: RoomType; type?: RoomType;
parent?: Room; parent?: Room;
kind: CreateRoomKind; access: CreateRoomAccess;
name: string; name: string;
topic?: string; topic?: string;
aliasLocalPart?: string; aliasLocalPart?: string;
@@ -106,7 +122,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
initialState.push(createRoomParentState(data.parent)); initialState.push(createRoomParentState(data.parent));
} }
initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock)); if (data.type === RoomType.Call) {
initialState.push(createRoomCallState());
}
initialState.push(createRoomJoinRulesState(data.access, data.parent, data.knock));
const options: ICreateRoomOpts = { const options: ICreateRoomOpts = {
room_version: data.version, room_version: data.version,
@@ -118,9 +138,15 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
data.allowFederation, data.allowFederation,
data.additionalCreators data.additionalCreators
), ),
power_level_content_override:
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
initial_state: initialState, initial_state: initialState,
}; };
if (data.type === RoomType.Space) {
options.power_level_content_override = createSpacePowerLevelsOverride();
}
const result = await mx.createRoom(options); const result = await mx.createRoom(options);
if (data.parent) { if (data.parent) {
@@ -169,12 +169,13 @@ export function RoomMentionAutocomplete({
<RoomIcon <RoomIcon
size="50" size="50"
joinRule={room.getJoinRule() ?? JoinRule.Restricted} joinRule={room.getJoinRule() ?? JoinRule.Restricted}
roomType={room.getType()}
filled filled
/> />
)} )}
/> />
) : ( ) : (
<RoomIcon size="100" joinRule={room.getJoinRule()} space={room.isSpaceRoom()} /> <RoomIcon size="100" joinRule={room.getJoinRule()} roomType={room.getType()} />
)} )}
</Avatar> </Avatar>
} }
@@ -389,6 +389,8 @@ export function MLocation({ content }: MLocationProps) {
const geoUri = content.geo_uri; const geoUri = content.geo_uri;
if (typeof geoUri !== 'string') return <BrokenContent />; if (typeof geoUri !== 'string') return <BrokenContent />;
const location = parseGeoUri(geoUri); const location = parseGeoUri(geoUri);
if (!location) return <BrokenContent />;
return ( return (
<Box direction="Column" alignItems="Start" gap="100"> <Box direction="Column" alignItems="Start" gap="100">
<Text size="T400">{geoUri}</Text> <Text size="T400">{geoUri}</Text>
@@ -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,9 @@ 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;
} }
>(({ joinRule, space, ...props }, ref) => ( >(({ joinRule, roomType, ...props }, ref) => (
<Icon <Icon src={getRoomIconSrc(Icons, roomType, joinRule)} {...props} ref={ref} />
src={joinRuleToIconSrc(Icons, joinRule, space || false) ?? Icons.Hash}
{...props}
ref={ref}
/>
)); ));
@@ -17,6 +17,7 @@ export const SequenceCard = as<
firstChild, firstChild,
lastChild, lastChild,
outlined, outlined,
mergeBorder,
...props ...props
}, },
ref ref
@@ -24,7 +25,7 @@ export const SequenceCard = as<
<Box <Box
as={AsSequenceCard} as={AsSequenceCard}
className={classNames( className={classNames(
css.SequenceCard({ radii, outlined }), css.SequenceCard({ radii, outlined, mergeBorder }),
ContainerColor({ variant }), ContainerColor({ variant }),
className className
)} )}
+11 -2
View File
@@ -11,7 +11,7 @@ export const SequenceCard = recipe({
}, },
borderStyle: 'solid', borderStyle: 'solid',
borderWidth: outlinedWidth, borderWidth: outlinedWidth,
borderBottomWidth: 0,
selectors: { selectors: {
'&:first-child, :not(&) + &': { '&:first-child, :not(&) + &': {
borderTopLeftRadius: [radii], borderTopLeftRadius: [radii],
@@ -20,7 +20,6 @@ export const SequenceCard = recipe({
'&:last-child, &:not(:has(+&))': { '&:last-child, &:not(:has(+&))': {
borderBottomLeftRadius: [radii], borderBottomLeftRadius: [radii],
borderBottomRightRadius: [radii], borderBottomRightRadius: [radii],
borderBottomWidth: outlinedWidth,
}, },
[`&[data-first-child="true"]`]: { [`&[data-first-child="true"]`]: {
borderTopLeftRadius: [radii], borderTopLeftRadius: [radii],
@@ -74,6 +73,16 @@ export const SequenceCard = recipe({
}, },
}, },
}, },
mergeBorder: {
true: {
borderBottomWidth: 0,
selectors: {
'&:last-child, &:not(:has(+&))': {
borderBottomWidth: outlinedWidth,
},
},
},
},
}, },
defaultVariants: { defaultVariants: {
radii: '400', radii: '400',
@@ -21,7 +21,7 @@ export function SplashScreen({ children }: SplashScreenProps) {
justifyContent="Center" justifyContent="Center"
> >
<Text size="H2" align="Center"> <Text size="H2" align="Center">
Cinny Lotus Chat
</Text> </Text>
</Box> </Box>
</Box> </Box>
@@ -0,0 +1,18 @@
import React from 'react';
import { as, Avatar } from 'folds';
import classNames from 'classnames';
import * as css from './styles.css';
type StackedAvatarProps = {
radii?: '0' | '300' | '400' | '500' | 'Pill' | 'Inherit' | undefined;
};
export const StackedAvatar = as<'span', css.StackedAvatarVariants & StackedAvatarProps>(
({ size, variant, className, ...props }, ref) => (
<Avatar
size={size}
className={classNames(css.StackedAvatar({ size, variant }), className)}
{...props}
ref={ref}
/>
)
);
@@ -0,0 +1 @@
export * from './StackedAvatar';
@@ -0,0 +1,59 @@
import { ComplexStyleRule } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { color, config, ContainerColor, toRem } from 'folds';
const getVariant = (variant: ContainerColor): ComplexStyleRule => ({
outlineColor: color[variant].Container,
});
export const StackedAvatar = recipe({
base: {
backgroundColor: color.Surface.Container,
outlineStyle: 'solid',
selectors: {
'&:first-child': {
marginLeft: 0,
},
'button&': {
cursor: 'pointer',
},
},
},
variants: {
size: {
'200': {
marginLeft: toRem(-6),
outlineWidth: config.borderWidth.B300,
},
'300': {
marginLeft: toRem(-9),
outlineWidth: config.borderWidth.B400,
},
'400': {
marginLeft: toRem(-10.5),
outlineWidth: config.borderWidth.B500,
},
'500': {
marginLeft: toRem(-13),
outlineWidth: config.borderWidth.B600,
},
},
variant: {
Background: getVariant('Background'),
Surface: getVariant('Surface'),
SurfaceVariant: getVariant('SurfaceVariant'),
Primary: getVariant('Primary'),
Secondary: getVariant('Secondary'),
Success: getVariant('Success'),
Warning: getVariant('Warning'),
Critical: getVariant('Critical'),
},
},
defaultVariants: {
size: '400',
variant: 'Surface',
},
});
export type StackedAvatarVariants = RecipeVariants<typeof StackedAvatar>;
+7 -2
View File
@@ -26,7 +26,12 @@ export function SSOStage({
useEffect(() => { useEffect(() => {
const handleMessage = (evt: MessageEvent) => { const handleMessage = (evt: MessageEvent) => {
if (ssoWindow && evt.data === 'authDone' && evt.source === ssoWindow) { if (
evt.origin === new URL(ssoRedirectURL).origin &&
ssoWindow &&
evt.data === 'authDone' &&
evt.source === ssoWindow
) {
ssoWindow.close(); ssoWindow.close();
setSSOWindow(undefined); setSSOWindow(undefined);
handleSubmit(); handleSubmit();
@@ -37,7 +42,7 @@ export function SSOStage({
return () => { return () => {
window.removeEventListener('message', handleMessage); window.removeEventListener('message', handleMessage);
}; };
}, [ssoWindow, handleSubmit]); }, [ssoWindow, handleSubmit, ssoRedirectURL]);
return ( return (
<Dialog> <Dialog>
@@ -30,7 +30,15 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
if (previewStatus.status === AsyncStatus.Error) return null; if (previewStatus.status === AsyncStatus.Error) return null;
const renderContent = (prev: IPreviewUrlResponse) => { const renderContent = (prev: IPreviewUrlResponse) => {
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication, 256, 256, 'scale', false); const imgUrl = mxcUrlToHttp(
mx,
prev['og:image'] || '',
useAuthentication,
256,
256,
'scale',
false
);
return ( return (
<> <>
@@ -42,7 +50,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
as="a" as="a"
href={url} href={url}
target="_blank" target="_blank"
rel="no-referrer" rel="noreferrer"
size="T200" size="T200"
priority="300" priority="300"
> >
@@ -323,7 +323,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
)} )}
/> />
) : ( ) : (
<RoomIcon size="100" joinRule={room.getJoinRule()} /> <RoomIcon size="100" joinRule={room.getJoinRule()} roomType={room.getType()} />
)} )}
</Avatar> </Avatar>
} }
+10
View File
@@ -20,6 +20,16 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
'm.identity_server'?: { 'm.identity_server'?: {
base_url: string; base_url: string;
}; };
'org.matrix.msc2965.authentication'?: {
account?: string;
issuer?: string;
};
'org.matrix.msc4143.rtc_foci'?: [
{
livekit_service_url: string;
type: 'livekit';
}
];
}; };
export const autoDiscovery = async ( export const autoDiscovery = async (
@@ -291,7 +291,11 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
)} )}
/> />
) : ( ) : (
<RoomIcon size="200" joinRule={room.getJoinRule()} /> <RoomIcon
size="200"
joinRule={room.getJoinRule()}
roomType={room.getType()}
/>
)} )}
</Avatar> </Avatar>
} }
@@ -0,0 +1,225 @@
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useCallback } from 'react';
import { useSetAtom } from 'jotai';
import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed';
type MicrophoneButtonProps = {
enabled: boolean;
onToggle: () => Promise<unknown>;
disabled?: boolean;
};
function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
return (
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Turn Off Microphone' : 'Turn On Microphone'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'}
fill="Soft"
radii="300"
size="300"
onClick={() => onToggle()}
outlined
disabled={disabled}
>
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
</IconButton>
)}
</TooltipProvider>
);
}
type SoundButtonProps = {
enabled: boolean;
onToggle: () => void;
disabled?: boolean;
};
function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
return (
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Turn Off Sound' : 'Turn On Sound'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'}
fill="Soft"
radii="300"
size="300"
onClick={() => onToggle()}
outlined
disabled={disabled}
>
<Icon
size="100"
src={enabled ? Icons.Headphone : Icons.HeadphoneMute}
filled={!enabled}
/>
</IconButton>
)}
</TooltipProvider>
);
}
type VideoButtonProps = {
enabled: boolean;
onToggle: () => Promise<unknown>;
disabled?: boolean;
};
function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
return (
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Stop Camera' : 'Start Camera'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'}
fill="Soft"
radii="300"
size="300"
onClick={() => onToggle()}
outlined
disabled={disabled}
>
<Icon
size="100"
src={enabled ? Icons.VideoCamera : Icons.VideoCameraMute}
filled={enabled}
/>
</IconButton>
)}
</TooltipProvider>
);
}
type ScreenShareButtonProps = {
enabled: boolean;
onToggle: () => void;
disabled?: boolean;
};
function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) {
return (
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Stop Screenshare' : 'Start Screenshare'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'}
fill="Soft"
radii="300"
size="300"
onClick={onToggle}
outlined
disabled={disabled}
>
<Icon size="100" src={Icons.ScreenShare} filled={enabled} />
</IconButton>
)}
</TooltipProvider>
);
}
export function CallControl({
callEmbed,
compact,
callJoined,
}: {
callEmbed: CallEmbed;
compact: boolean;
callJoined: boolean;
}) {
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const setCallEmbed = useSetAtom(callEmbedAtom);
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed])
);
const exiting =
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
const handleHangup = () => {
if (!callJoined) {
setCallEmbed(undefined);
return;
}
hangup();
};
return (
<Box shrink="No" alignItems="Center" gap="300">
<Box alignItems="Inherit" gap="200">
<MicrophoneButton
enabled={microphone}
onToggle={() => callEmbed.control.toggleMicrophone()}
disabled={!callJoined}
/>
<SoundButton
enabled={sound}
onToggle={() => callEmbed.control.toggleSound()}
disabled={!callJoined}
/>
{!compact && <StatusDivider />}
<VideoButton
enabled={video}
onToggle={() => callEmbed.control.toggleVideo()}
disabled={!callJoined}
/>
{!compact && (
<ScreenShareButton
enabled={screenshare}
onToggle={() => callEmbed.control.toggleScreenshare()}
disabled={!callJoined}
/>
)}
</Box>
<StatusDivider />
<Chip
variant="Critical"
radii="Pill"
fill="Soft"
before={
exiting ? (
<Spinner variant="Critical" fill="Soft" size="50" />
) : (
<Icon size="50" src={Icons.PhoneDown} filled />
)
}
disabled={exiting}
outlined
onClick={handleHangup}
>
{!compact && (
<Text as="span" size="L400">
End
</Text>
)}
</Chip>
</Box>
);
}
@@ -0,0 +1,55 @@
import React from 'react';
import { Room } from 'matrix-js-sdk';
import { Chip, Text } from 'folds';
import { useAtomValue } from 'jotai';
import { useRoomName } from '../../hooks/useRoomMeta';
import { RoomIcon } from '../../components/room-avatar';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { getAllParents, guessPerfectParent } from '../../utils/room';
import { useOrphanSpaces } from '../../state/hooks/roomList';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { mDirectAtom } from '../../state/mDirectList';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
type CallRoomNameProps = {
room: Room;
};
export function CallRoomName({ room }: CallRoomNameProps) {
const mx = useMatrixClient();
const name = useRoomName(room);
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
const mDirects = useAtomValue(mDirectAtom);
const dm = mDirects.has(room.roomId);
const allRoomsSet = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allRoomsSet);
const allParents = getAllParents(roomToParents, room.roomId);
const orphanParents = allParents && orphanSpaces.filter((o) => allParents.has(o));
const perfectOrphanParent = orphanParents && guessPerfectParent(mx, room.roomId, orphanParents);
const { navigateRoom } = useRoomNavigate();
return (
<Chip
variant="Background"
radii="Pill"
before={
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} filled />
}
onClick={() => navigateRoom(room.roomId)}
>
<Text size="L400" truncate>
{name}
{!dm && perfectOrphanParent && (
<Text as="span" size="T200" priority="300">
{' •'} <b>{getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent}</b>
</Text>
)}
</Text>
</Chip>
);
}
@@ -0,0 +1,81 @@
import React from 'react';
import { Box, Spinner } from 'folds';
import classNames from 'classnames';
import { LiveChip } from './LiveChip';
import * as css from './styles.css';
import { CallRoomName } from './CallRoomName';
import { CallControl } from './CallControl';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
import { MemberGlance } from './MemberGlance';
import { StatusDivider } from './components';
import { CallEmbed } from '../../plugins/call/CallEmbed';
import { useCallJoined } from '../../hooks/useCallEmbed';
import { useCallSpeakers } from '../../hooks/useCallSpeakers';
import { MemberSpeaking } from './MemberSpeaking';
type CallStatusProps = {
callEmbed: CallEmbed;
};
export function CallStatus({ callEmbed }: CallStatusProps) {
const { room } = callEmbed;
const callSession = useCallSession(room);
const callMembers = useCallMembers(room, callSession);
const screenSize = useScreenSize();
const callJoined = useCallJoined(callEmbed);
const speakers = useCallSpeakers(callEmbed);
const compact = screenSize === ScreenSize.Mobile;
const memberVisible = callJoined && callMembers.length > 0;
return (
<Box
className={classNames(css.CallStatus, ContainerColor({ variant: 'Background' }))}
shrink="No"
gap="400"
alignItems={compact ? undefined : 'Center'}
direction={compact ? 'Column' : 'Row'}
>
<Box grow="Yes" alignItems="Center" gap="200">
{memberVisible ? (
<Box shrink="No">
<LiveChip count={callMembers.length} room={room} members={callMembers} />
</Box>
) : (
<Spinner variant="Secondary" size="200" />
)}
<Box grow="Yes" alignItems="Center" gap="Inherit">
{!compact && (
<>
<CallRoomName room={room} />
{speakers.size > 0 && (
<>
<StatusDivider />
<span data-spacing-node />
<MemberSpeaking room={room} speakers={speakers} />
</>
)}
</>
)}
</Box>
{memberVisible && (
<Box shrink="No">
<MemberGlance room={room} members={callMembers} speakers={speakers} />
</Box>
)}
</Box>
{memberVisible && !compact && <StatusDivider />}
<Box shrink="No" alignItems="Center" gap="Inherit">
{compact && (
<Box grow="Yes">
<CallRoomName room={room} />
</Box>
)}
<CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
</Box>
</Box>
);
}
+137
View File
@@ -0,0 +1,137 @@
import React, { MouseEventHandler, useState } from 'react';
import {
Avatar,
Badge,
Box,
Chip,
config,
Icon,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Scroll,
Text,
toRem,
} from 'folds';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import FocusTrap from 'focus-trap-react';
import { Room } from 'matrix-js-sdk';
import * as css from './styles.css';
import { stopPropagation } from '../../utils/keyboard';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../../components/user-avatar';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { getMouseEventCords } from '../../utils/dom';
type LiveChipProps = {
room: Room;
members: CallMembership[];
count: number;
};
export function LiveChip({ count, room, members }: LiveChipProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const openUserProfile = useOpenUserRoomProfile();
const [cords, setCords] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
return (
<PopOut
anchor={cords}
position="Top"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu
style={{
maxHeight: '75vh',
maxWidth: toRem(300),
display: 'flex',
}}
>
<Box grow="Yes">
<Scroll size="0" hideTrack visibility="Hover">
<Box direction="Column" style={{ padding: config.space.S100 }}>
{members.map((callMember) => {
const userId = callMember.sender;
if (!userId) return null;
const name =
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined
: undefined;
return (
<MenuItem
key={callMember.membershipID}
size="400"
variant="Surface"
radii="300"
style={{ paddingLeft: config.space.S200 }}
onClick={(evt) =>
openUserProfile(
room.roomId,
undefined,
userId,
getMouseEventCords(evt.nativeEvent),
'Right'
)
}
before={
<Avatar size="200" radii="400">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Text size="T300" truncate>
{name}
</Text>
</MenuItem>
);
})}
</Box>
</Scroll>
</Box>
</Menu>
</FocusTrap>
}
>
<Chip
variant="Surface"
fill="Soft"
before={<Badge variant="Critical" fill="Solid" size="200" />}
after={<Icon size="50" src={cords ? Icons.ChevronBottom : Icons.ChevronTop} />}
radii="Pill"
onClick={handleOpenMenu}
>
<Text className={css.LiveChipText} as="span" size="L400" truncate>
{count} Live
</Text>
</Chip>
</PopOut>
);
}
@@ -0,0 +1,75 @@
import { Box, config, Icon, Icons, Text } from 'folds';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import React from 'react';
import { Room } from 'matrix-js-sdk';
import { UserAvatar } from '../../components/user-avatar';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { StackedAvatar } from '../../components/stacked-avatar';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { getMouseEventCords } from '../../utils/dom';
import * as css from './styles.css';
type MemberGlanceProps = {
room: Room;
members: CallMembership[];
speakers: Set<string>;
max?: number;
};
export function MemberGlance({ room, members, speakers, max = 6 }: MemberGlanceProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const openUserProfile = useOpenUserRoomProfile();
const visibleMembers = members.slice(0, max);
const remainingCount = max && members.length > max ? members.length - max : 0;
return (
<Box alignItems="Center">
{visibleMembers.map((callMember) => {
const userId = callMember.sender;
if (!userId) return null;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined
: undefined;
return (
<StackedAvatar
key={callMember.membershipID}
className={speakers.has(callMember.sender) ? css.SpeakerAvatarOutline : undefined}
title={name}
as="button"
variant="Background"
size="200"
radii="Pill"
onClick={(evt) =>
openUserProfile(
room.roomId,
undefined,
userId,
getMouseEventCords(evt.nativeEvent),
'Top'
)
}
>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</StackedAvatar>
);
})}
{remainingCount > 0 && (
<Text size="L400" style={{ paddingLeft: config.space.S100 }}>
+{remainingCount}
</Text>
)}
</Box>
);
}
@@ -0,0 +1,78 @@
import { Room } from 'matrix-js-sdk';
import React from 'react';
import { Box, Icon, Icons, Text } from 'folds';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
type MemberSpeakingProps = {
room: Room;
speakers: Set<string>;
};
export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) {
const speakingNames = Array.from(speakers).map(
(userId) => getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId
);
return (
<Box alignItems="Center" gap="100">
<Icon size="100" src={Icons.Mic} filled />
<Text size="T200" truncate>
{speakingNames.length === 1 && (
<>
<b>{speakingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' is speaking...'}
</Text>
</>
)}
{speakingNames.length === 2 && (
<>
<b>{speakingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{speakingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' are speaking...'}
</Text>
</>
)}
{speakingNames.length === 3 && (
<>
<b>{speakingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{speakingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{speakingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' are speaking...'}
</Text>
</>
)}
{speakingNames.length > 3 && (
<>
<b>{speakingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{speakingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{speakingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{speakingNames.length - 3} others</b>
<Text as="span" size="Inherit" priority="300">
{' are speaking...'}
</Text>
</>
)}
</Text>
</Box>
);
}
@@ -0,0 +1,9 @@
import React from 'react';
import { Line } from 'folds';
import * as css from './styles.css';
export function StatusDivider() {
return (
<Line variant="Background" size="300" direction="Vertical" className={css.ControlDivider} />
);
}
+1
View File
@@ -0,0 +1 @@
export * from './CallStatus';
@@ -0,0 +1,21 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const LiveChipText = style({
color: color.Critical.Main,
});
export const CallStatus = style([
{
padding: `${toRem(6)} ${config.space.S200}`,
borderTop: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
},
]);
export const ControlDivider = style({
height: toRem(16),
});
export const SpeakerAvatarOutline = style({
boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Success.Main}`,
});
+337
View File
@@ -0,0 +1,337 @@
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
import {
Box,
Button,
Chip,
config,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '../../components/sequence-card';
import * as css from './styles.css';
import {
ChatButton,
ControlDivider,
MicrophoneButton,
ScreenShareButton,
SoundButton,
VideoButton,
} from './Controls';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useResizeObserver } from '../../hooks/useResizeObserver';
import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
type CallControlsProps = {
callEmbed: CallEmbed;
};
export function CallControls({ callEmbed }: CallControlsProps) {
const controlRef = useRef<HTMLDivElement>(null);
const [compact, setCompact] = useState(document.body.clientWidth < 500);
useResizeObserver(
useCallback(() => {
const element = controlRef.current;
if (!element) return;
setCompact(element.clientWidth < 500);
}, []),
useCallback(() => controlRef.current, [])
);
const { microphone, video, sound, screenshare, spotlight } = useCallControlState(
callEmbed.control
);
const [cords, setCords] = useState<RectCords>();
const [shareConfirm, setShareConfirm] = useState(false);
const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [pttActive, setPttActive] = useState(false);
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
const microphoneRef = useRef(microphone);
useEffect(() => { microphoneRef.current = microphone; }, [microphone]);
// Handle PTT mode toggle mid-call
const pttModeRef = useRef(pttMode);
useEffect(() => {
if (pttMode && !pttModeRef.current) {
callEmbed.control.setMicrophone(false);
} else if (!pttMode && pttModeRef.current) {
callEmbed.control.setMicrophone(true);
}
pttModeRef.current = pttMode;
}, [pttMode, callEmbed]);
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleSpotlightClick = () => {
callEmbed.control.toggleSpotlight();
setCords(undefined);
};
const handleReactionsClick = () => {
callEmbed.control.toggleReactions();
setCords(undefined);
};
const handleSettingsClick = () => {
callEmbed.control.toggleSettings();
setCords(undefined);
};
useEffect(() => {
if (!pttMode) return;
const iframeWindow = callEmbed.iframe.contentWindow;
const onKeyDown = (e: KeyboardEvent) => {
if (e.code !== pttKey || e.repeat) return;
// Don't intercept keys typed into a text input or editable element
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true'
) return;
e.preventDefault();
if (!microphoneRef.current) callEmbed.control.setMicrophone(true);
setPttActive(true);
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code !== pttKey) return;
callEmbed.control.setMicrophone(false);
setPttActive(false);
};
// Release PTT when the tab loses focus to prevent stuck-on mic
const onBlur = () => {
callEmbed.control.setMicrophone(false);
setPttActive(false);
};
// Re-mute on focus restore: EC can re-assert audio_enabled:true on audio-context resume
const onFocus = () => {
callEmbed.control.setMicrophone(false);
setPttActive(false);
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
window.addEventListener('focus', onFocus);
iframeWindow?.addEventListener('keydown', onKeyDown);
iframeWindow?.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
window.removeEventListener('focus', onFocus);
iframeWindow?.removeEventListener('keydown', onKeyDown);
iframeWindow?.removeEventListener('keyup', onKeyUp);
};
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pttMode, pttKey, callEmbed]);
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed])
);
const exiting =
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
return (
<Box
ref={controlRef}
className={css.CallControlContainer}
justifyContent="Center"
alignItems="Center"
>
{pttMode && (
<Chip
variant={pttActive ? 'Success' : 'Warning'}
fill="Soft"
radii="400"
style={{
position: 'absolute',
top: '-2.2rem',
left: '50%',
transform: 'translateX(-50%)',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}
outlined
>
<Text size="T200" style={{ fontWeight: 700 }}>
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
</Text>
</Chip>
)}
{shareConfirm && (
<Box
style={{
position: 'absolute',
bottom: '110%',
left: '50%',
transform: 'translateX(-50%)',
background: 'var(--bg-surface)',
border: '1px solid var(--border-color)',
borderRadius: '0.75rem',
padding: '1rem 1.25rem',
zIndex: 100,
minWidth: '260px',
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}
>
<Text size="T300" style={{ fontWeight: 600 }}>Share your screen?</Text>
<Text size="T200" style={{ opacity: 0.75 }}>Your screen will be visible to all participants in this call.</Text>
<Box gap="200">
<Button
size="300" variant="Success" fill="Solid" radii="300"
onClick={() => { callEmbed.control.toggleScreenshare(); setShareConfirm(false); }}
>
<Text size="B300">Share</Text>
</Button>
<Button
size="300" variant="Surface" fill="Soft" radii="300" outlined
onClick={() => setShareConfirm(false)}
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
)}
<SequenceCard
className={css.ControlCard}
variant="SurfaceVariant"
gap="400"
radii="500"
alignItems="Center"
justifyContent="SpaceBetween"
>
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<MicrophoneButton
enabled={microphone}
onToggle={() => callEmbed.control.toggleMicrophone()}
/>
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
</Box>
{!compact && <ControlDivider />}
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
<ScreenShareButton
enabled={screenshare}
onToggle={() => screenshare
? callEmbed.control.toggleScreenshare()
: setShareConfirm(true)
}
/>
</Box>
</Box>
{!compact && <ControlDivider />}
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<ChatButton />
<PopOut
anchor={cords}
position="Top"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={handleSpotlightClick}
>
<Text size="B300" truncate>
{spotlight ? 'Grid View' : 'Spotlight View'}
</Text>
</MenuItem>
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={handleReactionsClick}
>
<Text size="B300" truncate>
Reactions
</Text>
</MenuItem>
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={handleSettingsClick}
>
<Text size="B300" truncate>
Settings
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
>
<IconButton
variant="Surface"
fill="Soft"
radii="400"
size="400"
onClick={handleOpenMenu}
outlined
aria-pressed={!!cords}
>
<Icon size="400" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
</Box>
<Box shrink="No" direction="Column">
<Button
style={{ minWidth: toRem(88) }}
variant="Critical"
fill="Solid"
onClick={hangup}
before={
exiting ? (
<Spinner variant="Critical" fill="Solid" size="200" />
) : (
<Icon src={Icons.PhoneDown} size="200" filled />
)
}
disabled={exiting}
>
<Text size="B400">End</Text>
</Button>
</Box>
</Box>
</SequenceCard>
</Box>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { CallMembership, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import React, { useState } from 'react';
import { Avatar, Box, Icon, Icons, Text } from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { SequenceCard } from '../../components/sequence-card';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { useRoom } from '../../hooks/useRoom';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { UserAvatar } from '../../components/user-avatar';
import { getMouseEventCords } from '../../utils/dom';
import * as css from './styles.css';
interface MemberWithMembershipData {
membershipData?: SessionMembershipData & {
'm.call.intent': 'video' | 'audio';
};
}
type CallMemberCardProps = {
member: CallMembership;
};
export function CallMemberCard({ member }: CallMemberCardProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = useRoom();
const openUserProfile = useOpenUserRoomProfile();
const userId = member.sender;
if (!userId) return null;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined
: undefined;
const audioOnly =
(member as unknown as MemberWithMembershipData).membershipData?.['m.call.intent'] === 'audio';
return (
<SequenceCard
as="button"
key={member.membershipID}
className={css.CallMemberCard}
variant="SurfaceVariant"
radii="500"
onClick={(evt: any) =>
openUserProfile(
room.roomId,
undefined,
userId,
getMouseEventCords(evt.nativeEvent),
'Right'
)
}
>
<Box grow="Yes" gap="300" alignItems="Center">
<Avatar size="200" radii="400">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
<Box grow="Yes">
<Text size="L400" truncate>
{name}
</Text>
</Box>
{audioOnly && <Icon src={Icons.VideoCameraMute} size="100" />}
</Box>
</SequenceCard>
);
}
export function CallMemberRenderer({
members,
max = 4,
}: {
members: CallMembership[];
max?: number;
}) {
const [viewMore, setViewMore] = useState(false);
const truncatedMembers = viewMore ? members : members.slice(0, 4);
const remaining = members.length - truncatedMembers.length;
return (
<>
{truncatedMembers.map((member) => (
<CallMemberCard key={member.membershipID} member={member} />
))}
{members.length > max && (
<SequenceCard
as="button"
className={css.CallMemberCard}
variant="SurfaceVariant"
radii="500"
onClick={() => setViewMore(!viewMore)}
>
<Box grow="Yes" gap="300" alignItems="Center">
{viewMore ? (
<Text size="L400" truncate>
Collapse
</Text>
) : (
<Text size="L400" truncate>
{remaining === 0 ? `+${remaining} Other` : `+${remaining} Others`}
</Text>
)}
</Box>
<Icon src={viewMore ? Icons.ChevronTop : Icons.ChevronBottom} size="100" />
</SequenceCard>
)}
</>
);
}
+150
View File
@@ -0,0 +1,150 @@
import React, { RefObject, useRef } from 'react';
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { PrescreenControls } from './PrescreenControls';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { StateEvent } from '../../../types/matrix/room';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
function LivekitServerMissingMessage() {
return (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your homeserver does not support calling. But you can still join call started by others.
</Text>
);
}
function JoinMessage({
hasParticipant,
livekitSupported,
}: {
hasParticipant?: boolean;
livekitSupported?: boolean;
}) {
if (hasParticipant) return null;
if (livekitSupported === false) {
return <LivekitServerMissingMessage />;
}
return (
<Text style={{ margin: 'auto' }} size="L400" align="Center">
Voice chats empty Be the first to hop in!
</Text>
);
}
function NoPermissionMessage() {
return (
<Text style={{ margin: 'auto' }} size="L400" align="Center">
You don&#39;t have permission to join!
</Text>
);
}
function AlreadyInCallMessage() {
return (
<Text style={{ margin: 'auto', color: color.Warning.Main }} size="L400" align="Center">
Already in another call End the current call to join!
</Text>
);
}
function CallPrescreen() {
const mx = useMatrixClient();
const room = useRoom();
const livekitSupported = useLivekitSupport();
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const hasPermission = permissions.event(StateEvent.GroupCallMemberPrefix, mx.getSafeUserId());
const callSession = useCallSession(room);
const callMembers = useCallMembers(room, callSession);
const hasParticipant = callMembers.length > 0;
const callEmbed = useCallEmbed();
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
const canJoin = hasPermission && (livekitSupported || hasParticipant);
return (
<Scroll variant="Surface" hideTrack>
<Box className={css.CallViewContent} alignItems="Center" justifyContent="Center">
<Box style={{ maxWidth: toRem(382), width: '100%' }} direction="Column" gap="100">
{hasParticipant && (
<Header size="300">
<Box grow="Yes" alignItems="Center">
<Text size="L400">Participant</Text>
</Box>
<Badge variant="Critical" fill="Solid" size="400">
<Text as="span" size="L400" truncate>
{callMembers.length} Live
</Text>
</Badge>
</Header>
)}
<CallMemberRenderer members={callMembers} />
<PrescreenControls canJoin={canJoin} />
<Box className={css.PrescreenMessage} alignItems="Center">
{!inOtherCall &&
(hasPermission ? (
<JoinMessage hasParticipant={hasParticipant} livekitSupported={livekitSupported} />
) : (
<NoPermissionMessage />
))}
{inOtherCall && <AlreadyInCallMessage />}
</Box>
</Box>
</Box>
</Scroll>
);
}
type CallJoinedProps = {
containerRef: RefObject<HTMLDivElement>;
joined: boolean;
};
function CallJoined({ joined, containerRef }: CallJoinedProps) {
const callEmbed = useCallEmbed();
return (
<Box grow="Yes" direction="Column">
<Box grow="Yes" ref={containerRef} />
{callEmbed && joined && <CallControls callEmbed={callEmbed} />}
</Box>
);
}
export function CallView() {
const room = useRoom();
const callContainerRef = useRef<HTMLDivElement>(null);
useCallEmbedPlacementSync(callContainerRef);
const callEmbed = useCallEmbed();
const callJoined = useCallJoined(callEmbed);
const currentJoined = callEmbed?.roomId === room.roomId && callJoined;
return (
<Box
className={ContainerColor({ variant: 'Surface' })}
style={{ minWidth: toRem(280) }}
grow="Yes"
>
{!currentJoined && <CallPrescreen />}
<CallJoined joined={currentJoined} containerRef={callContainerRef} />
</Box>
);
}
+177
View File
@@ -0,0 +1,177 @@
import React from 'react';
import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds';
import { useAtom } from 'jotai';
import * as css from './styles.css';
import { callChatAtom } from '../../state/callEmbed';
export function ControlDivider() {
return (
<Line variant="SurfaceVariant" size="300" direction="Vertical" className={css.ControlDivider} />
);
}
type MicrophoneButtonProps = {
enabled: boolean;
onToggle: () => void;
};
export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Turn Off Microphone' : 'Turn On Microphone'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'}
fill="Soft"
radii="400"
size="400"
onClick={() => onToggle()}
outlined
>
<Icon size="400" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
</IconButton>
)}
</TooltipProvider>
);
}
type SoundButtonProps = {
enabled: boolean;
onToggle: () => void;
};
export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Deafen' : 'Undeafen'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'}
fill="Soft"
radii="400"
size="400"
onClick={() => onToggle()}
outlined
>
<Icon
size="400"
src={enabled ? Icons.Headphone : Icons.HeadphoneMute}
filled={!enabled}
/>
</IconButton>
)}
</TooltipProvider>
);
}
type VideoButtonProps = {
enabled: boolean;
onToggle: () => void;
};
export function VideoButton({ enabled, onToggle }: VideoButtonProps) {
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Stop Camera' : 'Start Camera'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'}
fill="Soft"
radii="400"
size="400"
onClick={() => onToggle()}
outlined
>
<Icon
size="400"
src={enabled ? Icons.VideoCamera : Icons.VideoCameraMute}
filled={enabled}
/>
</IconButton>
)}
</TooltipProvider>
);
}
type ScreenShareButtonProps = {
enabled: boolean;
onToggle: () => void;
};
export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) {
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Stop Screenshare' : 'Start Screenshare'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'}
fill="Soft"
radii="400"
size="400"
onClick={() => onToggle()}
outlined
>
<Icon size="400" src={Icons.ScreenShare} filled={enabled} />
</IconButton>
)}
</TooltipProvider>
);
}
export function ChatButton() {
const [chat, setChat] = useAtom(callChatAtom);
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{chat ? 'Close Chat' : 'Open Chat'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={chat ? 'Success' : 'Surface'}
fill="Soft"
radii="400"
size="400"
onClick={() => setChat(!chat)}
outlined
>
<Icon size="400" src={Icons.Message} filled={chat} />
</IconButton>
)}
</TooltipProvider>
);
}
@@ -0,0 +1,97 @@
import React, { useEffect, useState } from 'react';
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
import { SequenceCard } from '../../components/sequence-card';
import * as css from './styles.css';
import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls';
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed';
import { useCallPreferences } from '../../state/hooks/callPreferences';
type MediaPermState = 'granted' | 'denied' | 'prompt' | 'unknown';
function useMediaPermissions(): MediaPermState {
const [state, setState] = useState<MediaPermState>('unknown');
useEffect(() => {
if (!navigator.permissions) {
setState('unknown');
return;
}
navigator.permissions
.query({ name: 'microphone' as PermissionName })
.then((result) => {
setState(result.state as MediaPermState);
result.onchange = () => setState(result.state as MediaPermState);
})
.catch(() => setState('unknown'));
}, []);
return state;
}
type PrescreenControlsProps = {
canJoin?: boolean;
};
export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
const room = useRoom();
const callEmbed = useCallEmbed();
const callJoined = useCallJoined(callEmbed);
const direct = useIsDirectRoom();
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
const startCall = useCallStart(direct);
const joining = callEmbed?.roomId === room.roomId && !callJoined;
const micPermission = useMediaPermissions();
const micDenied = micPermission === 'denied';
const disabled = inOtherCall || !canJoin || micDenied;
const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } =
useCallPreferences();
return (
<SequenceCard
className={css.ControlCard}
variant="SurfaceVariant"
gap="400"
radii="500"
alignItems="Center"
justifyContent="SpaceBetween"
wrap="Wrap"
>
<Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200">
<MicrophoneButton enabled={microphone} onToggle={toggleMicrophone} />
<SoundButton enabled={sound} onToggle={toggleSound} />
</Box>
<ControlDivider />
<Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200">
<VideoButton enabled={video} onToggle={toggleVideo} />
<ChatButton />
</Box>
<Box grow="Yes" direction="Column" gap="200">
{micDenied && (
<Text size="T200" style={{ color: 'var(--tc-critical-high, #e53e3e)', textAlign: 'center' }}>
Microphone access is blocked. Enable it in your browser settings to join.
</Text>
)}
<Button
variant={disabled ? 'Secondary' : 'Success'}
fill={disabled ? 'Soft' : 'Solid'}
onClick={() => startCall(room, { microphone, video, sound })}
disabled={disabled || joining}
before={
joining ? (
<Spinner variant="Success" fill="Solid" size="200" />
) : (
<Icon src={Icons.Phone} size="200" filled />
)
}
>
<Text size="B400">Join</Text>
</Button>
</Box>
</SequenceCard>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
export const CallViewContent = style({
padding: config.space.S400,
paddingRight: 0,
minHeight: '100%',
});
export const ControlCard = style({
padding: config.space.S300,
});
export const ControlDivider = style({
height: toRem(24),
});
export const CallMemberCard = style({
padding: config.space.S300,
});
export const CallControlContainer = style({
padding: config.space.S400,
position: 'relative',
});
export const PrescreenMessage = style({
padding: config.space.S200,
});
@@ -6,9 +6,8 @@ import { useAtomValue } from 'jotai';
import { import {
ExtendedJoinRules, ExtendedJoinRules,
JoinRulesSwitcher, JoinRulesSwitcher,
useRoomJoinRuleIcon, useJoinRuleIcons,
useRoomJoinRuleLabel, useRoomJoinRuleLabel,
useSpaceJoinRuleIcon,
} from '../../../components/JoinRulesSwitcher'; } from '../../../components/JoinRulesSwitcher';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css'; import { SequenceCardStyle } from '../../room-settings/styles.css';
@@ -75,8 +74,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) {
return r; return r;
}, [allowKnockRestricted, allowRestricted, allowKnock, space]); }, [allowKnockRestricted, allowRestricted, allowKnock, space]);
const icons = useRoomJoinRuleIcon(); const icons = useJoinRuleIcons(room.getType());
const spaceIcons = useSpaceJoinRuleIcon();
const labels = useRoomJoinRuleLabel(); const labels = useRoomJoinRuleLabel();
const [submitState, submit] = useAsyncCallback( const [submitState, submit] = useAsyncCallback(
@@ -137,7 +135,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) {
} }
after={ after={
<JoinRulesSwitcher <JoinRulesSwitcher
icons={room.isSpaceRoom() ? spaceIcons : icons} icons={icons}
labels={labels} labels={labels}
rules={joinRules} rules={joinRules}
value={rule} value={rule}
@@ -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
+60 -25
View File
@@ -1,5 +1,5 @@
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react'; import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
import { MatrixError, Room } from 'matrix-js-sdk'; import { MatrixError, Room, JoinRule } from 'matrix-js-sdk';
import { import {
Box, Box,
Button, Button,
@@ -33,24 +33,43 @@ import {
createRoom, createRoom,
CreateRoomAliasInput, CreateRoomAliasInput,
CreateRoomData, CreateRoomData,
CreateRoomKind, CreateRoomAccess,
CreateRoomKindSelector, CreateRoomAccessSelector,
RoomVersionSelector, RoomVersionSelector,
useAdditionalCreators, useAdditionalCreators,
CreateRoomType,
} from '../../components/create-room'; } from '../../components/create-room';
import { RoomType } from '../../../types/matrix/room';
import { CreateRoomTypeSelector } from '../../components/create-room/CreateRoomTypeSelector';
import { getRoomIconSrc } from '../../utils/room';
const getCreateRoomKindToIcon = (kind: CreateRoomKind) => { const getCreateRoomAccessToIcon = (access: CreateRoomAccess, type?: CreateRoomType) => {
if (kind === CreateRoomKind.Private) return Icons.HashLock; const isVoiceRoom = type === CreateRoomType.VoiceRoom;
if (kind === CreateRoomKind.Restricted) return Icons.Hash;
return Icons.HashGlobe; let joinRule: JoinRule = JoinRule.Public;
if (access === CreateRoomAccess.Restricted) joinRule = JoinRule.Restricted;
if (access === CreateRoomAccess.Private) joinRule = JoinRule.Knock;
return getRoomIconSrc(Icons, isVoiceRoom ? RoomType.Call : undefined, joinRule);
};
const getCreateRoomTypeToIcon = (type: CreateRoomType) => {
if (type === CreateRoomType.VoiceRoom) return Icons.VolumeHigh;
return Icons.Hash;
}; };
type CreateRoomFormProps = { type CreateRoomFormProps = {
defaultKind?: CreateRoomKind; defaultAccess?: CreateRoomAccess;
defaultType?: CreateRoomType;
space?: Room; space?: Room;
onCreate?: (roomId: string) => void; onCreate?: (roomId: string) => void;
}; };
export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormProps) { export function CreateRoomForm({
defaultAccess,
defaultType,
space,
onCreate,
}: CreateRoomFormProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const alive = useAlive(); const alive = useAlive();
@@ -64,8 +83,9 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
const allowRestricted = space && restrictedSupported(selectedRoomVersion); const allowRestricted = space && restrictedSupported(selectedRoomVersion);
const [kind, setKind] = useState( const [type, setType] = useState(defaultType ?? CreateRoomType.TextRoom);
defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private const [access, setAccess] = useState(
defaultAccess ?? (allowRestricted ? CreateRoomAccess.Restricted : CreateRoomAccess.Private)
); );
const allowAdditionalCreators = creatorsSupported(selectedRoomVersion); const allowAdditionalCreators = creatorsSupported(selectedRoomVersion);
const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } = const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } =
@@ -75,13 +95,13 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
const [knock, setKnock] = useState(false); const [knock, setKnock] = useState(false);
const [advance, setAdvance] = useState(false); const [advance, setAdvance] = useState(false);
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion); const allowKnock = access === CreateRoomAccess.Private && knockSupported(selectedRoomVersion);
const allowKnockRestricted = const allowKnockRestricted =
kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion); access === CreateRoomAccess.Restricted && knockRestrictedSupported(selectedRoomVersion);
const handleRoomVersionChange = (version: string) => { const handleRoomVersionChange = (version: string) => {
if (!restrictedSupported(version)) { if (!restrictedSupported(version)) {
setKind(CreateRoomKind.Private); setAccess(CreateRoomAccess.Private);
} }
selectRoomVersion(version); selectRoomVersion(version);
}; };
@@ -107,19 +127,23 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined; aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
if (!roomName) return; if (!roomName) return;
const publicRoom = kind === CreateRoomKind.Public; const publicRoom = access === CreateRoomAccess.Public;
let roomKnock = false; let roomKnock = false;
if (allowKnock && kind === CreateRoomKind.Private) { if (allowKnock && access === CreateRoomAccess.Private) {
roomKnock = knock; roomKnock = knock;
} }
if (allowKnockRestricted && kind === CreateRoomKind.Restricted) { if (allowKnockRestricted && access === CreateRoomAccess.Restricted) {
roomKnock = knock; roomKnock = knock;
} }
let roomType: RoomType | undefined;
if (type === CreateRoomType.VoiceRoom) roomType = RoomType.Call;
create({ create({
version: selectedRoomVersion, version: selectedRoomVersion,
type: roomType,
parent: space, parent: space,
kind, access,
name: roomName, name: roomName,
topic: roomTopic || undefined, topic: roomTopic || undefined,
aliasLocalPart: publicRoom ? aliasLocalPart : undefined, aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
@@ -136,21 +160,32 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
return ( return (
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500"> <Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
{!space && (
<Box direction="Column" gap="100">
<Text size="L400">Type</Text>
<CreateRoomTypeSelector
value={type}
onSelect={setType}
disabled={disabled}
getIcon={getCreateRoomTypeToIcon}
/>
</Box>
)}
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Access</Text> <Text size="L400">Access</Text>
<CreateRoomKindSelector <CreateRoomAccessSelector
value={kind} value={access}
onSelect={setKind} onSelect={setAccess}
canRestrict={allowRestricted} canRestrict={allowRestricted}
disabled={disabled} disabled={disabled}
getIcon={getCreateRoomKindToIcon} getIcon={(roomAccess) => getCreateRoomAccessToIcon(roomAccess, type)}
/> />
</Box> </Box>
<Box shrink="No" direction="Column" gap="100"> <Box shrink="No" direction="Column" gap="100">
<Text size="L400">Name</Text> <Text size="L400">Name</Text>
<Input <Input
required required
before={<Icon size="100" src={getCreateRoomKindToIcon(kind)} />} before={<Icon size="100" src={getCreateRoomAccessToIcon(access, type)} />}
name="nameInput" name="nameInput"
autoFocus autoFocus
size="500" size="500"
@@ -171,7 +206,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
/> />
</Box> </Box>
{kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />} {access === CreateRoomAccess.Public && <CreateRoomAliasInput disabled={disabled} />}
<Box shrink="No" direction="Column" gap="100"> <Box shrink="No" direction="Column" gap="100">
<Box gap="200" alignItems="End"> <Box gap="200" alignItems="End">
@@ -201,7 +236,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
/> />
</SequenceCard> </SequenceCard>
)} )}
{kind !== CreateRoomKind.Public && ( {access !== CreateRoomAccess.Public && (
<> <>
<SequenceCard <SequenceCard
style={{ padding: config.space.S300 }} style={{ padding: config.space.S300 }}
@@ -23,12 +23,13 @@ import {
} from '../../state/hooks/createRoomModal'; } from '../../state/hooks/createRoomModal';
import { CreateRoomModalState } from '../../state/createRoomModal'; import { CreateRoomModalState } from '../../state/createRoomModal';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { CreateRoomType } from '../../components/create-room/types';
type CreateRoomModalProps = { type CreateRoomModalProps = {
state: CreateRoomModalState; state: CreateRoomModalState;
}; };
function CreateRoomModal({ state }: CreateRoomModalProps) { function CreateRoomModal({ state }: CreateRoomModalProps) {
const { spaceId } = state; const { spaceId, type } = state;
const closeDialog = useCloseCreateRoomModal(); const closeDialog = useCloseCreateRoomModal();
const allJoinedRooms = useAllJoinedRoomsSet(); const allJoinedRooms = useAllJoinedRoomsSet();
@@ -57,7 +58,9 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
}} }}
> >
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">New Room</Text> <Text size="H4">
{type === CreateRoomType.VoiceRoom ? 'New Voice Room' : 'New Chat Room'}
</Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton size="300" radii="300" onClick={closeDialog}> <IconButton size="300" radii="300" onClick={closeDialog}>
@@ -74,7 +77,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
direction="Column" direction="Column"
gap="500" gap="500"
> >
<CreateRoomForm space={space} onCreate={closeDialog} /> <CreateRoomForm space={space} onCreate={closeDialog} defaultType={type} />
</Box> </Box>
</Scroll> </Scroll>
</Box> </Box>
+23 -23
View File
@@ -33,25 +33,25 @@ import {
createRoom, createRoom,
CreateRoomAliasInput, CreateRoomAliasInput,
CreateRoomData, CreateRoomData,
CreateRoomKind, CreateRoomAccess,
CreateRoomKindSelector, CreateRoomAccessSelector,
RoomVersionSelector, RoomVersionSelector,
useAdditionalCreators, useAdditionalCreators,
} from '../../components/create-room'; } from '../../components/create-room';
import { RoomType } from '../../../types/matrix/room'; import { RoomType } from '../../../types/matrix/room';
const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => { const getCreateSpaceAccessToIcon = (access: CreateRoomAccess) => {
if (kind === CreateRoomKind.Private) return Icons.SpaceLock; if (access === CreateRoomAccess.Private) return Icons.SpaceLock;
if (kind === CreateRoomKind.Restricted) return Icons.Space; if (access === CreateRoomAccess.Restricted) return Icons.Space;
return Icons.SpaceGlobe; return Icons.SpaceGlobe;
}; };
type CreateSpaceFormProps = { type CreateSpaceFormProps = {
defaultKind?: CreateRoomKind; defaultAccess?: CreateRoomAccess;
space?: Room; space?: Room;
onCreate?: (roomId: string) => void; onCreate?: (roomId: string) => void;
}; };
export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFormProps) { export function CreateSpaceForm({ defaultAccess, space, onCreate }: CreateSpaceFormProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const alive = useAlive(); const alive = useAlive();
@@ -65,8 +65,8 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
const allowRestricted = space && restrictedSupported(selectedRoomVersion); const allowRestricted = space && restrictedSupported(selectedRoomVersion);
const [kind, setKind] = useState( const [access, setAccess] = useState(
defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private defaultAccess ?? (allowRestricted ? CreateRoomAccess.Restricted : CreateRoomAccess.Private)
); );
const allowAdditionalCreators = creatorsSupported(selectedRoomVersion); const allowAdditionalCreators = creatorsSupported(selectedRoomVersion);
@@ -76,13 +76,13 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
const [knock, setKnock] = useState(false); const [knock, setKnock] = useState(false);
const [advance, setAdvance] = useState(false); const [advance, setAdvance] = useState(false);
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion); const allowKnock = access === CreateRoomAccess.Private && knockSupported(selectedRoomVersion);
const allowKnockRestricted = const allowKnockRestricted =
kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion); access === CreateRoomAccess.Restricted && knockRestrictedSupported(selectedRoomVersion);
const handleRoomVersionChange = (version: string) => { const handleRoomVersionChange = (version: string) => {
if (!restrictedSupported(version)) { if (!restrictedSupported(version)) {
setKind(CreateRoomKind.Private); setAccess(CreateRoomAccess.Private);
} }
selectRoomVersion(version); selectRoomVersion(version);
}; };
@@ -108,12 +108,12 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined; aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
if (!roomName) return; if (!roomName) return;
const publicRoom = kind === CreateRoomKind.Public; const publicRoom = access === CreateRoomAccess.Public;
let roomKnock = false; let roomKnock = false;
if (allowKnock && kind === CreateRoomKind.Private) { if (allowKnock && access === CreateRoomAccess.Private) {
roomKnock = knock; roomKnock = knock;
} }
if (allowKnockRestricted && kind === CreateRoomKind.Restricted) { if (allowKnockRestricted && access === CreateRoomAccess.Restricted) {
roomKnock = knock; roomKnock = knock;
} }
@@ -121,7 +121,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
version: selectedRoomVersion, version: selectedRoomVersion,
type: RoomType.Space, type: RoomType.Space,
parent: space, parent: space,
kind, access,
name: roomName, name: roomName,
topic: roomTopic || undefined, topic: roomTopic || undefined,
aliasLocalPart: publicRoom ? aliasLocalPart : undefined, aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
@@ -139,19 +139,19 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500"> <Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Access</Text> <Text size="L400">Access</Text>
<CreateRoomKindSelector <CreateRoomAccessSelector
value={kind} value={access}
onSelect={setKind} onSelect={setAccess}
canRestrict={allowRestricted} canRestrict={allowRestricted}
disabled={disabled} disabled={disabled}
getIcon={getCreateSpaceKindToIcon} getIcon={getCreateSpaceAccessToIcon}
/> />
</Box> </Box>
<Box shrink="No" direction="Column" gap="100"> <Box shrink="No" direction="Column" gap="100">
<Text size="L400">Name</Text> <Text size="L400">Name</Text>
<Input <Input
required required
before={<Icon size="100" src={getCreateSpaceKindToIcon(kind)} />} before={<Icon size="100" src={getCreateSpaceAccessToIcon(access)} />}
name="nameInput" name="nameInput"
autoFocus autoFocus
size="500" size="500"
@@ -172,7 +172,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
/> />
</Box> </Box>
{kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />} {access === CreateRoomAccess.Public && <CreateRoomAliasInput disabled={disabled} />}
<Box shrink="No" direction="Column" gap="100"> <Box shrink="No" direction="Column" gap="100">
<Box gap="200" alignItems="End"> <Box gap="200" alignItems="End">
@@ -202,7 +202,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
/> />
</SequenceCard> </SequenceCard>
)} )}
{kind !== CreateRoomKind.Public && advance && (allowKnock || allowKnockRestricted) && ( {access !== CreateRoomAccess.Public && advance && (allowKnock || allowKnockRestricted) && (
<SequenceCard <SequenceCard
style={{ padding: config.space.S300 }} style={{ padding: config.space.S300 }}
variant="SurfaceVariant" variant="SurfaceVariant"
+12 -3
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>
)} )}
+5 -3
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={
+16 -4
View File
@@ -36,6 +36,8 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal'; import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal'; import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
import { AddExistingModal } from '../add-existing'; import { AddExistingModal } from '../add-existing';
import { CreateRoomType } from '../../components/create-room/types';
import { BetaNoticeBadge } from '../../components/BetaNoticeBadge';
function SpaceProfileLoading() { function SpaceProfileLoading() {
return ( return (
@@ -249,8 +251,8 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
setCords(evt.currentTarget.getBoundingClientRect()); setCords(evt.currentTarget.getBoundingClientRect());
}; };
const handleCreateRoom = () => { const handleCreateRoom = (type?: CreateRoomType) => {
openCreateRoomModal(item.roomId); openCreateRoomModal(item.roomId, type);
setCords(undefined); setCords(undefined);
}; };
@@ -281,9 +283,19 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
radii="300" radii="300"
variant="Primary" variant="Primary"
fill="None" fill="None"
onClick={handleCreateRoom} onClick={() => handleCreateRoom(CreateRoomType.TextRoom)}
> >
<Text size="T300">New Room</Text> <Text size="T300">Chat Room</Text>
</MenuItem>
<MenuItem
size="300"
radii="300"
variant="Primary"
fill="None"
onClick={() => handleCreateRoom(CreateRoomType.VoiceRoom)}
after={<BetaNoticeBadge />}
>
<Text size="T300">Voice Room</Text>
</MenuItem> </MenuItem>
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}> <MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
<Text size="T300">Existing Room</Text> <Text size="T300">Existing Room</Text>
+278
View File
@@ -0,0 +1,278 @@
import { CSSProperties } from 'react';
import { ChatBackground } from '../../state/settings';
export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'blueprint', label: 'Blueprint' },
{ value: 'carbon', label: 'Carbon' },
{ value: 'stars', label: 'Stars' },
{ value: 'topographic', label: 'Topographic' },
{ value: 'herringbone', label: 'Herringbone' },
{ value: 'crosshatch', label: 'Crosshatch' },
{ value: 'chevron', label: 'Chevron' },
{ value: 'polka', label: 'Polka' },
{ value: 'triangles', label: 'Triangles' },
{ value: 'plaid', label: 'Plaid' },
{ value: 'tactical', label: 'Tactical' },
{ value: 'circuit', label: 'Circuit' },
{ value: 'hexgrid', label: 'Hex Grid' },
];
const DARK: Record<ChatBackground, CSSProperties> = {
none: {},
blueprint: {
backgroundColor: '#0a1628',
backgroundImage: [
'linear-gradient(rgba(100,149,237,0.14) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(100,149,237,0.14) 1px, transparent 1px)',
'linear-gradient(rgba(100,149,237,0.05) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(100,149,237,0.05) 1px, transparent 1px)',
].join(','),
backgroundSize: '80px 80px, 80px 80px, 16px 16px, 16px 16px',
},
carbon: {
backgroundColor: '#0e0e0e',
backgroundImage: [
'repeating-linear-gradient(45deg, rgba(255,255,255,0.035) 0, rgba(255,255,255,0.035) 2px, transparent 0, transparent 50%)',
'repeating-linear-gradient(135deg, rgba(255,255,255,0.035) 0, rgba(255,255,255,0.035) 2px, transparent 0, transparent 50%)',
].join(','),
backgroundSize: '8px 8px',
},
stars: {
backgroundColor: '#050510',
backgroundImage: [
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
'radial-gradient(circle, rgba(255,255,255,0.55) 1px, transparent 1px)',
'radial-gradient(circle, rgba(200,200,255,0.3) 1px, transparent 1px)',
].join(','),
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
backgroundPosition: '0 0, 65px 32px, 32px 97px',
},
topographic: {
backgroundColor: '#0f0f17',
backgroundImage: [
'repeating-radial-gradient(circle at 20% 20%, transparent 0, transparent 30px, rgba(152,0,0,0.07) 31px, transparent 32px)',
'repeating-radial-gradient(circle at 80% 80%, transparent 0, transparent 25px, rgba(100,100,200,0.06) 26px, transparent 27px)',
'repeating-radial-gradient(circle at 50% 10%, transparent 0, transparent 45px, rgba(152,0,0,0.04) 46px, transparent 47px)',
].join(','),
},
herringbone: {
backgroundColor: '#111118',
backgroundImage: [
'repeating-linear-gradient(60deg, rgba(180,160,210,0.08) 0, rgba(180,160,210,0.08) 1px, transparent 0, transparent 50%)',
'repeating-linear-gradient(120deg, rgba(180,160,210,0.08) 0, rgba(180,160,210,0.08) 1px, transparent 0, transparent 50%)',
].join(','),
backgroundSize: '20px 36px',
},
crosshatch: {
backgroundColor: '#0f0f0f',
backgroundImage: [
'linear-gradient(rgba(255,255,255,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(255,255,255,0.06) 1px, transparent 1px)',
'linear-gradient(rgba(255,255,255,0.022) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(255,255,255,0.022) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
},
// Interlocking zigzag stripes
chevron: {
backgroundColor: '#0f0f17',
backgroundImage: [
'linear-gradient(135deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
'linear-gradient(225deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
'linear-gradient(315deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
'linear-gradient(45deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
].join(','),
backgroundSize: '20px 20px',
},
// Even dot grid
polka: {
backgroundColor: '#0e0e14',
backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.2) 2px, transparent 2px)',
backgroundSize: '28px 28px',
},
// Isometric triangle grid
triangles: {
backgroundColor: '#111118',
backgroundImage: [
'linear-gradient(60deg, rgba(100,149,237,0.09) 25%, transparent 25%, transparent 75%, rgba(100,149,237,0.09) 75%)',
'linear-gradient(120deg, rgba(100,149,237,0.09) 25%, transparent 25%, transparent 75%, rgba(100,149,237,0.09) 75%)',
].join(','),
backgroundSize: '40px 70px',
backgroundPosition: '0 0, 20px 35px',
},
// Tartan-inspired crossing lines with accent colour
plaid: {
backgroundColor: '#0a1020',
backgroundImage: [
'repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(100,149,237,0.13) 39px, rgba(100,149,237,0.13) 40px)',
'repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(100,149,237,0.13) 39px, rgba(100,149,237,0.13) 40px)',
'repeating-linear-gradient(0deg, transparent, transparent 7px, rgba(152,0,0,0.08) 7px, rgba(152,0,0,0.08) 8px)',
'repeating-linear-gradient(90deg, transparent, transparent 7px, rgba(152,0,0,0.08) 7px, rgba(152,0,0,0.08) 8px)',
].join(','),
},
// LotusGuild TDS exact dot-grid
tactical: {
backgroundColor: '#030508',
backgroundImage: 'radial-gradient(circle, rgba(0,212,255,0.055) 1px, transparent 1px)',
backgroundSize: '28px 28px',
},
// Circuit board — green grid with node dots
circuit: {
backgroundColor: '#040a04',
backgroundImage: [
'linear-gradient(rgba(0,255,136,0.045) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,255,136,0.045) 1px, transparent 1px)',
'radial-gradient(circle, rgba(0,255,136,0.20) 1.5px, transparent 1.5px)',
].join(','),
backgroundSize: '40px 40px, 40px 40px, 40px 40px',
backgroundPosition: '0 0, 0 0, 20px 20px',
},
// True pointy-top hexagonal grid via SVG data URI
hexgrid: {
backgroundColor: '#060c14',
backgroundImage: 'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C212%2C255%2C0.13%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
backgroundSize: '29px 50px',
},
};
const LIGHT: Record<ChatBackground, CSSProperties> = {
none: {},
blueprint: {
backgroundColor: '#eef3ff',
backgroundImage: [
'linear-gradient(rgba(50,100,220,0.16) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(50,100,220,0.16) 1px, transparent 1px)',
'linear-gradient(rgba(50,100,220,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(50,100,220,0.06) 1px, transparent 1px)',
].join(','),
backgroundSize: '80px 80px, 80px 80px, 16px 16px, 16px 16px',
},
carbon: {
backgroundColor: '#efefef',
backgroundImage: [
'repeating-linear-gradient(45deg, rgba(0,0,0,0.04) 0, rgba(0,0,0,0.04) 2px, transparent 0, transparent 50%)',
'repeating-linear-gradient(135deg, rgba(0,0,0,0.04) 0, rgba(0,0,0,0.04) 2px, transparent 0, transparent 50%)',
].join(','),
backgroundSize: '8px 8px',
},
// Stars is intentionally always dark — it's a night-sky theme
stars: {
backgroundColor: '#050510',
backgroundImage: [
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
'radial-gradient(circle, rgba(255,255,255,0.55) 1px, transparent 1px)',
'radial-gradient(circle, rgba(200,200,255,0.3) 1px, transparent 1px)',
].join(','),
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
backgroundPosition: '0 0, 65px 32px, 32px 97px',
},
topographic: {
backgroundColor: '#faf8f5',
backgroundImage: [
'repeating-radial-gradient(circle at 20% 20%, transparent 0, transparent 30px, rgba(100,60,60,0.09) 31px, transparent 32px)',
'repeating-radial-gradient(circle at 80% 80%, transparent 0, transparent 25px, rgba(60,60,130,0.07) 26px, transparent 27px)',
'repeating-radial-gradient(circle at 50% 10%, transparent 0, transparent 45px, rgba(100,60,60,0.05) 46px, transparent 47px)',
].join(','),
},
herringbone: {
backgroundColor: '#f9f9f9',
backgroundImage: [
'repeating-linear-gradient(60deg, rgba(80,70,110,0.09) 0, rgba(80,70,110,0.09) 1px, transparent 0, transparent 50%)',
'repeating-linear-gradient(120deg, rgba(80,70,110,0.09) 0, rgba(80,70,110,0.09) 1px, transparent 0, transparent 50%)',
].join(','),
backgroundSize: '20px 36px',
},
crosshatch: {
backgroundColor: '#ffffff',
backgroundImage: [
'linear-gradient(rgba(0,0,0,0.07) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,0,0,0.07) 1px, transparent 1px)',
'linear-gradient(rgba(0,0,0,0.025) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,0,0,0.025) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
},
chevron: {
backgroundColor: '#f9f8ff',
backgroundImage: [
'linear-gradient(135deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
'linear-gradient(225deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
'linear-gradient(315deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
'linear-gradient(45deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
].join(','),
backgroundSize: '20px 20px',
},
polka: {
backgroundColor: '#fafafa',
backgroundImage: 'radial-gradient(circle, rgba(0,0,0,0.18) 2px, transparent 2px)',
backgroundSize: '28px 28px',
},
triangles: {
backgroundColor: '#f4f7ff',
backgroundImage: [
'linear-gradient(60deg, rgba(50,100,220,0.1) 25%, transparent 25%, transparent 75%, rgba(50,100,220,0.1) 75%)',
'linear-gradient(120deg, rgba(50,100,220,0.1) 25%, transparent 25%, transparent 75%, rgba(50,100,220,0.1) 75%)',
].join(','),
backgroundSize: '40px 70px',
backgroundPosition: '0 0, 20px 35px',
},
plaid: {
backgroundColor: '#f5f0ff',
backgroundImage: [
'repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(100,50,180,0.15) 39px, rgba(100,50,180,0.15) 40px)',
'repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(100,50,180,0.15) 39px, rgba(100,50,180,0.15) 40px)',
'repeating-linear-gradient(0deg, transparent, transparent 7px, rgba(200,0,0,0.09) 7px, rgba(200,0,0,0.09) 8px)',
'repeating-linear-gradient(90deg, transparent, transparent 7px, rgba(200,0,0,0.09) 7px, rgba(200,0,0,0.09) 8px)',
].join(','),
},
tactical: {
backgroundColor: '#f0f4fa',
backgroundImage: 'radial-gradient(circle, rgba(0,100,200,0.08) 1px, transparent 1px)',
backgroundSize: '28px 28px',
},
circuit: {
backgroundColor: '#f0f8f0',
backgroundImage: [
'linear-gradient(rgba(0,160,80,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,160,80,0.06) 1px, transparent 1px)',
'radial-gradient(circle, rgba(0,160,80,0.22) 1.5px, transparent 1.5px)',
].join(','),
backgroundSize: '40px 40px, 40px 40px, 40px 40px',
backgroundPosition: '0 0, 0 0, 20px 20px',
},
hexgrid: {
backgroundColor: '#f4f8ff',
backgroundImage: 'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%2850%2C100%2C220%2C0.11%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
backgroundSize: '29px 50px',
},
};
export const getChatBg = (bg: ChatBackground, isDark: boolean): CSSProperties =>
isDark ? DARK[bg] : LIGHT[bg];
@@ -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} />}
> >
@@ -203,7 +203,12 @@ export function SearchResultGroup({
src={getRoomAvatarUrl(mx, room, 96, useAuthentication)} src={getRoomAvatarUrl(mx, room, 96, useAuthentication)}
alt={room.name} alt={room.name}
renderFallback={() => ( renderFallback={() => (
<RoomIcon size="50" joinRule={room.getJoinRule() ?? JoinRule.Restricted} filled /> <RoomIcon
size="50"
roomType={room.getType()}
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
filled
/>
)} )}
/> />
</Avatar> </Avatar>
+78 -6
View File
@@ -19,6 +19,7 @@ import {
} 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 { useAtom, useAtomValue } from 'jotai';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; import { NavItem, NavItemContent, NavItemOptions, NavLink } 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';
@@ -51,6 +52,13 @@ 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 { useRoomName } from '../../hooks/useRoomMeta';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { callChatAtom } from '../../state/callEmbed';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '../../hooks/useLivekitSupport';
type RoomNavItemMenuProps = { type RoomNavItemMenuProps = {
room: Room; room: Room;
@@ -209,6 +217,24 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
} }
); );
function CallChatToggle() {
const [chat, setChat] = useAtom(callChatAtom);
return (
<IconButton
onClick={() => setChat(!chat)}
aria-pressed={chat}
aria-label="Toggle Chat"
variant="Background"
fill="None"
size="300"
radii="300"
>
<Icon size="50" src={Icons.Message} filled={chat} />
</IconButton>
);
}
type RoomNavItemProps = { type RoomNavItemProps = {
room: Room; room: Room;
selected: boolean; selected: boolean;
@@ -236,6 +262,8 @@ export function RoomNavItem({
(receipt) => receipt.userId !== mx.getUserId() (receipt) => receipt.userId !== mx.getUserId()
); );
const roomName = useRoomName(room);
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => { const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
evt.preventDefault(); evt.preventDefault();
setMenuAnchor({ setMenuAnchor({
@@ -251,6 +279,29 @@ export function RoomNavItem({
}; };
const optionsVisible = hover || !!menuAnchor; const optionsVisible = hover || !!menuAnchor;
const callSession = useCallSession(room);
const callMembers = useCallMembers(room, callSession);
const startCall = useCallStart(direct);
const callEmbed = useCallEmbed();
const callPref = useAtomValue(useCallPreferencesAtom());
const autoDiscoveryInfo = useAutoDiscoveryInfo();
const handleStartCall: MouseEventHandler<HTMLAnchorElement> = (evt) => {
// Do not join if no livekit support or call is not started by others
if (!livekitSupport(autoDiscoveryInfo) && callMembers.length === 0) {
return;
}
// Do not join if already in call
if (callEmbed) {
return;
}
// Start call in second click
if (selected) {
evt.preventDefault();
startCall(room, callPref);
}
};
return ( return (
<NavItem <NavItem
@@ -263,7 +314,7 @@ export function RoomNavItem({
{...hoverProps} {...hoverProps}
{...focusWithinProps} {...focusWithinProps}
> >
<NavLink to={linkPath}> <NavLink to={linkPath} onClick={room.isCallRoom() ? handleStartCall : undefined}>
<NavItemContent> <NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200"> <Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400"> <Avatar size="200" radii="400">
@@ -275,25 +326,28 @@ export function RoomNavItem({
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication)
} }
alt={room.name} alt={roomName}
renderFallback={() => ( renderFallback={() => (
<Text as="span" size="H6"> <Text as="span" size="H6">
{nameInitials(room.name)} {nameInitials(roomName)}
</Text> </Text>
)} )}
/> />
) : ( ) : (
<RoomIcon <RoomIcon
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }} style={{
opacity: unread ? config.opacity.P500 : config.opacity.P300,
}}
filled={selected} filled={selected}
size="100" size="100"
joinRule={room.getJoinRule()} joinRule={room.getJoinRule()}
roomType={room.getType()}
/> />
)} )}
</Avatar> </Avatar>
<Box as="span" grow="Yes"> <Box as="span" grow="Yes">
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate> <Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{room.name} {roomName}
</Text> </Text>
</Box> </Box>
{!optionsVisible && !unread && !selected && typingMember.length > 0 && ( {!optionsVisible && !unread && !selected && typingMember.length > 0 && (
@@ -307,14 +361,30 @@ export function RoomNavItem({
</UnreadBadgeCenter> </UnreadBadgeCenter>
)} )}
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
<Icon size="50" src={getRoomNotificationModeIcon(notificationMode)} /> <Icon
size="50"
src={getRoomNotificationModeIcon(notificationMode)}
aria-label={notificationMode}
/>
)}
{room.isCallRoom() && callMembers.length > 0 && (
<Badge variant="Critical" fill="Solid" size="400">
<Text as="span" size="L400" truncate>
{callMembers.length} Live
</Text>
</Badge>
)} )}
</Box> </Box>
</NavItemContent> </NavItemContent>
</NavLink> </NavLink>
{optionsVisible && ( {optionsVisible && (
<NavItemOptions> <NavItemOptions>
{selected && (callEmbed?.roomId === room.roomId || room.isCallRoom()) && (
<CallChatToggle />
)}
<PopOut <PopOut
id={`menu-${room.roomId}`}
aria-expanded={!!menuAnchor}
anchor={menuAnchor} anchor={menuAnchor}
offset={menuAnchor?.width === 0 ? 0 : undefined} offset={menuAnchor?.width === 0 ? 0 : undefined}
alignOffset={menuAnchor?.width === 0 ? 0 : -5} alignOffset={menuAnchor?.width === 0 ? 0 : -5}
@@ -343,6 +413,8 @@ export function RoomNavItem({
<IconButton <IconButton
onClick={handleOpenMenu} onClick={handleOpenMenu}
aria-pressed={!!menuAnchor} aria-pressed={!!menuAnchor}
aria-controls={`menu-${room.roomId}`}
aria-label="More Options"
variant="Background" variant="Background"
fill="None" fill="None"
size="300" size="300"
@@ -104,6 +104,7 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
renderFallback={() => ( renderFallback={() => (
<RoomIcon <RoomIcon
size="50" size="50"
roomType={room.getType()}
joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite} joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite}
filled filled
/> />
@@ -23,7 +23,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId()); const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId());
const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId()); const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId());
const permissionGroups = usePermissionGroups(); const permissionGroups = usePermissionGroups(room.isCallRoom());
const [powerEditor, setPowerEditor] = useState(false); const [powerEditor, setPowerEditor] = useState(false);
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { MessageEvent, StateEvent } from '../../../../types/matrix/room'; import { MessageEvent, StateEvent } from '../../../../types/matrix/room';
import { PermissionGroup } from '../../common-settings/permissions'; import { PermissionGroup } from '../../common-settings/permissions';
export const usePermissionGroups = (): PermissionGroup[] => { export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
const groups: PermissionGroup[] = useMemo(() => { const groups: PermissionGroup[] = useMemo(() => {
const messagesGroup: PermissionGroup = { const messagesGroup: PermissionGroup = {
name: 'Messages', name: 'Messages',
@@ -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: [
@@ -177,6 +190,13 @@ export const usePermissionGroups = (): PermissionGroup[] => {
const otherSettingsGroup: PermissionGroup = { const otherSettingsGroup: PermissionGroup = {
name: 'Other', name: 'Other',
items: [ items: [
{
location: {
state: true,
key: StateEvent.PoniesRoomEmotes,
},
name: 'Manage Emojis & Stickers',
},
{ {
location: { location: {
state: true, state: true,
@@ -196,12 +216,13 @@ export const usePermissionGroups = (): PermissionGroup[] => {
return [ return [
messagesGroup, messagesGroup,
...(isCallRoom ? [callSettingsGroup] : []),
moderationGroup, moderationGroup,
roomOverviewGroup, roomOverviewGroup,
roomSettingsGroup, roomSettingsGroup,
otherSettingsGroup, otherSettingsGroup,
]; ];
}, []); }, [isCallRoom]);
return groups; return groups;
}; };
+57
View File
@@ -0,0 +1,57 @@
import React from 'react';
import { useSetAtom } from 'jotai';
import { useParams } from 'react-router-dom';
import { Box, Text, TooltipProvider, Tooltip, Icon, Icons, IconButton, toRem } from 'folds';
import { Page, PageHeader } from '../../components/page';
import { callChatAtom } from '../../state/callEmbed';
import { RoomView } from './RoomView';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
export function CallChatView() {
const { eventId } = useParams();
const setChat = useSetAtom(callChatAtom);
const screenSize = useScreenSizeContext();
const handleClose = () => setChat(false);
return (
<Page
style={{
width: screenSize === ScreenSize.Desktop ? toRem(456) : '100%',
flexShrink: 0,
flexGrow: 0,
}}
>
<PageHeader>
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes">
<Text size="H5" truncate>
Chat
</Text>
</Box>
<Box shrink="No" alignItems="Center">
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton ref={triggerRef} variant="Surface" onClick={handleClose}>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
</Box>
</PageHeader>
<Box grow="Yes" direction="Column">
<RoomView eventId={eventId} />
</Box>
</Page>
);
}
+34 -2
View File
@@ -2,6 +2,7 @@ import React, { useCallback } from 'react';
import { Box, Line } from 'folds'; import { Box, Line } from 'folds';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { useAtomValue } from 'jotai';
import { RoomView } from './RoomView'; import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer'; import { MembersDrawer } from './MembersDrawer';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
@@ -13,6 +14,10 @@ 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';
import { callChatAtom } from '../../state/callEmbed';
import { CallChatView } from './CallChatView';
export function Room() { export function Room() {
const { eventId } = useParams(); const { eventId } = useParams();
@@ -24,6 +29,7 @@ export function Room() {
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);
const chat = useAtomValue(callChatAtom);
useKeyDown( useKeyDown(
window, window,
@@ -37,11 +43,37 @@ export function Room() {
) )
); );
const callView = room.isCallRoom();
return ( return (
<PowerLevelsContextProvider value={powerLevels}> <PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes"> <Box grow="Yes">
<RoomView room={room} eventId={eventId} /> {callView && (screenSize === ScreenSize.Desktop || !chat) && (
{screenSize === ScreenSize.Desktop && isDrawer && ( <Box grow="Yes" direction="Column">
<RoomViewHeader callView />
<Box grow="Yes">
<CallView />
</Box>
</Box>
)}
{!callView && (
<Box grow="Yes" direction="Column">
<RoomViewHeader />
<Box grow="Yes">
<RoomView eventId={eventId} />
</Box>
</Box>
)}
{callView && chat && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<CallChatView />
</>
)}
{!callView && 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} />

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