Compare commits

...

204 Commits

Author SHA1 Message Date
Krishan
2a498168ac Enable semantic check on PR title 2025-08-17 11:07:31 +00:00
Krishan
802357b7a0 Rename the PL 150 to Manager (#2443)
Manager seem more appropriate than Co-Founder. As Co-founder essentially have same power to Founder.
2025-08-17 16:20:17 +05:30
Ajay Bura
c5d4530947 Add new join with address prompt (#2442) 2025-08-16 21:40:39 +10:00
Ajay Bura
367397fdd4 Fix type error when accessing FileList (#2441) 2025-08-16 21:35:34 +10:00
Ajay Bura
63fa60e7f4 Open user profile at around mouse anchor (#2440) 2025-08-16 21:34:46 +10:00
Ajay Bura
544a06964d Hide block user button for own profile (#2439) 2025-08-16 21:32:09 +10:00
Ajay Bura
50583f9474 Fix room v12 mention pills (#2438) 2025-08-16 21:30:52 +10:00
Ajay Bura
1ad7fe8deb Fix missing creators support using via (#2431)
* add additional_creators in IRoomCreateContent type

* use creators in getViaServers

* consider creators in guessing perfect parent
2025-08-16 21:30:02 +10:00
Ajay Bura
752a19a4e7 Open tombstone space as space (#2428) 2025-08-16 21:27:37 +10:00
Krishan
76ac4e2987 Release v4.9.0 (#2421) 2025-08-13 12:08:19 +10:00
Ajay Bura
f82cfead46 Support room version 12 (#2399)
* WIP - support room version 12

* add room creators hook

* revert changes from powerlevels

* improve use room creators hook

* add hook to get dm users

* add options to add creators in create room/space

* add member item component in member drawer

* remove unused import

* extract member drawer header component

* get room creators as set only if room version support them

* add room permissions hook

* support room v12 creators power

* make predecessor event id optional

* add info about founders in permissions

* allow to create infinite powers to room creators

* allow everyone with permission to create infinite power

* handle additional creators in room upgrade

* add option to follow space tombstone
2025-08-13 00:12:30 +10:00
Ajay Bura
4d1ae4eafd Redesign user profile view (#2396)
* WIP - new profile view

* render common rooms in user profile

* add presence component

* WIP - room user profile

* temp hide profile button

* show mutual rooms in spaces, rooms and direct messages categories

* add message button

* add option to change user powers in profile

* improve ban info and option to unban

* add share user button in user profile

* add option to block user in user profile

* improve blocked user alert body

* add moderation tool in user profile

* open profile view on left side in member drawer

* open new user profile in all places
2025-08-09 22:16:10 +10:00
Gimle Larpes
a41dee4a55 Minor usability improvements (#2405)
* usability improvements

* revert change

* requested change
2025-08-05 18:59:04 +05:30
Krishan
13961d501f Revert "Update dependency linkifyjs to v4.3.2 [SECURITY] (#2407)" (#2414)
This reverts commit fe4fb4b4f7.
2025-08-05 23:16:49 +10:00
Gimle Larpes
e6f14e79da Prevent publishing rooms with incompatible joinrules to directory (#2406)
* prevent listing "private" rooms on directory

* clean up boolean expression

* add knock_restricted
2025-08-05 18:40:42 +05:30
dependabot[bot]
1ff09d0fc1 Bump docker/metadata-action from 5.7.0 to 5.8.0 (#2413)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.7.0 to 5.8.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5.7.0...v5.8.0)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: 5.8.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>
2025-08-05 23:10:19 +10:00
dependabot[bot]
f2d25c8d6c Bump docker/login-action from 3.4.0 to 3.5.0 (#2412)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.4.0 to 3.5.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3.4.0...v3.5.0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 3.5.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>
2025-08-05 23:09:54 +10:00
renovate[bot]
fe4fb4b4f7 Update dependency linkifyjs to v4.3.2 [SECURITY] (#2407)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 23:09:22 +10:00
Ajay Bura
faa952295f Redesign space/room creation panel (#2408)
* add new create room

* rename create room modal file

* default restrict access for space children in room create modal

* move create room kind selector to components

* add radii variant to sequence card component

* more more reusable create room logic to components

* add create space

* update address input description

* add new space modal

* fix add room button visible on left room in space lobby
2025-08-05 23:07:07 +10:00
Ajay Bura
e9798a22c3 Show file size exceeds error on upload (#2411)
* Show file size exceeds error on upload

* fix missing import and make size bold
2025-08-05 23:05:18 +10:00
Jaggar
34dd64103c Fix room input for virtual keyboard on webkit (#2346)
* Slate style has certain behavior elements that iOS expects

* Swap to using that implementation
2025-08-05 23:04:21 +10:00
Ajay Bura
6a27720709 Improve thread reply layout (#2410) 2025-08-04 20:34:01 +05:30
Ajay Bura
ccf10fc20c Revert "Fix menus congestion and improve thread reply layout (#2402)" (#2409)
This reverts commit d8d4714370.
2025-08-04 20:29:12 +05:30
Ajay Bura
31942b1114 Add code block language header and improve styles (#2403)
* add code block language header and improve styles

* improve codeblock fallback text

* move floating expand button to code block header

* reduce code font size
2025-07-27 22:21:09 +10:00
Ajay Bura
d8d4714370 Fix menus congestion and improve thread reply layout (#2402)
* make menus more spacious

* improve threaded reply layout

* fix search filter button spacing
2025-07-27 22:20:23 +10:00
Gimle Larpes
9183fd66b2 Add settings to enable 24-hour time format and customizable date format (#2347)
* Add setting to enable 24-hour time format

* added hour24Clock to TimeProps

* Add incomplete dateFormatString setting

* Move 24-hour  toggle to Appearance

* Add "Date & Time" subheading, cleanup after merge

* Add setting for date formatting

* Fix minor formatting and naming issues

* Document functions

* adress most comments

* add hint for date formatting

* add support for 24hr time to TimePicker

* prevent overflow on small displays
2025-07-27 22:13:00 +10:00
Ajay Bura
67b05eeb09 Render room avatar as fallback for dm group chat (#2398)
* render room avatar for dm group chat

* remove extra conditions
2025-07-23 21:00:02 +05:30
Ajay Bura
7d4b0dd133 Fix small height image half clickable view button (#2397) 2025-07-23 20:59:32 +05:30
Filipe Medeiros
9073dee986 Add button to start thread on reply (#2320)
* add simple button to start a thread on reply

* force build

* remove useless actions

* add actions back

* change icon to ThreadPlus

* add button to context menu

* fix capital T

---------

Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
2025-07-23 20:47:17 +05:30
Gimle Larpes
3cdb5c2fe6 Add code block copy and collapse functionality (#2361)
* add buttons to codeblocks

* add functionality

* Document functions

* Improve accessibility

* Remove pointless DefaultReset

* implement some requested changes

* fix content shift when expanding or collapsing

---------

Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
2025-07-23 20:40:56 +05:30
Ajay Bura
acc7d4ff56 Support oidc action param for login and register page (#2389) 2025-07-16 20:49:13 +10:00
Ajay Bura
50cc78788f Jump to time option in room timeline (#2377)
* add time and date picker components

* add time utils

* add jump to time in room timeline

* fix typo causing crash in safari
2025-07-15 22:41:33 +10:00
Ajay Bura
c462a3b8d5 Link device account management with OIDC (#2390)
* load auth metadata configs on startup

* deep-link cross-signing reset button with oidc

* deep-link manage devices and delete device with oidc

* fix import typo
2025-07-15 22:40:16 +10:00
Ajay Bura
c30c142653 Stop parsing servername from roomId (#2391) 2025-07-15 22:33:45 +10:00
Ajay Bura
fbd7e0a14b improve parent selection when opening a room (#2388)
when a room has more than one orphan parent, we will select parent which has highest number of special users who have special powers in selected room.
2025-07-11 21:03:55 +10:00
Ajay Bura
6b81401e2d fix room not opening when two rooms has same alias (#2387) 2025-07-11 21:00:30 +10:00
renovate[bot]
c757b8967f Update dependency vite to v5.4.19 [SECURITY] (#2326)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-05 21:52:35 +10:00
dependabot[bot]
d0a7ef31bc Bump softprops/action-gh-release from 2.2.1 to 2.3.2 (#2363)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.2.1 to 2.3.2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](c95fe14893...72f2c25fcb)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 2.3.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>
2025-07-05 21:51:29 +10:00
dependabot[bot]
3fd8a18157 Bump dawidd6/action-download-artifact from 9 to 11 (#2364)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 9 to 11.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](07ab29fd4a...ac66b43f0e)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-05 21:49:14 +10:00
dependabot[bot]
54ba1096d7 Bump nginx from 1.27.4-alpine to 1.29.0-alpine (#2382)
Bumps nginx from 1.27.4-alpine to 1.29.0-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-version: 1.29.0-alpine
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-05 21:38:01 +10:00
Ajay Bura
87fc490c3b Fix new direct message showing with room (#2386)
as we were mutating the content of m.direct the sdk was comparing old value with new one and preventing update if found equal
2025-07-05 21:31:15 +10:00
RGBCube
ebe5beba1d Add support for more code highlight (#2355) 2025-06-29 16:13:47 +05:30
Gimle Larpes
77ab37f637 Fix focus behaviour when opening single-purpose features (#2349)
* Improve focus behaviour on search boxes and chats

* Implemented MR #2317

* Fix crash if canMessage is false

* Prepare for PR #2335

* disable autofocus on message field
2025-06-28 20:15:21 +05:30
Gimle Larpes
461e730c34 Make "View Source" a developer tool (#2368) 2025-06-28 16:05:59 +05:30
Priyansh
05e83eabef Fix auto focus in "Join with Address" text input (#2317) 2025-06-27 21:50:28 +05:30
dependabot[bot]
ba72925d53 Bump docker/build-push-action from 6.15.0 to 6.18.0 (#2351)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.15.0 to 6.18.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.15.0...v6.18.0)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 6.18.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>
2025-06-19 08:52:03 +10:00
dependabot[bot]
87ce209050 Bump actions/setup-node from 4.3.0 to 4.4.0 (#2307)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.3.0 to 4.4.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4.3.0...v4.4.0)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 4.4.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>
2025-06-19 08:29:55 +10:00
Ajay Bura
3ed8260877 Release v4.8.1 (#2360) 2025-06-10 23:48:55 +10:00
Ajay Bura
44347db6e4 Add allow from currently selected space if no m.space.parent found (#2359) 2025-06-10 23:47:46 +10:00
Ajay Bura
91632aa193 Fix space navigation & view space timeline dev-option (#2358)
* fix inaccessible space on alias change

* fix new room in space open in home

* allow opening space timeline

* hide event timeline feature behind dev tool

* add navToActivePath to clear cache function
2025-06-10 14:44:17 +10:00
Ajay Bura
e6f4eeca8e Update folds to v2.2.0 (#2341) 2025-05-27 14:10:27 +05:30
Ajay Bura
a23279e633 Fix rate limit when reordering in space lobby (#2254)
* move can drop lobby item logic to hook

* add comment

* resolve rate limit when reordering space children
2025-05-26 14:21:27 +05:30
Krishan
83057ebbd4 Fix additional spam string matching (#2339) 2025-05-25 15:51:19 +05:30
Ajay Bura
c51ba9670e Release v4.8.0 (#2337) 2025-05-24 21:22:39 +05:30
Ajay Bura
59a007419f hide decline all public invite button when no invite 2025-05-24 21:19:35 +05:30
Ajay Bura
206ed33516 Better invites management (#2336)
* move block users to account settings

* filter invites and add more options

* add better rate limit recovery in rateLimitedActions util function
2025-05-24 20:07:56 +05:30
Ajay Bura
0d27bde33e Release v4.7.1 (#2332) 2025-05-21 17:28:38 +05:30
Ajay Bura
df391968d8 Fix crash on malformed blurhash (#2331) 2025-05-21 17:28:13 +05:30
Ajay Bura
5964eee833 Release v4.7.0 (#2328) 2025-05-18 11:45:12 +05:30
Ajay Bura
387ce9c462 upgrade to matrix-js-sdk v37.5.0 (#2327)
* upgrade to js-sdk 37

* fix server crypto wasm locally
2025-05-18 10:53:56 +05:30
Ajay Bura
6ddcf2cb02 update kick command example 2025-05-13 16:58:43 +05:30
Ajay Bura
87e97eab88 Update commands (#2325)
* kick-ban all members by servername

* Add command for deleting multiple messages

* remove console logs and improve ban command description

* improve commands description

* add server acl command

* fix code highlight not working after editing in dev tools
2025-05-13 16:16:22 +05:30
Ajay Bura
13f1d53191 fix room setting crash in knock_restricted join rule (#2323)
* fix room setting crash in knock_restricted join rule

* only show knock & space member join rule for space children

* fix knock restricted icon and label
2025-05-13 14:18:52 +05:30
Krishan
225894d327 Release v4.6.0 (#2301) 2025-03-31 17:49:00 +05:30
Ajay Bura
c14333c540 remove libolm related code (#2300) 2025-03-31 19:10:24 +11:00
sophie
405d1f6789 Update example caddyfile (#2285) 2025-03-28 20:19:34 +11:00
dependabot[bot]
ff6d0b8f9b Bump actions/upload-artifact from 4.6.1 to 4.6.2 (#2289)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.1 to 4.6.2.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.6.1...v4.6.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-28 20:18:37 +11:00
renovate[bot]
d141c02074 Update dependency vite to v5.4.15 [SECURITY] (#2292)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-28 20:17:17 +11:00
Ajay Bura
12ae94cd60 Remove old settings components (#2296) 2025-03-28 20:16:01 +11:00
Ajay Bura
82805dcfdd Display no members when changing filter in room members (#2297) 2025-03-28 20:15:31 +11:00
Ajay Bura
5c39a36c12 Add new space settings (#2293) 2025-03-27 19:54:13 +11:00
Ajay Bura
4aed4d7472 Fix DM rooms are not being encrypted (#2286)
* force check user device before creating dm

* fix getUserDeviceInfo doesn't exist on MatrixClient
2025-03-24 20:08:11 +11:00
Ajay Bura
649f70332b Fix displayname input controlled/uncontrolled error (#2287) 2025-03-24 20:07:15 +11:00
Ajay Bura
08e975cd8e Change username color in chat with power level color (#2282)
* add active theme context

* add chroma js library

* add hook for accessible tag color

* disable reply user color - temporary

* render user color based on tag in room timeline

* remove default tag icons

* move accessible color function to plugins

* render user power color in reply

* increase username weight in timeline

* add default color for member power level tag

* show red slash in power color badge with no color

* show power level color in room input reply

* show power level username color in notifications

* show power level color in notification reply

* show power level color in message search

* render power level color in room pin menu

* add toggle for legacy username colors

* drop over saturation from member default color

* change border color of power color badge

* show legacy username color in direct rooms
2025-03-23 22:09:29 +11:00
Ajay Bura
7d54eef95b Add room notification mode switcher in room header menu (#2284) 2025-03-22 19:22:29 +11:00
Ajay Bura
1361c1d5de Add margin top on media caption (#2283) 2025-03-22 19:21:49 +11:00
Gary Wang
ea48092270 Fix press enter while composing will cause send unfinished message (#2266) 2025-03-20 20:38:08 +11:00
dependabot[bot]
324ed776c9 Bump dawidd6/action-download-artifact from 8 to 9 (#2269)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 8 to 9.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](20319c5641...07ab29fd4a)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-20 20:37:25 +11:00
renovate[bot]
7af89da092 Update dependency vite to v5.4.12 [SECURITY] (#2176)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-20 20:34:05 +11:00
dependabot[bot]
002223e149 Bump docker/login-action from 3.3.0 to 3.4.0 (#2277)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.3.0 to 3.4.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3.3.0...v3.4.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-20 20:32:21 +11:00
dependabot[bot]
90aecb8d7a Bump actions/setup-node from 4.2.0 to 4.3.0 (#2278)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.2.0 to 4.3.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4.2.0...v4.3.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-20 20:30:02 +11:00
renovate[bot]
3e39dd25af Update dependency prismjs to v1.30.0 [SECURITY] (#2270)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-20 20:27:52 +11:00
Ajay Bura
71bfc96b5c Add option to change room notification settings (#2281) 2025-03-20 20:27:00 +11:00
Ajay Bura
074a5e855d Add publish to directory toggle in room settings (#2279) 2025-03-20 20:25:31 +11:00
Ajay Bura
c16e060f73 Add room upgrade option in room settings (#2280)
* add room upgrade option in room settings

* update upgrade room dialog styles
2025-03-20 20:23:16 +11:00
Ajay Bura
f688e2d1ae fix error when editing room profile 2025-03-20 09:57:57 +05:30
Ajay Bura
286983c833 New room settings, add customizable power levels and dev tools (#2222)
* WIP - add room settings dialog

* join rule setting - WIP

* show emojis & stickers in room settings - WIP

* restyle join rule switcher

* Merge branch 'dev' into new-room-settings

* add join rule hook

* open room settings from global state

* open new room settings from all places

* rearrange settings menu item

* add option for creating new image pack

* room devtools - WIP

* render room state events as list

* add option to open state event

* add option to edit state event

* refactor text area code editor into hook

* add option to send message and state event

* add cutout card component

* add hook for room account data

* display room account data - WIP

* refactor global account data editor component

* add account data editor in room

* fix font style in devtool

* show state events in compact form

* add option to delete room image pack

* add server badge component

* add member tile component

* render members in room settings

* add search in room settings member

* add option to reset member search

* add filter in room members

* fix member virtual item key

* remove color from serve badge in room members

* show room in settings

* fix loading indicator position

* power level tags in room setting - WIP

* generate fallback tag in backward compatible way

* add color picker

* add powers editor - WIP

* add props to stop adding emoji to recent usage

* add beta feature notice badge

* add types for power level tag icon

* refactor image pack rooms code to hook

* option for adding new power levels tags

* remove console log

* refactor power icon

* add option to edit power level tags

* remove power level from powers pill

* fix power level labels

* add option to delete power levels

* fix long power level name shrinks power integer

* room permissions - WIP

* add power level selector component

* add room permissions

* move user default permission setting to other group

* add power permission peek menu

* fix weigh of power switch text

* hide above for max power in permission switcher

* improve beta badge description

* render room profile in room settings

* add option to edit room profile

* make room topic input text area

* add option to enable room encryption in room settings

* add option to change message history visibility

* add option to change join rule

* add option for addresses in room settings

* close encryption dialog after enabling
2025-03-19 23:14:54 +11:00
Ajay Bura
00f3df8719 Stop showing notification from invite/left rooms (#2267) 2025-03-12 22:50:23 +11:00
Ajay Bura
d8009978e5 add option to download audio/video file (#2253)
* add option to download audio file

* add button to download video
2025-03-06 14:29:23 +11:00
dependabot[bot]
9bb30fbd92 Bump docker/setup-buildx-action from 3.9.0 to 3.10.0 (#2242)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.9.0 to 3.10.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.9.0...v3.10.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-06 12:58:37 +11:00
dependabot[bot]
82688c2e13 Bump docker/metadata-action from 5.6.1 to 5.7.0 (#2240)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.6.1 to 5.7.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5.6.1...v5.7.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-06 12:58:18 +11:00
dependabot[bot]
a02d7162d9 Bump actions/upload-artifact from 4.6.0 to 4.6.1 (#2241)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.0 to 4.6.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.6.0...v4.6.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-06 12:57:06 +11:00
dependabot[bot]
e39cc32df9 Bump docker/build-push-action from 6.13.0 to 6.15.0 (#2243)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.13.0 to 6.15.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.13.0...v6.15.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-06 12:56:33 +11:00
dependabot[bot]
6017c0a2fc Bump docker/setup-qemu-action from 3.4.0 to 3.6.0 (#2244)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.4.0 to 3.6.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3.4.0...v3.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-06 12:56:11 +11:00
Krishan
78fa6e3925 Release v4.5.1 (#2251) 2025-03-05 13:33:18 +11:00
Ajay Bura
5d00383d71 fix crash on emoji selection from emojiboard (#2249)
* fix crash on emoji select

* fix crash in message editor on emoji select
2025-03-05 13:23:28 +11:00
Krishan
19cf700d61 Release v4.5.0 (#2247) 2025-03-04 17:47:28 +11:00
Ajay Bura
0c5ff65639 fix backslash inserted in links upon edit (#2246)
* fix backslash appear in url with inline markdown sequences

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

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

* open account data inside setting window

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

* stop sending/receiving typing status

* send private read receipt when setting toggle is activated

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

* add image preview on upload

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

* collapse autocomplete on escape

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

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

* Less jank typing

* Mention the reply author in m.mentions

* Improve typing

* Fix typing in m.mentions finder

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

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

* Formatting

* Add intentional mentions to edited messages

* refactor reusable code and fix todo

* parse mentions from all nodes

---------

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

* Clarify variable names and wording

* Restore list atom

* Improve spoilered image UX with autoload off

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

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

* add enable param to level hierarchy hook

* improve HierarchyItem types

* fix type errors in lobby

* load space hierarachy per level

* fix menu item visibility

* fix unknown spaces over federation

* show inaccessible rooms only to admins

* fix unknown room renders loading content twice

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

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

* fix typo

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

* recover inline escape sequences on edit

* remove escape sequences from plain text body

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

* escape block markdown sequences

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

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

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

* add hook for email notification status

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

* remove search limit from user mention

* remove limit from room mention autocomplete

* increase user search limit to 1000

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Update General.tsx

* Update General.tsx

---------

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

* fix all pinned message show edited

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

* access notification from window variable

* fix Notification check

* catch notification variable error

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

* account settings - WIP

* add missing key prop

* add object url hook

* extract wide modal styles

* profile settings and image editor - WIP

* add outline style to upload card

* remove file param from bind upload atom hook

* add compact variant to upload card

* add  compact upload card renderer

* add option to update profile avatar

* add option to change profile displayname

* allow displayname change based on capabilities check

* rearrange settings components into folders

* add system notification settings

* add initial page param in settings

* convert account data hook to typescript

* add push rule hook

* add notification mode hook

* add notification mode switcher component

* add all messages notification settings options

* add special messages notification settings

* add keyword notifications

* add ignored users section

* improve ignore user list strings

* add about settings

* add access token option in about settings

* add developer tools settings

* add expand button to account data dev tool option

* update folds

* fix editable active element textarea check

* do not close dialog when editable element in focus

* add text area plugins

* add text area intent handler hook

* add newline intent mod in text area

* add next line hotkey in text area intent hook

* add syntax error position dom utility function

* add account data editor

* add button to send new account data in dev tools

* improve custom emoji plugin

* add more custom emojis hooks

* add text util css

* add word break in setting tile title and description

* emojis and sticker user settings - WIP

* view image packs from settings

* emoji pack editing - WIP

* add option to edit pack meta

* change saved changes message

* add image edit and delete controls

* add option to upload pack images and apply changes

* fix state event type when updating image pack

* lazy load pack image tile img

* hide upload image button when user can not edit pack

* add option to add or remove global image packs

* upgrade to rust crypto (#2168)

* update matrix js sdk

* remove dead code

* use rust crypto

* update setPowerLevel usage

* fix types

* fix deprecated isRoomEncrypted method uses

* fix deprecated room.currentState uses

* fix deprecated import/export room keys func

* fix merge issues in image pack file

* fix remaining issues in image pack file

* start indexedDBStore

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

* user session settings - WIP

* add useAsync hook

* add password stage uia

* add uia flow matrix error hook

* add UIA action component

* add options to delete sessions

* add sso uia stage

* fix SSO stage complete error

* encryption - WIP

* update user settings encryption terminology

* add default variant to password input

* use password input in uia password stage

* add options for local backup in user settings

* remove typo in import local backup password input label

* online backup - WIP

* fix uia sso action

* move access token settings from about to developer tools

* merge encryption tab into sessions and rename it to devices

* add device placeholder tile

* add logout dialog

* add logout button for current device

* move other devices in component

* render unverified device verification tile

* add learn more section for current device verification

* add device verification status badge

* add info card component

* add index file for password input component

* add types for secret storage

* add component to access secret storage key

* manual verification - WIP

* update matrix-js-sdk to v35

* add manual verification

* use react query for device list

* show unverified tab on sidebar

* fix device list updates

* add session key details to current device

* render restore encryption backup

* fix loading state of restore backup

* fix unverified tab settings closes after verification

* key backup tile - WIP

* fix unverified tab badge

* rename session key to device key in device tile

* improve backup restore functionality

* fix restore button enabled after layout reload during restoring backup

* update backup info on status change

* add backup disconnection failures

* add device verification using sas

* restore backup after verification

* show option to logout on startup error screen

* fix key backup hook update on decryption key cached

* add option to enable device verification

* add device verification reset dialog

* add logout button in settings drawer

* add encrypted message lost on logout

* fix backup restore never finish with 0 keys

* fix setup dialog hides when enabling device verification

* show backup details in menu

* update setup device verification body copy

* replace deprecated method

* fix displayname appear as mxid in settings

* remove old refactored codes

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

* add more checks around membership change

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

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

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

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

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

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

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

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

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

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

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

* Handle sending captions for images

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

* Remove unused renderBody() parameter

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

* Add caption rendering for generic files

+ Fix video and audio not properly sending captions

* Use m.text for captions & render on demand

* Allow custom HTML in sending captions

* Don't *send* captions

* mvoe content const into renderCaption()

---------

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

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

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

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

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

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

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

* feat: sort autocomplete emojis

Fixes #1632

* fix: sort function after concat

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

* room pinned message - WIP

* add room event hook

* fetch pinned messages before displaying

* use react-query in room event hook

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

* use room event hook in reply component

* render pinned messages

* add option to pin/unpin messages

* remove message base from message placeholders and add variant

* display message placeholder while loading pinned messages

* render pinned event error

* show no pinned message placeholder

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-03 16:59:12 +10:00
Ajay Bura
5824d7c716 fix font-weight in dark theme with unsupported fonts (#1964) 2024-09-22 22:31:32 +10:00
Krishan
6e191d3c79 Fix matrix.to links opening in webview in cinny desktop (#1963) 2024-09-22 10:08:55 +05:30
Krishan
21164a9b61 Release v4.2.1 (#1953) 2024-09-14 23:24:34 +10:00
Krishan
4923b17ad6 Fix auth media check for dendrite (#1952) 2024-09-14 18:54:06 +05:30
Krishan
c75e903619 Release v4.2.0 (#1949) 2024-09-11 19:26:08 +05:30
renovate[bot]
042cbc4453 Update dependency matrix-js-sdk to v34.5.0 (#1945)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-11 17:08:38 +10:00
Ajay Bura
03cc25eec0 Fix authenticated media download (#1947)
* remove dead function

* fix media download in room timeline

* authenticate remaining media endpoints
2024-09-11 17:07:02 +10:00
夜坂雅
f2c31d29a2 fix: Fix video and audio loading with authenicated media (#1946)
Appeareantly Firefox (and maybe Chrome) won't let service workers take over requests from <video> and <audio> tags, so we just fetch the URL ourselves.
2024-09-11 10:43:15 +05:30
Ajay Bura
5482f8e72e render matrix room and event link content (#1938) 2024-09-09 20:51:52 +10:00
Ajay Bura
96df140153 Improve-auth-media (#1933)
* fix set power level broken after sdk update

* add media authentication hook

* fix service worker types

* fix service worker not working in dev mode

* fix env mode check when registering sw
2024-09-09 14:15:20 +05:30
Ajay Bura
4dfce32730 fix mention url is encoded wrong (#1936) 2024-09-08 22:53:59 +10:00
Ajay Bura
388f606ad2 fix escape to mark as read (#1935) 2024-09-08 22:53:17 +10:00
Ajay Bura
09444f9e08 fix sso login without identity providers (#1934) 2024-09-08 22:51:43 +10:00
夜坂雅
c6a8fb1117 Add authenticated media support (#1930)
* chore: Bump matrix-js-sdk to 34.4.0

* feat: Authenticated media support

* chore: Use Vite PWA for service worker support

* fix: Fix Vite PWA SW entry point

Forget this. :P

* fix: Also add Nginx rewrite for sw.js

* fix: Correct Nginx rewrite

* fix: Add Netlify redirect for sw.js

Otherwise the generic SPA rewrite to index.html would take effect, breaking Service Worker.

* fix: Account for subpath when regisering service worker

* chore: Correct types
2024-09-07 19:15:55 +05:30
Dylan Hackworth
043012e809 pressing up to edit should take you to end of line (#1928) 2024-09-07 18:38:16 +05:30
utf
5c9ee1a988 Fix IPv6 support for the Docker container (#1884)
* Fix `docker-nginx.conf` indentation

* Listen on IPv4 and IPv6 inside Docker
2024-08-23 20:56:03 +10:00
Krishan
22b7f6dd7d Create Code of Conduct (#1908) 2024-08-21 15:43:40 +05:30
dependabot[bot]
bdba0332e1 Bump cla-assistant/github-action from 2.4.0 to 2.5.1 (#1905)
Bumps [cla-assistant/github-action](https://github.com/cla-assistant/github-action) from 2.4.0 to 2.5.1.
- [Release notes](https://github.com/cla-assistant/github-action/releases)
- [Commits](https://github.com/cla-assistant/github-action/compare/v2.4.0...v2.5.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-21 00:22:26 +10:00
dependabot[bot]
16be69c104 Bump docker/build-push-action from 6.6.1 to 6.7.0 (#1906)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.6.1 to 6.7.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.6.1...v6.7.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-21 00:21:25 +10:00
greentore
830d05e217 Add basic m.thread support (#1349)
* Add basic `m.thread` support

* Fix types

* Update to v4

* Fix auto formatting mess

* Add threaded reply indicators

* Fix reply overflow

* Fix replying to edited threaded replies

* Add thread indicator to room input

* Fix editing encrypted events

* Use `toRem` function for converting units

---------

Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
2024-08-15 20:22:32 +05:30
dependabot[bot]
7e7bee8f48 Bump actions/upload-artifact from 4.3.4 to 4.3.6 (#1890)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.3.4 to 4.3.6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.3.4...v4.3.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-14 23:38:35 +10:00
aceArt-GmbH
ac1797344c Add translation support using i18next (#1576)
Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
2024-08-14 18:59:34 +05:30
dependabot[bot]
b4ce8a7cab Bump docker/build-push-action from 6.5.0 to 6.6.1 (#1891)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.5.0 to 6.6.1.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.5.0...v6.6.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-14 23:21:11 +10:00
Krishan
e68c56b334 Release v4.1.0 (#1867) 2024-08-04 20:15:10 +10:00
Ajay Bura
cabfdd47b5 fix type to focus not working after room switch (#1866) 2024-08-04 16:04:11 +10:00
dependabot[bot]
cfe893f358 Bump docker/setup-buildx-action from 3.5.0 to 3.6.1 (#1850)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.5.0 to 3.6.1.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.5.0...v3.6.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-04 15:38:47 +10:00
Ajay Bura
581211f13e fix crash when decoding malformed urls (#1865) 2024-08-04 15:38:20 +10:00
Ajay Bura
8ed78d48fb fix notification not working for selected room (#1864) 2024-08-04 15:37:28 +10:00
Ajay Bura
96222de5bc fix page up/down button not working (#1863) 2024-08-04 15:36:42 +10:00
Ajay Bura
681287c46a show unverified tab indicator on sidebar (#1862) 2024-08-04 14:19:37 +10:00
Ajay Bura
9cb5c70d51 add back btn for mobile view (#1861) 2024-08-03 23:47:53 +10:00
Krishan
c62050445b Fix typo in readme 2024-08-01 23:45:22 +10:00
Krishan
a8f5a6c2f4 update self deploy instructions after react router (#1859)
* update self deploy instructions after react router

* List the alternative

* docs to deploy on subdir
2024-08-01 19:12:45 +05:30
Ajay Bura
e54bb2e423 fix tombstone replacement room open previous room (#1856) 2024-07-30 22:19:51 +10:00
Ajay Bura
5058136737 support matrix.to links (#1849)
* support room via server params and eventId

* change copy link to matrix.to links

* display matrix.to links in messages as pill and stop generating url previews for them

* improve editor mention to include viaServers and eventId

* fix mention custom attributes

* always try to open room in current space

* jump to latest remove target eventId from url

* add create direct search options to open/create dm with url
2024-07-30 22:18:59 +10:00
Ajay Bura
74dc76e22e fix room opens at home after leave rejoin (#1848) 2024-07-28 23:40:21 +10:00
551 changed files with 38134 additions and 13860 deletions

View File

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

13
.github/renovate.json vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

17
.github/workflows/pr-title.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Check PR title
on:
pull_request_target:
types:
- opened
- reopened
- edited
- synchronize
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.2.0
- name: Setup node - name: Setup node
uses: actions/setup-node@v4.0.3 uses: actions/setup-node@v4.4.0
with: with:
node-version: 20.12.2 node-version: 20.12.2
cache: 'npm' cache: 'npm'
@@ -52,7 +52,7 @@ jobs:
gpg --export | xxd -p gpg --export | xxd -p
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
- name: Upload tagged release - name: Upload tagged release
uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191 uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8
with: with:
files: | files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz cinny-${{ steps.vars.outputs.tag }}.tar.gz
@@ -66,31 +66,31 @@ jobs:
packages: write packages: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.2.0
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0 uses: docker/setup-qemu-action@v3.6.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.5.0 uses: docker/setup-buildx-action@v3.10.0
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3.3.0 uses: docker/login-action@v3.5.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 Container registry
uses: docker/login-action@v3.3.0 uses: docker/login-action@v3.5.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
id: meta id: meta
uses: docker/metadata-action@v5.5.1 uses: docker/metadata-action@v5.8.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.5.0 uses: docker/build-push-action@v6.18.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

1
.npmrc
View File

@@ -1,3 +1,2 @@
legacy-peer-deps=true legacy-peer-deps=true
save-exact=true save-exact=true
@matrix-org:registry=https://gitlab.matrix.org/api/v4/projects/27/packages/npm/

128
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
cinnyapp@gmail.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View File

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

View File

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

6
contrib/caddy/caddyfile Normal file
View File

@@ -0,0 +1,6 @@
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
cinny.domain.tld {
root * /path/to/cinny/dist
try_files {path} / index.html
file_server
}

View File

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

View File

@@ -1,16 +1,19 @@
server { server {
location / { listen 80;
root /usr/share/nginx/html; listen [::]:80;
rewrite ^/config.json$ /config.json break; location / {
root /usr/share/nginx/html;
rewrite ^/config.json$ /config.json break;
rewrite ^/manifest.json$ /manifest.json break; rewrite ^/manifest.json$ /manifest.json break;
rewrite ^.*/olm.wasm$ /olm.wasm break; rewrite ^/sw.js$ /sw.js break;
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break; rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
rewrite ^/public/(.*)$ /public/$1 break; rewrite ^/public/(.*)$ /public/$1 break;
rewrite ^/assets/(.*)$ /assets/$1 break; rewrite ^/assets/(.*)$ /assets/$1 break;
rewrite ^(.+)$ /index.html break; rewrite ^(.+)$ /index.html break;
} }
} }

View File

@@ -9,10 +9,10 @@
status = 200 status = 200
[[redirects]] [[redirects]]
from = "*/olm.wasm" from = "/sw.js"
to = "/olm.wasm" to = "/sw.js"
status = 200 status = 200
force = true
[[redirects]] [[redirects]]
from = "/pdf.worker.min.js" from = "/pdf.worker.min.js"

8146
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "cinny", "name": "cinny",
"version": "4.0.3", "version": "4.9.0",
"description": "Yet another matrix client", "description": "Yet another matrix client",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -24,7 +24,6 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14", "@fontsource/inter": "4.5.14",
"@matrix-org/olm": "3.2.14",
"@tanstack/react-query": "5.24.1", "@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0", "@tanstack/react-virtual": "3.2.0",
@@ -33,45 +32,53 @@
"@vanilla-extract/recipes": "0.3.0", "@vanilla-extract/recipes": "0.3.0",
"@vanilla-extract/vite-plugin": "3.7.1", "@vanilla-extract/vite-plugin": "3.7.1",
"await-to-js": "3.0.0", "await-to-js": "3.0.0",
"badwords-list": "2.0.1-4",
"blurhash": "2.0.4", "blurhash": "2.0.4",
"browser-encrypt-attachment": "0.3.0", "browser-encrypt-attachment": "0.3.0",
"chroma-js": "3.1.2",
"classnames": "2.3.2", "classnames": "2.3.2",
"dateformat": "5.0.3", "dateformat": "5.0.3",
"dayjs": "1.11.10", "dayjs": "1.11.10",
"domhandler": "5.0.3", "domhandler": "5.0.3",
"emojibase": "6.1.0", "emojibase": "15.3.1",
"emojibase-data": "7.0.1", "emojibase-data": "15.3.2",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"flux": "4.0.3", "flux": "4.0.3",
"focus-trap-react": "10.0.2", "focus-trap-react": "10.0.2",
"folds": "2.0.0", "folds": "2.2.0",
"formik": "2.4.6", "formik": "2.4.6",
"html-dom-parser": "4.0.0", "html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0", "html-react-parser": "4.2.0",
"i18next": "23.12.2",
"i18next-browser-languagedetector": "8.0.0",
"i18next-http-backend": "2.5.2",
"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.1.3",
"linkifyjs": "4.1.3", "linkifyjs": "4.1.3",
"matrix-js-sdk": "29.1.0", "matrix-js-sdk": "37.5.0",
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "4.2.67", "pdfjs-dist": "4.2.67",
"prismjs": "1.29.0", "prismjs": "1.30.0",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"react": "18.2.0", "react": "18.2.0",
"react-aria": "3.29.1", "react-aria": "3.29.1",
"react-autosize-textarea": "7.1.0", "react-autosize-textarea": "7.1.0",
"react-blurhash": "0.2.0", "react-blurhash": "0.2.0",
"react-colorful": "5.6.1",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-error-boundary": "4.0.13", "react-error-boundary": "4.0.13",
"react-google-recaptcha": "2.1.0", "react-google-recaptcha": "2.1.0",
"react-i18next": "15.0.0",
"react-modal": "3.16.1", "react-modal": "3.16.1",
"react-range": "1.8.14", "react-range": "1.8.14",
"react-router-dom": "6.20.0", "react-router-dom": "6.20.0",
"sanitize-html": "2.12.1", "sanitize-html": "2.12.1",
"slate": "0.94.1", "slate": "0.112.0",
"slate-history": "0.93.0", "slate-dom": "0.112.2",
"slate-react": "0.98.4", "slate-history": "0.110.3",
"slate-react": "0.112.1",
"tippy.js": "6.3.7", "tippy.js": "6.3.7",
"ua-parser-js": "1.0.35" "ua-parser-js": "1.0.35"
}, },
@@ -79,7 +86,9 @@
"@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",
"@types/chroma-js": "3.1.1",
"@types/file-saver": "2.0.5", "@types/file-saver": "2.0.5",
"@types/is-hotkey": "0.1.10",
"@types/node": "18.11.18", "@types/node": "18.11.18",
"@types/prismjs": "1.26.0", "@types/prismjs": "1.26.0",
"@types/react": "18.2.39", "@types/react": "18.2.39",
@@ -101,8 +110,9 @@
"prettier": "2.8.1", "prettier": "2.8.1",
"sass": "1.56.2", "sass": "1.56.2",
"typescript": "4.9.4", "typescript": "4.9.4",
"vite": "5.0.13", "vite": "5.4.19",
"vite-plugin-pwa": "0.20.5",
"vite-plugin-static-copy": "1.0.4", "vite-plugin-static-copy": "1.0.4",
"vite-plugin-top-level-await": "1.4.1" "vite-plugin-top-level-await": "1.4.4"
} }
} }

Binary file not shown.

Binary file not shown.

7
public/locales/de.json Normal file
View File

@@ -0,0 +1,7 @@
{
"Organisms": {
"RoomCommon": {
"changed_room_name": " hat den Raum Name geändert"
}
}
}

7
public/locales/en.json Normal file
View File

@@ -0,0 +1,7 @@
{
"Organisms": {
"RoomCommon": {
"changed_room_name": " changed room name"
}
}
}

View File

@@ -4,10 +4,25 @@ import PropTypes from 'prop-types';
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { isInSameDay } from '../../../util/common'; import { isInSameDay } from '../../../util/common';
function Time({ timestamp, fullTime }) { /**
* Renders a formatted timestamp.
*
* Displays the time in hour:minute format if the message is from today or yesterday, unless `fullTime` is true.
* For older messages, it shows the date and time.
*
* @param {number} timestamp - The timestamp to display.
* @param {boolean} [fullTime=false] - If true, always show the full date and time.
* @param {boolean} hour24Clock - Whether to use 24-hour time format.
* @param {string} dateFormatString - Format string for the date part.
* @returns {JSX.Element} A <time> element with the formatted date/time.
*/
function Time({ timestamp, fullTime, hour24Clock, dateFormatString }) {
const date = new Date(timestamp); const date = new Date(timestamp);
const formattedFullTime = dateFormat(date, 'dd mmmm yyyy, hh:MM TT'); const formattedFullTime = dateFormat(
date,
hour24Clock ? 'dd mmmm yyyy, HH:MM' : 'dd mmmm yyyy, hh:MM TT'
);
let formattedDate = formattedFullTime; let formattedDate = formattedFullTime;
if (!fullTime) { if (!fullTime) {
@@ -16,17 +31,19 @@ function Time({ timestamp, fullTime }) {
compareDate.setDate(compareDate.getDate() - 1); compareDate.setDate(compareDate.getDate() - 1);
const isYesterday = isInSameDay(date, compareDate); const isYesterday = isInSameDay(date, compareDate);
formattedDate = dateFormat(date, isToday || isYesterday ? 'hh:MM TT' : 'dd/mm/yyyy'); const timeFormat = hour24Clock ? 'HH:MM' : 'hh:MM TT';
formattedDate = dateFormat(
date,
isToday || isYesterday ? timeFormat : dateFormatString.toLowerCase()
);
if (isYesterday) { if (isYesterday) {
formattedDate = `Yesterday, ${formattedDate}`; formattedDate = `Yesterday, ${formattedDate}`;
} }
} }
return ( return (
<time <time dateTime={date.toISOString()} title={formattedFullTime}>
dateTime={date.toISOString()}
title={formattedFullTime}
>
{formattedDate} {formattedDate}
</time> </time>
); );
@@ -39,6 +56,8 @@ Time.defaultProps = {
Time.propTypes = { Time.propTypes = {
timestamp: PropTypes.number.isRequired, timestamp: PropTypes.number.isRequired,
fullTime: PropTypes.bool, fullTime: PropTypes.bool,
hour24Clock: PropTypes.bool.isRequired,
dateFormatString: PropTypes.string.isRequired,
}; };
export default Time; export default Time;

View File

@@ -0,0 +1,322 @@
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Text,
Icon,
Icons,
IconButton,
Input,
Button,
TextArea as TextAreaComponent,
color,
Spinner,
Chip,
Scroll,
config,
} from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { Cursor } from '../plugins/text-area';
import { syntaxErrorPosition } from '../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { Page, PageHeader } from './page';
import { useAlive } from '../hooks/useAlive';
import { SequenceCard } from './sequence-card';
import { TextViewerContent } from './text-viewer';
import { useTextAreaCodeEditor } from '../hooks/useTextAreaCodeEditor';
const EDITOR_INTENT_SPACE_COUNT = 2;
export type AccountDataSubmitCallback = (type: string, content: object) => Promise<void>;
type AccountDataInfo = {
type: string;
content: object;
};
type AccountDataEditProps = {
type: string;
defaultContent: string;
submitChange: AccountDataSubmitCallback;
onCancel: () => void;
onSave: (info: AccountDataInfo) => void;
};
function AccountDataEdit({
type,
defaultContent,
submitChange,
onCancel,
onSave,
}: AccountDataEditProps) {
const alive = useAlive();
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const [jsonError, setJSONError] = useState<SyntaxError>();
const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
textAreaRef,
EDITOR_INTENT_SPACE_COUNT
);
const [submitState, submit] = useAsyncCallback<void, MatrixError, [string, object]>(submitChange);
const submitting = submitState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (submitting) return;
const target = evt.target as HTMLFormElement | undefined;
const typeInput = target?.typeInput as HTMLInputElement | undefined;
const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
if (!typeInput || !contentTextArea) return;
const typeStr = typeInput.value.trim();
const contentStr = contentTextArea.value.trim();
let parsedContent: object;
try {
parsedContent = JSON.parse(contentStr);
} catch (e) {
setJSONError(e as SyntaxError);
return;
}
setJSONError(undefined);
if (
!typeStr ||
parsedContent === null ||
defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
) {
return;
}
submit(typeStr, parsedContent).then(() => {
if (alive()) {
onSave({
type: typeStr,
content: parsedContent,
});
}
});
};
useEffect(() => {
if (jsonError) {
const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
const cursor = new Cursor(errorPosition, errorPosition, 'none');
operations.select(cursor);
getTarget()?.focus();
}
}, [jsonError, operations, getTarget]);
return (
<Box
as="form"
onSubmit={handleSubmit}
grow="Yes"
style={{
padding: config.space.S400,
}}
direction="Column"
gap="400"
aria-disabled={submitting}
>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Account Data</Text>
<Box gap="300">
<Box grow="Yes" direction="Column">
<Input
variant={type.length > 0 || submitting ? 'SurfaceVariant' : 'Background'}
name="typeInput"
size="400"
radii="300"
readOnly={type.length > 0 || submitting}
defaultValue={type}
required
/>
</Box>
<Button
variant="Success"
size="400"
radii="300"
type="submit"
disabled={submitting}
before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
>
<Text size="B400">Save</Text>
</Button>
<Button
variant="Secondary"
fill="Soft"
size="400"
radii="300"
type="button"
onClick={onCancel}
disabled={submitting}
>
<Text size="B400">Cancel</Text>
</Button>
</Box>
{submitState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{submitState.error.message}</b>
</Text>
)}
</Box>
<Box grow="Yes" direction="Column" gap="100">
<Box shrink="No">
<Text size="L400">JSON Content</Text>
</Box>
<TextAreaComponent
ref={textAreaRef}
name="contentTextArea"
style={{
fontFamily: 'monospace',
}}
onKeyDown={handleKeyDown}
defaultValue={defaultContent}
resize="None"
spellCheck="false"
required
readOnly={submitting}
/>
{jsonError && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>
{jsonError.name}: {jsonError.message}
</b>
</Text>
)}
</Box>
</Box>
);
}
type AccountDataViewProps = {
type: string;
defaultContent: string;
onEdit: () => void;
};
function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
return (
<Box
direction="Column"
style={{
padding: config.space.S400,
}}
gap="400"
>
<Box shrink="No" gap="300" alignItems="End">
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Account Data</Text>
<Input
variant="SurfaceVariant"
size="400"
radii="300"
readOnly
defaultValue={type}
required
/>
</Box>
<Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
<Text size="B400">Edit</Text>
</Button>
</Box>
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">JSON Content</Text>
<SequenceCard variant="SurfaceVariant">
<Scroll visibility="Always" size="300" hideTrack>
<TextViewerContent
size="T300"
style={{
padding: `${config.space.S300} ${config.space.S100} ${config.space.S300} ${config.space.S300}`,
}}
text={defaultContent}
langName="JSON"
/>
</Scroll>
</SequenceCard>
</Box>
</Box>
);
}
export type AccountDataEditorProps = {
type?: string;
content?: object;
submitChange: AccountDataSubmitCallback;
requestClose: () => void;
};
export function AccountDataEditor({
type,
content,
submitChange,
requestClose,
}: AccountDataEditorProps) {
const [data, setData] = useState<AccountDataInfo>({
type: type ?? '',
content: content ?? {},
});
const [edit, setEdit] = useState(!type);
const closeEdit = useCallback(() => {
if (!type) {
requestClose();
return;
}
setEdit(false);
}, [type, requestClose]);
const handleSave = useCallback((info: AccountDataInfo) => {
setData(info);
setEdit(false);
}, []);
const contentJSONStr = useMemo(
() => JSON.stringify(data.content, null, EDITOR_INTENT_SPACE_COUNT),
[data.content]
);
return (
<Page>
<PageHeader outlined={false} balance>
<Box alignItems="Center" grow="Yes" gap="200">
<Box alignItems="Inherit" grow="Yes" gap="200">
<Chip
size="500"
radii="Pill"
onClick={requestClose}
before={<Icon size="100" src={Icons.ArrowLeft} />}
>
<Text size="T300">Developer Tools</Text>
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes" direction="Column">
{edit ? (
<AccountDataEdit
type={data.type}
defaultContent={contentJSONStr}
submitChange={submitChange}
onCancel={closeEdit}
onSave={handleSave}
/>
) : (
<AccountDataView
type={data.type}
defaultContent={contentJSONStr}
onEdit={() => setEdit(true)}
/>
)}
</Box>
</Page>
);
}

View File

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

View File

@@ -0,0 +1,86 @@
import { ReactNode, useCallback } from 'react';
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
import {
getDirectPath,
getExplorePath,
getHomePath,
getInboxPath,
getSpacePath,
} from '../pages/pathUtils';
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
type BackRouteHandlerProps = {
children: (onBack: () => void) => ReactNode;
};
export function BackRouteHandler({ children }: BackRouteHandlerProps) {
const navigate = useNavigate();
const location = useLocation();
const goBack = useCallback(() => {
if (
matchPath(
{
path: HOME_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getHomePath());
return;
}
if (
matchPath(
{
path: DIRECT_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getDirectPath());
return;
}
const spaceMatch = matchPath(
{
path: SPACE_PATH,
caseSensitive: true,
end: false,
},
location.pathname
);
if (spaceMatch?.params.spaceIdOrAlias) {
navigate(getSpacePath(spaceMatch.params.spaceIdOrAlias));
return;
}
if (
matchPath(
{
path: EXPLORE_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getExplorePath());
return;
}
if (
matchPath(
{
path: INBOX_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getInboxPath());
}
}, [navigate, location]);
return children(goBack);
}

View File

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

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { TooltipProvider, Tooltip, Box, Text, Badge, toRem } from 'folds';
export function BetaNoticeBadge() {
return (
<TooltipProvider
position="Right"
align="Center"
tooltip={
<Tooltip style={{ maxWidth: toRem(200) }}>
<Box direction="Column">
<Text size="L400">Notice</Text>
<Text size="T200">This feature is under testing and may change over time.</Text>
</Box>
</Tooltip>
}
>
{(triggerRef) => (
<Badge size="500" tabIndex={0} ref={triggerRef} variant="Primary" fill="Solid">
<Text size="L400">Beta</Text>
</Badge>
)}
</TooltipProvider>
);
}

View File

@@ -1,36 +0,0 @@
import { ReactNode, useCallback, useEffect } from 'react';
import { Capabilities } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { MediaConfig } from '../hooks/useMediaConfig';
import { promiseFulfilledResult } from '../utils/common';
type CapabilitiesAndMediaConfigLoaderProps = {
children: (capabilities?: Capabilities, mediaConfig?: MediaConfig) => ReactNode;
};
export function CapabilitiesAndMediaConfigLoader({
children,
}: CapabilitiesAndMediaConfigLoaderProps) {
const mx = useMatrixClient();
const [state, load] = useAsyncCallback<
[Capabilities | undefined, MediaConfig | undefined],
unknown,
[]
>(
useCallback(async () => {
const result = await Promise.allSettled([mx.getCapabilities(true), mx.getMediaConfig()]);
const capabilities = promiseFulfilledResult(result[0]);
const mediaConfig = promiseFulfilledResult(result[1]);
return [capabilities, mediaConfig];
}, [mx])
);
useEffect(() => {
load();
}, [load]);
const [capabilities, mediaConfig] =
state.status === AsyncStatus.Success ? state.data : [undefined, undefined];
return children(capabilities, mediaConfig);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,59 @@
import FocusTrap from 'focus-trap-react';
import { Box, Button, config, Menu, PopOut, RectCords, Text } from 'folds';
import React, { MouseEventHandler, ReactNode, useState } from 'react';
import { stopPropagation } from '../utils/keyboard';
type HexColorPickerPopOutProps = {
children: (onOpen: MouseEventHandler<HTMLElement>, opened: boolean) => ReactNode;
picker: ReactNode;
onRemove?: () => void;
};
export function HexColorPickerPopOut({ picker, onRemove, children }: HexColorPickerPopOutProps) {
const [cords, setCords] = useState<RectCords>();
const handleOpen: MouseEventHandler<HTMLElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
return (
<PopOut
anchor={cords}
position="Bottom"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu
style={{
padding: config.space.S100,
borderRadius: config.radii.R500,
overflow: 'initial',
}}
>
<Box direction="Column" gap="200">
{picker}
{onRemove && (
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="400"
onClick={() => onRemove()}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
</Menu>
</FocusTrap>
}
>
{children(handleOpen, !!cords)}
</PopOut>
);
}

View File

@@ -0,0 +1,155 @@
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
import {
config,
Box,
MenuItem,
Text,
Icon,
Icons,
IconSrc,
RectCords,
PopOut,
Menu,
Button,
Spinner,
} from 'folds';
import { JoinRule } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';
export type ExtraJoinRules = 'knock_restricted';
export type ExtendedJoinRules = JoinRule | ExtraJoinRules;
type JoinRuleIcons = Record<ExtendedJoinRules, IconSrc>;
export const useRoomJoinRuleIcon = (): JoinRuleIcons =>
useMemo(
() => ({
[JoinRule.Invite]: Icons.HashLock,
[JoinRule.Knock]: Icons.HashLock,
knock_restricted: Icons.Hash,
[JoinRule.Restricted]: Icons.Hash,
[JoinRule.Public]: Icons.HashGlobe,
[JoinRule.Private]: Icons.HashLock,
}),
[]
);
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>;
export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
useMemo(
() => ({
[JoinRule.Invite]: 'Invite Only',
[JoinRule.Knock]: 'Knock & Invite',
knock_restricted: 'Space Members or Knock',
[JoinRule.Restricted]: 'Space Members',
[JoinRule.Public]: 'Public',
[JoinRule.Private]: 'Invite Only',
}),
[]
);
type JoinRulesSwitcherProps<T extends ExtendedJoinRules[]> = {
icons: JoinRuleIcons;
labels: JoinRuleLabels;
rules: T;
value: T[number];
onChange: (value: T[number]) => void;
disabled?: boolean;
changing?: boolean;
};
export function JoinRulesSwitcher<T extends ExtendedJoinRules[]>({
icons,
labels,
rules,
value,
onChange,
disabled,
changing,
}: JoinRulesSwitcherProps<T>) {
const [cords, setCords] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleChange = useCallback(
(selectedRule: ExtendedJoinRules) => {
setCords(undefined);
onChange(selectedRule);
},
[onChange]
);
return (
<PopOut
anchor={cords}
position="Bottom"
align="End"
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" gap="100" style={{ padding: config.space.S100 }}>
{rules.map((rule) => (
<MenuItem
key={rule}
size="300"
variant="Surface"
radii="300"
aria-pressed={value === rule}
onClick={() => handleChange(rule)}
before={<Icon size="100" src={icons[rule]} />}
disabled={disabled}
>
<Box grow="Yes">
<Text size="T300">{labels[rule]}</Text>
</Box>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
before={<Icon size="100" src={icons[value] ?? icons[JoinRule.Restricted]} />}
after={
changing ? (
<Spinner size="100" variant="Secondary" fill="Soft" />
) : (
<Icon size="100" src={Icons.ChevronBottom} />
)
}
onClick={handleOpenMenu}
disabled={disabled}
>
<Text size="B300">{labels[value] ?? 'Unsupported'}</Text>
</Button>
</PopOut>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
import FocusTrap from 'focus-trap-react';
import React from 'react';
import { config, Menu, MenuItem, Text } from 'folds';
import { stopPropagation } from '../utils/keyboard';
import { useMemberSortMenu } from '../hooks/useMemberSort';
type MemberSortMenuProps = {
requestClose: () => void;
selected: number;
onSelect: (index: number) => void;
};
export function MemberSortMenu({ selected, onSelect, requestClose }: MemberSortMenuProps) {
const memberSortMenu = useMemberSortMenu();
return (
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: requestClose,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ padding: config.space.S100 }}>
{memberSortMenu.map((menuItem, index) => (
<MenuItem
key={menuItem.name}
variant="Surface"
aria-pressed={selected === index}
size="300"
radii="300"
onClick={() => {
onSelect(index);
requestClose();
}}
>
<Text size="T300">{menuItem.name}</Text>
</MenuItem>
))}
</Menu>
</FocusTrap>
);
}

View File

@@ -0,0 +1,49 @@
import FocusTrap from 'focus-trap-react';
import React from 'react';
import { config, Menu, MenuItem, Text } from 'folds';
import { stopPropagation } from '../utils/keyboard';
import { useMembershipFilterMenu } from '../hooks/useMemberFilter';
type MembershipFilterMenuProps = {
requestClose: () => void;
selected: number;
onSelect: (index: number) => void;
};
export function MembershipFilterMenu({
selected,
onSelect,
requestClose,
}: MembershipFilterMenuProps) {
const membershipFilterMenu = useMembershipFilterMenu();
return (
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: requestClose,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ padding: config.space.S100 }}>
{membershipFilterMenu.map((menuItem, index) => (
<MenuItem
key={menuItem.name}
variant="Surface"
aria-pressed={selected === index}
size="300"
radii="300"
onClick={() => {
onSelect(index);
requestClose();
}}
>
<Text size="T300">{menuItem.name}</Text>
</MenuItem>
))}
</Menu>
</FocusTrap>
);
}

View File

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

View File

@@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import { MsgType } from 'matrix-js-sdk'; import { MsgType } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser'; import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { config } from 'folds';
import { import {
AudioContent, AudioContent,
DownloadFile, DownloadFile,
@@ -27,6 +29,8 @@ import { Image, MediaControl, Video } from './media';
import { ImageViewer } from './image-viewer'; import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer'; import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer'; import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to';
import { IImageContent } from '../../types/matrix/common';
type RenderMessageContentProps = { type RenderMessageContentProps = {
displayName: string; displayName: string;
@@ -38,6 +42,7 @@ type RenderMessageContentProps = {
urlPreview?: boolean; urlPreview?: boolean;
highlightRegex?: RegExp; highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions; htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
outlineAttachment?: boolean; outlineAttachment?: boolean;
}; };
export function RenderMessageContent({ export function RenderMessageContent({
@@ -50,39 +55,77 @@ export function RenderMessageContent({
urlPreview, urlPreview,
highlightRegex, highlightRegex,
htmlReactParserOptions, htmlReactParserOptions,
linkifyOpts,
outlineAttachment, outlineAttachment,
}: RenderMessageContentProps) { }: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => {
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
if (filteredUrls.length === 0) return undefined;
return (
<UrlPreviewHolder>
{filteredUrls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={ts} />
))}
</UrlPreviewHolder>
);
};
const renderCaption = () => {
const content: IImageContent = getContent();
if (content.filename && content.filename !== content.body) {
return (
<MText
style={{ marginTop: config.space.S200 }}
edited={edited}
content={content}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/>
);
}
return null;
};
const renderFile = () => ( const renderFile = () => (
<MFile <>
content={getContent()} <MFile
renderFileContent={({ body, mimeType, info, encInfo, url }) => ( content={getContent()}
<FileContent renderFileContent={({ body, mimeType, info, encInfo, url }) => (
body={body} <FileContent
mimeType={mimeType} body={body}
renderAsPdfFile={() => ( mimeType={mimeType}
<ReadPdfFile renderAsPdfFile={() => (
body={body} <ReadPdfFile
mimeType={mimeType} body={body}
url={url} mimeType={mimeType}
encInfo={encInfo} url={url}
renderViewer={(p) => <PdfViewer {...p} />} encInfo={encInfo}
/> renderViewer={(p) => <PdfViewer {...p} />}
)} />
renderAsTextFile={() => ( )}
<ReadTextFile renderAsTextFile={() => (
body={body} <ReadTextFile
mimeType={mimeType} body={body}
url={url} mimeType={mimeType}
encInfo={encInfo} url={url}
renderViewer={(p) => <TextViewer {...p} />} encInfo={encInfo}
/> renderViewer={(p) => <TextViewer {...p} />}
)} />
> )}
<DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} /> >
</FileContent> <DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} />
)} </FileContent>
outlined={outlineAttachment} )}
/> outlined={outlineAttachment}
/>
{renderCaption()}
</>
); );
if (msgType === MsgType.Text) { if (msgType === MsgType.Text) {
@@ -95,19 +138,10 @@ export function RenderMessageContent({
{...props} {...props}
highlightRegex={highlightRegex} highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/> />
)} )}
renderUrlsPreview={ renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
urlPreview
? (urls) => (
<UrlPreviewHolder>
{urls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={ts} />
))}
</UrlPreviewHolder>
)
: undefined
}
/> />
); );
} }
@@ -123,19 +157,10 @@ export function RenderMessageContent({
{...props} {...props}
highlightRegex={highlightRegex} highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/> />
)} )}
renderUrlsPreview={ renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
urlPreview
? (urls) => (
<UrlPreviewHolder>
{urls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={ts} />
))}
</UrlPreviewHolder>
)
: undefined
}
/> />
); );
} }
@@ -150,82 +175,82 @@ export function RenderMessageContent({
{...props} {...props}
highlightRegex={highlightRegex} highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions} htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/> />
)} )}
renderUrlsPreview={ renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
urlPreview
? (urls) => (
<UrlPreviewHolder>
{urls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={ts} />
))}
</UrlPreviewHolder>
)
: undefined
}
/> />
); );
} }
if (msgType === MsgType.Image) { if (msgType === MsgType.Image) {
return ( return (
<MImage <>
content={getContent()} <MImage
renderImageContent={(props) => ( content={getContent()}
<ImageContent renderImageContent={(props) => (
{...props} <ImageContent
autoPlay={mediaAutoLoad} {...props}
renderImage={(p) => <Image {...p} loading="lazy" />} autoPlay={mediaAutoLoad}
renderViewer={(p) => <ImageViewer {...p} />} renderImage={(p) => <Image {...p} loading="lazy" />}
/> renderViewer={(p) => <ImageViewer {...p} />}
)} />
outlined={outlineAttachment} )}
/> outlined={outlineAttachment}
/>
{renderCaption()}
</>
); );
} }
if (msgType === MsgType.Video) { if (msgType === MsgType.Video) {
return ( return (
<MVideo <>
content={getContent()} <MVideo
renderAsFile={renderFile} content={getContent()}
renderVideoContent={({ body, info, mimeType, url, encInfo }) => ( renderAsFile={renderFile}
<VideoContent renderVideoContent={({ body, info, mimeType, url, encInfo }) => (
body={body} <VideoContent
info={info} body={body}
mimeType={mimeType} info={info}
url={url} mimeType={mimeType}
encInfo={encInfo} url={url}
renderThumbnail={ encInfo={encInfo}
mediaAutoLoad renderThumbnail={
? () => ( mediaAutoLoad
<ThumbnailContent ? () => (
info={info} <ThumbnailContent
renderImage={(src) => ( info={info}
<Image alt={body} title={body} src={src} loading="lazy" /> renderImage={(src) => (
)} <Image alt={body} title={body} src={src} loading="lazy" />
/> )}
) />
: undefined )
} : undefined
renderVideo={(p) => <Video {...p} />} }
/> renderVideo={(p) => <Video {...p} />}
)} />
outlined={outlineAttachment} )}
/> outlined={outlineAttachment}
/>
{renderCaption()}
</>
); );
} }
if (msgType === MsgType.Audio) { if (msgType === MsgType.Audio) {
return ( return (
<MAudio <>
content={getContent()} <MAudio
renderAsFile={renderFile} content={getContent()}
renderAudioContent={(props) => ( renderAsFile={renderFile}
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} /> renderAudioContent={(props) => (
)} <AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
outlined={outlineAttachment} )}
/> outlined={outlineAttachment}
/>
{renderCaption()}
</>
); );
} }

View File

@@ -0,0 +1,120 @@
import { Box, config, Icon, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import React, { MouseEventHandler, ReactNode, useMemo, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';
import {
getRoomNotificationModeIcon,
RoomNotificationMode,
useSetRoomNotificationPreference,
} from '../hooks/useRoomsNotificationPreferences';
import { AsyncStatus } from '../hooks/useAsyncCallback';
const useRoomNotificationModes = (): RoomNotificationMode[] =>
useMemo(
() => [
RoomNotificationMode.Unset,
RoomNotificationMode.AllMessages,
RoomNotificationMode.SpecialMessages,
RoomNotificationMode.Mute,
],
[]
);
const useRoomNotificationModeStr = (): Record<RoomNotificationMode, string> =>
useMemo(
() => ({
[RoomNotificationMode.Unset]: 'Default',
[RoomNotificationMode.AllMessages]: 'All Messages',
[RoomNotificationMode.SpecialMessages]: 'Mention & Keywords',
[RoomNotificationMode.Mute]: 'Mute',
}),
[]
);
type NotificationModeSwitcherProps = {
roomId: string;
value?: RoomNotificationMode;
children: (
handleOpen: MouseEventHandler<HTMLButtonElement>,
opened: boolean,
changing: boolean
) => ReactNode;
};
export function RoomNotificationModeSwitcher({
roomId,
value = RoomNotificationMode.Unset,
children,
}: NotificationModeSwitcherProps) {
const modes = useRoomNotificationModes();
const modeToStr = useRoomNotificationModeStr();
const { modeState, setMode } = useSetRoomNotificationPreference(roomId);
const changing = modeState.status === AsyncStatus.Loading;
const [menuCords, setMenuCords] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleClose = () => {
setMenuCords(undefined);
};
const handleSelect = (mode: RoomNotificationMode) => {
if (changing) return;
setMode(mode, value);
handleClose();
};
return (
<PopOut
anchor={menuCords}
offset={5}
position="Right"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{modes.map((mode) => (
<MenuItem
key={mode}
size="300"
variant="Surface"
aria-pressed={mode === value}
radii="300"
disabled={changing}
onClick={() => handleSelect(mode)}
before={
<Icon
size="100"
src={getRoomNotificationModeIcon(mode)}
filled={mode === value}
/>
}
>
<Text size="T300">
{mode === value ? <b>{modeToStr[mode]}</b> : modeToStr[mode]}
</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
>
{children(handleOpenMenu, !!menuCords, changing)}
</PopOut>
);
}

View File

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

View File

@@ -0,0 +1,52 @@
import { ReactNode, useCallback, useMemo } from 'react';
import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { MediaConfig } from '../hooks/useMediaConfig';
import { promiseFulfilledResult } from '../utils/common';
export type ServerConfigs = {
capabilities?: Capabilities;
mediaConfig?: MediaConfig;
authMetadata?: ValidatedAuthMetadata;
};
type ServerConfigsLoaderProps = {
children: (configs: ServerConfigs) => ReactNode;
};
export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
const mx = useMatrixClient();
const fallbackConfigs = useMemo(() => ({}), []);
const [configsState] = useAsyncCallbackValue<ServerConfigs, unknown>(
useCallback(async () => {
const result = await Promise.allSettled([
mx.getCapabilities(),
mx.getMediaConfig(),
mx.getAuthMetadata(),
]);
const capabilities = promiseFulfilledResult(result[0]);
const mediaConfig = promiseFulfilledResult(result[1]);
const authMetadata = promiseFulfilledResult(result[2]);
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
try {
validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) {
console.error(e);
}
return {
capabilities,
mediaConfig,
authMetadata: validatedAuthMetadata,
};
}, [mx])
);
const configs: ServerConfigs =
configsState.status === AsyncStatus.Success ? configsState.data : fallbackConfigs;
return children(configs);
}

View File

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

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { Menu, PopOut, toRem } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile';
import { UserRoomProfile } from './user-profile';
import { UserRoomProfileState } from '../state/userRoomProfile';
import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
import { stopPropagation } from '../utils/keyboard';
import { SpaceProvider } from '../hooks/useSpace';
import { RoomProvider } from '../hooks/useRoom';
function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) {
const { roomId, spaceId, userId, cords, position } = state;
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
const room = getRoom(roomId);
const space = spaceId ? getRoom(spaceId) : undefined;
const close = useCloseUserRoomProfile();
if (!room) return null;
return (
<PopOut
anchor={cords}
position={position ?? 'Top'}
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ width: toRem(340) }}>
<SpaceProvider value={space ?? null}>
<RoomProvider value={room}>
<UserRoomProfile userId={userId} />
</RoomProvider>
</SpaceProvider>
</Menu>
</FocusTrap>
}
/>
);
}
export function UserRoomProfileRenderer() {
const state = useUserRoomProfileState();
if (!state) return null;
return <UserRoomProfileContextMenu state={state} />;
}

View File

@@ -0,0 +1,306 @@
import {
Box,
Button,
Chip,
config,
Icon,
Icons,
Input,
Line,
Menu,
MenuItem,
PopOut,
RectCords,
Scroll,
Text,
toRem,
} from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import FocusTrap from 'focus-trap-react';
import React, {
ChangeEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useMemo,
useState,
} from 'react';
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix';
import { useDirectUsers } from '../../hooks/useDirectUsers';
import { SettingTile } from '../setting-tile';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
import { findAndReplace } from '../../utils/findAndReplace';
import { highlightText } from '../../styles/CustomHtml.css';
import { makeHighlightRegex } from '../../plugins/react-custom-html-parser';
export const useAdditionalCreators = (defaultCreators?: string[]) => {
const mx = useMatrixClient();
const [additionalCreators, setAdditionalCreators] = useState<string[]>(
() => defaultCreators?.filter((id) => id !== mx.getSafeUserId()) ?? []
);
const addAdditionalCreator = (userId: string) => {
if (userId === mx.getSafeUserId()) return;
setAdditionalCreators((creators) => {
const creatorsSet = new Set(creators);
creatorsSet.add(userId);
return Array.from(creatorsSet);
});
};
const removeAdditionalCreator = (userId: string) => {
setAdditionalCreators((creators) => {
const creatorsSet = new Set(creators);
creatorsSet.delete(userId);
return Array.from(creatorsSet);
});
};
return {
additionalCreators,
addAdditionalCreator,
removeAdditionalCreator,
};
};
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
matchOptions: {
contain: true,
},
};
const getUserIdString = (userId: string) => getMxIdLocalPart(userId) ?? userId;
type AdditionalCreatorInputProps = {
additionalCreators: string[];
onSelect: (userId: string) => void;
onRemove: (userId: string) => void;
disabled?: boolean;
};
export function AdditionalCreatorInput({
additionalCreators,
onSelect,
onRemove,
disabled,
}: AdditionalCreatorInputProps) {
const mx = useMatrixClient();
const [menuCords, setMenuCords] = useState<RectCords>();
const directUsers = useDirectUsers();
const [validUserId, setValidUserId] = useState<string>();
const filteredUsers = useMemo(
() => directUsers.filter((userId) => !additionalCreators.includes(userId)),
[directUsers, additionalCreators]
);
const [result, search, resetSearch] = useAsyncSearch(
filteredUsers,
getUserIdString,
SEARCH_OPTIONS
);
const queryHighlighRegex = result?.query ? makeHighlightRegex([result.query]) : undefined;
const suggestionUsers = result
? result.items
: filteredUsers.sort((a, b) => (a.toLocaleLowerCase() >= b.toLocaleLowerCase() ? 1 : -1));
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleCloseMenu = () => {
setMenuCords(undefined);
setValidUserId(undefined);
resetSearch();
};
const handleCreatorChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const creatorInput = evt.currentTarget;
const creator = creatorInput.value.trim();
if (isUserId(creator)) {
setValidUserId(creator);
} else {
setValidUserId(undefined);
const term =
getMxIdLocalPart(creator) ?? (creator.startsWith('@') ? creator.slice(1) : creator);
if (term) {
search(term);
} else {
resetSearch();
}
}
};
const handleSelectUserId = (userId?: string) => {
if (userId && isUserId(userId)) {
onSelect(userId);
handleCloseMenu();
}
};
const handleCreatorKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (isKeyHotkey('enter', evt)) {
evt.preventDefault();
const creator = evt.currentTarget.value.trim();
handleSelectUserId(isUserId(creator) ? creator : suggestionUsers[0]);
}
};
const handleEnterClick = () => {
handleSelectUserId(validUserId);
};
return (
<SettingTile
title="Founders"
description="Special privileged users can be assigned during creation. These users have elevated control and can only be modified during a upgrade."
>
<Box shrink="No" direction="Column" gap="100">
<Box gap="200" wrap="Wrap">
<Chip type="button" variant="Primary" radii="Pill" outlined>
<Text size="B300">{mx.getSafeUserId()}</Text>
</Chip>
{additionalCreators.map((creator) => (
<Chip
type="button"
key={creator}
variant="Secondary"
radii="Pill"
after={<Icon size="50" src={Icons.Cross} />}
onClick={() => onRemove(creator)}
disabled={disabled}
>
<Text size="B300">{creator}</Text>
</Chip>
))}
<PopOut
anchor={menuCords}
position="Bottom"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
onDeactivate: handleCloseMenu,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu
style={{
width: '100vw',
maxWidth: toRem(300),
height: toRem(250),
display: 'flex',
}}
>
<Box grow="Yes" direction="Column">
<Box shrink="No" gap="100" style={{ padding: config.space.S100 }}>
<Box grow="Yes" direction="Column" gap="100">
<Input
size="400"
variant="Background"
radii="300"
outlined
placeholder="@john:server"
onChange={handleCreatorChange}
onKeyDown={handleCreatorKeyDown}
/>
</Box>
<Button
type="button"
variant="Success"
radii="300"
onClick={handleEnterClick}
disabled={!validUserId}
>
<Text size="B400">Enter</Text>
</Button>
</Box>
<Line size="300" />
<Box grow="Yes" direction="Column">
{!validUserId && suggestionUsers.length > 0 ? (
<Scroll size="300" hideTrack>
<Box
grow="Yes"
direction="Column"
gap="100"
style={{ padding: config.space.S200, paddingRight: 0 }}
>
{suggestionUsers.map((userId) => (
<MenuItem
key={userId}
size="300"
variant="Surface"
radii="300"
onClick={() => handleSelectUserId(userId)}
after={
<Text size="T200" truncate>
{getMxIdServer(userId)}
</Text>
}
>
<Box grow="Yes">
<Text size="T200" truncate>
<b>
{queryHighlighRegex
? findAndReplace(
getMxIdLocalPart(userId) ?? userId,
queryHighlighRegex,
(match, pushIndex) => (
<span
key={`highlight-${pushIndex}`}
className={highlightText}
>
{match[0]}
</span>
),
(txt) => txt
)
: getMxIdLocalPart(userId)}
</b>
</Text>
</Box>
</MenuItem>
))}
</Box>
</Scroll>
) : (
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
>
<Text size="H6" align="Center">
No Suggestions
</Text>
<Text size="T200" align="Center">
Please provide the user ID and hit Enter.
</Text>
</Box>
)}
</Box>
</Box>
</Menu>
</FocusTrap>
}
>
<Chip
type="button"
variant="Secondary"
radii="Pill"
onClick={handleOpenMenu}
aria-pressed={!!menuCords}
disabled={disabled}
>
<Icon size="50" src={Icons.Plus} />
</Chip>
</PopOut>
</Box>
</Box>
</SettingTile>
);
}

View File

@@ -0,0 +1,118 @@
import React, {
FormEventHandler,
KeyboardEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { MatrixError } from 'matrix-js-sdk';
import { Box, color, Icon, Icons, Input, Spinner, Text, toRem } from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import { getMxIdServer } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { replaceSpaceWithDash } from '../../utils/common';
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
import { useDebounce } from '../../hooks/useDebounce';
export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
const mx = useMatrixClient();
const aliasInputRef = useRef<HTMLInputElement>(null);
const [aliasAvail, setAliasAvail] = useState<AsyncState<boolean, Error>>({
status: AsyncStatus.Idle,
});
useEffect(() => {
if (aliasAvail.status === AsyncStatus.Success && aliasInputRef.current?.value === '') {
setAliasAvail({ status: AsyncStatus.Idle });
}
}, [aliasAvail]);
const checkAliasAvail = useAsync(
useCallback(
async (aliasLocalPart: string) => {
const roomAlias = `#${aliasLocalPart}:${getMxIdServer(mx.getSafeUserId())}`;
try {
const result = await mx.getRoomIdForAlias(roomAlias);
return typeof result.room_id !== 'string';
} catch (e) {
if (e instanceof MatrixError && e.httpStatus === 404) {
return true;
}
throw e;
}
},
[mx]
),
setAliasAvail
);
const aliasAvailable: boolean | undefined =
aliasAvail.status === AsyncStatus.Success ? aliasAvail.data : undefined;
const debounceCheckAliasAvail = useDebounce(checkAliasAvail, { wait: 500 });
const handleAliasChange: FormEventHandler<HTMLInputElement> = (evt) => {
const aliasInput = evt.currentTarget;
const aliasLocalPart = replaceSpaceWithDash(aliasInput.value);
if (aliasLocalPart) {
aliasInput.value = aliasLocalPart;
debounceCheckAliasAvail(aliasLocalPart);
} else {
setAliasAvail({ status: AsyncStatus.Idle });
}
};
const handleAliasKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (isKeyHotkey('enter', evt)) {
evt.preventDefault();
const aliasInput = evt.currentTarget;
const aliasLocalPart = replaceSpaceWithDash(aliasInput.value);
if (aliasLocalPart) {
checkAliasAvail(aliasLocalPart);
} else {
setAliasAvail({ status: AsyncStatus.Idle });
}
}
};
return (
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Address (Optional)</Text>
<Text size="T200" priority="300">
Pick an unique address to make it discoverable.
</Text>
<Input
ref={aliasInputRef}
onChange={handleAliasChange}
before={
aliasAvail.status === AsyncStatus.Loading ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="100" src={Icons.Hash} />
)
}
after={
<Text style={{ maxWidth: toRem(150) }} truncate>
:{getMxIdServer(mx.getSafeUserId())}
</Text>
}
onKeyDown={handleAliasKeyDown}
name="aliasInput"
size="500"
variant={aliasAvailable === true ? 'Success' : 'SurfaceVariant'}
radii="400"
autoComplete="off"
disabled={disabled}
/>
{aliasAvailable === false && (
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="100">
<Icon src={Icons.Warning} filled size="50" />
<Text size="T200">
<b>This address is already taken. Please select a different one.</b>
</Text>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
import { SequenceCard } from '../sequence-card';
import { SettingTile } from '../setting-tile';
export enum CreateRoomKind {
Private = 'private',
Restricted = 'restricted',
Public = 'public',
}
type CreateRoomKindSelectorProps = {
value?: CreateRoomKind;
onSelect: (value: CreateRoomKind) => void;
canRestrict?: boolean;
disabled?: boolean;
getIcon: (kind: CreateRoomKind) => IconSrc;
};
export function CreateRoomKindSelector({
value,
onSelect,
canRestrict,
disabled,
getIcon,
}: CreateRoomKindSelectorProps) {
return (
<Box shrink="No" direction="Column" gap="100">
{canRestrict && (
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomKind.Restricted ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomKind.Restricted}
onClick={() => onSelect(CreateRoomKind.Restricted)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomKind.Restricted)} />}
after={value === CreateRoomKind.Restricted && <Icon src={Icons.Check} />}
>
<Text size="H6">Restricted</Text>
<Text size="T300" priority="300">
Only member of parent space can join.
</Text>
</SettingTile>
</SequenceCard>
)}
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomKind.Private ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomKind.Private}
onClick={() => onSelect(CreateRoomKind.Private)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomKind.Private)} />}
after={value === CreateRoomKind.Private && <Icon src={Icons.Check} />}
>
<Text size="H6">Private</Text>
<Text size="T300" priority="300">
Only people with invite can join.
</Text>
</SettingTile>
</SequenceCard>
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomKind.Public ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomKind.Public}
onClick={() => onSelect(CreateRoomKind.Public)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomKind.Public)} />}
after={value === CreateRoomKind.Public && <Icon src={Icons.Check} />}
>
<Text size="H6">Public</Text>
<Text size="T300" priority="300">
Anyone with the address can join.
</Text>
</SettingTile>
</SequenceCard>
</Box>
);
}

View File

@@ -0,0 +1,117 @@
import React, { MouseEventHandler, useState } from 'react';
import {
Box,
Button,
Chip,
config,
Icon,
Icons,
Menu,
PopOut,
RectCords,
Text,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { SettingTile } from '../setting-tile';
import { SequenceCard } from '../sequence-card';
import { stopPropagation } from '../../utils/keyboard';
export function RoomVersionSelector({
versions,
value,
onChange,
disabled,
}: {
versions: string[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}) {
const [menuCords, setMenuCords] = useState<RectCords>();
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (version: string) => {
setMenuCords(undefined);
onChange(version);
};
return (
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<SettingTile
title="Version"
after={
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box
direction="Column"
gap="200"
style={{ padding: config.space.S200, maxWidth: toRem(300) }}
>
<Text size="L400">Versions</Text>
<Box wrap="Wrap" gap="100">
{versions.map((version) => (
<Chip
key={version}
variant={value === version ? 'Primary' : 'SurfaceVariant'}
aria-pressed={value === version}
outlined={value === version}
radii="300"
onClick={() => handleSelect(version)}
type="button"
>
<Text truncate size="T300">
{version}
</Text>
</Chip>
))}
</Box>
</Box>
</Menu>
</FocusTrap>
}
>
<Button
type="button"
onClick={handleMenu}
size="300"
variant="Secondary"
fill="Soft"
radii="300"
aria-pressed={!!menuCords}
before={<Icon size="50" src={menuCords ? Icons.ChevronTop : Icons.ChevronBottom} />}
disabled={disabled}
>
<Text size="B300">{value}</Text>
</Button>
</PopOut>
}
/>
</SequenceCard>
);
}

View File

@@ -0,0 +1,5 @@
export * from './CreateRoomKindSelector';
export * from './CreateRoomAliasInput';
export * from './RoomVersionSelector';
export * from './utils';
export * from './AdditionalCreatorInput';

View File

@@ -0,0 +1,140 @@
import {
ICreateRoomOpts,
ICreateRoomStateEvent,
JoinRule,
MatrixClient,
RestrictedAllowType,
Room,
} from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { CreateRoomKind } from './CreateRoomKindSelector';
import { RoomType, StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers';
import { getMxIdServer } from '../../utils/matrix';
export const createRoomCreationContent = (
type: RoomType | undefined,
allowFederation: boolean,
additionalCreators: string[] | undefined
): object => {
const content: Record<string, any> = {};
if (typeof type === 'string') {
content.type = type;
}
if (allowFederation === false) {
content['m.federate'] = false;
}
if (Array.isArray(additionalCreators)) {
content.additional_creators = additionalCreators;
}
return content;
};
export const createRoomJoinRulesState = (
kind: CreateRoomKind,
parent: Room | undefined,
knock: boolean
) => {
let content: RoomJoinRulesEventContent = {
join_rule: knock ? JoinRule.Knock : JoinRule.Invite,
};
if (kind === CreateRoomKind.Public) {
content = {
join_rule: JoinRule.Public,
};
}
if (kind === CreateRoomKind.Restricted && parent) {
content = {
join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted,
allow: [
{
type: RestrictedAllowType.RoomMembership,
room_id: parent.roomId,
},
],
};
}
return {
type: StateEvent.RoomJoinRules,
state_key: '',
content,
};
};
export const createRoomParentState = (parent: Room) => ({
type: StateEvent.SpaceParent,
state_key: parent.roomId,
content: {
canonical: true,
via: getViaServers(parent),
},
});
export const createRoomEncryptionState = () => ({
type: 'm.room.encryption',
state_key: '',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
},
});
export type CreateRoomData = {
version: string;
type?: RoomType;
parent?: Room;
kind: CreateRoomKind;
name: string;
topic?: string;
aliasLocalPart?: string;
encryption?: boolean;
knock: boolean;
allowFederation: boolean;
additionalCreators?: string[];
};
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
const initialState: ICreateRoomStateEvent[] = [];
if (data.encryption) {
initialState.push(createRoomEncryptionState());
}
if (data.parent) {
initialState.push(createRoomParentState(data.parent));
}
initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock));
const options: ICreateRoomOpts = {
room_version: data.version,
name: data.name,
topic: data.topic,
room_alias_name: data.aliasLocalPart,
creation_content: createRoomCreationContent(
data.type,
data.allowFederation,
data.additionalCreators
),
initial_state: initialState,
};
const result = await mx.createRoom(options);
if (data.parent) {
await mx.sendStateEvent(
data.parent.roomId,
StateEvent.SpaceChild as any,
{
auto_join: false,
suggested: false,
via: [getMxIdServer(mx.getUserId() ?? '') ?? ''],
},
result.room_id
);
}
return result.room_id;
};

View File

@@ -0,0 +1,8 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
export const CutoutCard = style({
borderRadius: config.radii.R300,
borderWidth: config.borderWidth.B300,
overflow: 'hidden',
});

View File

@@ -0,0 +1,15 @@
import { as, ContainerColor as TContainerColor } from 'folds';
import React from 'react';
import classNames from 'classnames';
import { ContainerColor } from '../../styles/ContainerColor.css';
import * as css from './CutoutCard.css';
export const CutoutCard = as<'div', { variant?: TContainerColor }>(
({ as: AsCutoutCard = 'div', className, variant = 'Surface', ...props }, ref) => (
<AsCutoutCard
className={classNames(ContainerColor({ variant }), css.CutoutCard, className)}
{...props}
ref={ref}
/>
)
);

View File

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

View File

@@ -41,21 +41,21 @@ export const EditorTextarea = style([
}, },
]); ]);
export const EditorPlaceholder = style([ export const EditorPlaceholderContainer = style([
DefaultReset, DefaultReset,
{ {
position: 'absolute',
zIndex: 1,
width: '100%',
opacity: config.opacity.Placeholder, opacity: config.opacity.Placeholder,
pointerEvents: 'none', pointerEvents: 'none',
userSelect: 'none', userSelect: 'none',
},
]);
selectors: { export const EditorPlaceholderTextVisual = style([
'&:not(:first-child)': { DefaultReset,
display: 'none', {
}, display: 'block',
}, paddingTop: toRem(13),
paddingLeft: toRem(1),
}, },
]); ]);

View File

@@ -106,22 +106,17 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
[editor, onKeyDown] [editor, onKeyDown]
); );
const renderPlaceholder = useCallback(({ attributes, children }: RenderPlaceholderProps) => { const renderPlaceholder = useCallback(
// drop style attribute as we use our custom placeholder css. ({ attributes, children }: RenderPlaceholderProps) => (
// eslint-disable-next-line @typescript-eslint/no-unused-vars <span {...attributes} className={css.EditorPlaceholderContainer}>
const { style, ...props } = attributes; {/* Inner component to style the actual text position and appearance */}
return ( <Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
<Text {children}
as="span" </Text>
{...props} </span>
className={css.EditorPlaceholder} ),
contentEditable={false} []
truncate );
>
{children}
</Text>
);
}, []);
return ( return (
<div className={css.Editor} ref={ref}> <div className={css.Editor} ref={ref}>

View File

@@ -13,6 +13,8 @@ import { CommandElement, EmoticonElement, LinkElement, MentionElement } from './
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getBeginCommand } from './utils'; import { getBeginCommand } from './utils';
import { BlockType } from './types'; import { BlockType } from './types';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
// Put this at the start and end of an inline component to work around this Chromium bug: // Put this at the start and end of an inline component to work around this Chromium bug:
// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 // https://bugs.chromium.org/p/chromium/issues/detail?id=1249405
@@ -76,6 +78,7 @@ function RenderEmoticonElement({
children, children,
}: { element: EmoticonElement } & RenderElementProps) { }: { element: EmoticonElement } & RenderElementProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const selected = useSelected(); const selected = useSelected();
const focused = useFocused(); const focused = useFocused();
@@ -90,7 +93,7 @@ function RenderEmoticonElement({
{element.key.startsWith('mxc://') ? ( {element.key.startsWith('mxc://') ? (
<img <img
className={css.EmoticonImg} className={css.EmoticonImg}
src={mx.mxcUrlToHttp(element.key) ?? element.key} src={mxcUrlToHttp(mx, element.key, useAuthentication) ?? element.key}
alt={element.shortcode} alt={element.shortcode}
/> />
) : ( ) : (
@@ -154,7 +157,7 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
<Text as="pre" className={css.CodeBlock} {...attributes}> <Text as="pre" className={css.CodeBlock} {...attributes}>
<Scroll <Scroll
direction="Horizontal" direction="Horizontal"
variant="Secondary" variant="SurfaceVariant"
size="300" size="300"
visibility="Hover" visibility="Hover"
hideTrack hideTrack

View File

@@ -257,7 +257,9 @@ export function Toolbar() {
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl'; const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
const disableInline = isBlockActive(editor, BlockType.CodeBlock); const disableInline = isBlockActive(editor, BlockType.CodeBlock);
const canEscape = isAnyMarkActive(editor) || !isBlockActive(editor, BlockType.Paragraph); const canEscape = isBlockActive(editor, BlockType.Paragraph)
? isAnyMarkActive(editor)
: ReactEditor.isFocused(editor);
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
return ( return (
@@ -337,7 +339,7 @@ export function Toolbar() {
<Box className={css.MarkdownBtnBox} shrink="No" grow="Yes" justifyContent="End"> <Box className={css.MarkdownBtnBox} shrink="No" grow="Yes" justifyContent="End">
<TooltipProvider <TooltipProvider
align="End" align="End"
tooltip={<BtnTooltip text="Toggle Markdown" />} tooltip={<BtnTooltip text={isMarkdown ? 'Disable Markdown' : 'Enable Markdown'} />}
delay={500} delay={500}
> >
{(triggerRef) => ( {(triggerRef) => (

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import { getDirectRoomAvatarUrl } from '../../../utils/room';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AutocompleteQuery } from './autocompleteQuery'; import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu'; import { AutocompleteMenu } from './AutocompleteMenu';
import { getMxIdServer, validMxId } from '../../../utils/matrix'; import { getMxIdServer, isRoomAlias } from '../../../utils/matrix';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch'; import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard'; import { onTabPress } from '../../../utils/keyboard';
import { useKeyDown } from '../../../hooks/useKeyDown'; import { useKeyDown } from '../../../hooks/useKeyDown';
@@ -17,11 +17,12 @@ import { mDirectAtom } from '../../../state/mDirectList';
import { allRoomsAtom } from '../../../state/room-list/roomList'; import { allRoomsAtom } from '../../../state/room-list/roomList';
import { factoryRoomIdByActivity } from '../../../utils/sort'; import { factoryRoomIdByActivity } from '../../../utils/sort';
import { RoomAvatar, RoomIcon } from '../../room-avatar'; import { RoomAvatar, RoomIcon } from '../../room-avatar';
import { getViaServers } from '../../../plugins/via-servers';
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void; type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
const roomAliasFromQueryText = (mx: MatrixClient, text: string) => const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
validMxId(`#${text}`) isRoomAlias(`#${text}`)
? `#${text}` ? `#${text}`
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; : `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
@@ -64,7 +65,6 @@ type RoomMentionAutocompleteProps = {
}; };
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 20,
matchOptions: { matchOptions: {
contain: true, contain: true,
}, },
@@ -96,7 +96,7 @@ export function RoomMentionAutocomplete({
SEARCH_OPTIONS SEARCH_OPTIONS
); );
const autoCompleteRoomIds = result ? result.items : allRooms.slice(0, 20); const autoCompleteRoomIds = result ? result.items.slice(0, 20) : allRooms.slice(0, 20);
useEffect(() => { useEffect(() => {
if (query.text) search(query.text); if (query.text) search(query.text);
@@ -104,10 +104,14 @@ export function RoomMentionAutocomplete({
}, [query.text, search, resetSearch]); }, [query.text, search, resetSearch]);
const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => { const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => {
const mentionRoom = mx.getRoom(roomAliasOrId);
const viaServers = mentionRoom ? getViaServers(mentionRoom) : undefined;
const mentionEl = createMentionElement( const mentionEl = createMentionElement(
roomAliasOrId, roomAliasOrId,
name.startsWith('#') ? name : `#${name}`, name.startsWith('#') ? name : `#${name}`,
roomId === roomAliasOrId || mx.getRoom(roomId)?.getCanonicalAlias() === roomAliasOrId roomId === roomAliasOrId || mx.getRoom(roomId)?.getCanonicalAlias() === roomAliasOrId,
undefined,
viaServers
); );
replaceWithElement(editor, query.range, mentionEl); replaceWithElement(editor, query.range, mentionEl);
moveCursor(editor, true); moveCursor(editor, true);

View File

@@ -15,14 +15,16 @@ import {
import { onTabPress } from '../../../utils/keyboard'; import { onTabPress } from '../../../utils/keyboard';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils'; import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { useKeyDown } from '../../../hooks/useKeyDown'; import { useKeyDown } from '../../../hooks/useKeyDown';
import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix'; import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room'; import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
import { UserAvatar } from '../../user-avatar'; import { UserAvatar } from '../../user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Membership } from '../../../../types/matrix/room';
type MentionAutoCompleteHandler = (userId: string, name: string) => void; type MentionAutoCompleteHandler = (userId: string, name: string) => void;
const userIdFromQueryText = (mx: MatrixClient, text: string) => const userIdFromQueryText = (mx: MatrixClient, text: string) =>
validMxId(`@${text}`) isUserId(`@${text}`)
? `@${text}` ? `@${text}`
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
@@ -66,8 +68,13 @@ type UserMentionAutocompleteProps = {
requestClose: () => void; requestClose: () => void;
}; };
const withAllowedMembership = (member: RoomMember): boolean =>
member.membership === Membership.Join ||
member.membership === Membership.Invite ||
member.membership === Membership.Knock;
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 20, limit: 1000,
matchOptions: { matchOptions: {
contain: true, contain: true,
}, },
@@ -84,12 +91,15 @@ export function UserMentionAutocomplete({
requestClose, requestClose,
}: UserMentionAutocompleteProps) { }: UserMentionAutocompleteProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const roomId: string = room.roomId!; const roomId: string = room.roomId!;
const roomAliasOrId = room.getCanonicalAlias() || roomId; const roomAliasOrId = room.getCanonicalAlias() || roomId;
const members = useRoomMembers(mx, roomId); const members = useRoomMembers(mx, roomId);
const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS); const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS);
const autoCompleteMembers = result ? result.items : members.slice(0, 20); const autoCompleteMembers = (result ? result.items.slice(0, 20) : members.slice(0, 20)).filter(
withAllowedMembership
);
useEffect(() => { useEffect(() => {
if (query.text) search(query.text); if (query.text) search(query.text);
@@ -143,7 +153,10 @@ export function UserMentionAutocomplete({
/> />
) : ( ) : (
autoCompleteMembers.map((roomMember) => { autoCompleteMembers.map((roomMember) => {
const avatarUrl = roomMember.getAvatarUrl(mx.baseUrl, 32, 32, 'crop', undefined, false); const avatarMxcUrl = roomMember.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication)
: undefined;
return ( return (
<MenuItem <MenuItem
key={roomMember.userId} key={roomMember.userId}

View File

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

View File

@@ -1,10 +1,17 @@
import { Descendant, Text } from 'slate'; import { Descendant, Editor, Text } from 'slate';
import { MatrixClient } from 'matrix-js-sdk';
import { sanitizeText } from '../../utils/sanitize'; import { sanitizeText } from '../../utils/sanitize';
import { BlockType } from './types'; import { BlockType } from './types';
import { CustomElement } from './slate'; import { CustomElement } from './slate';
import { parseBlockMD, parseInlineMD } from '../../plugins/markdown'; import {
parseBlockMD,
parseInlineMD,
unescapeMarkdownBlockSequences,
unescapeMarkdownInlineSequences,
} from '../../plugins/markdown';
import { findAndReplace } from '../../utils/findAndReplace'; import { findAndReplace } from '../../utils/findAndReplace';
import { sanitizeForRegex } from '../../utils/regex';
import { getCanonicalAliasOrRoomId, isUserId } from '../../utils/matrix';
export type OutputOptions = { export type OutputOptions = {
allowTextFormatting?: boolean; allowTextFormatting?: boolean;
@@ -18,7 +25,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
if (node.bold) string = `<strong>${string}</strong>`; if (node.bold) string = `<strong>${string}</strong>`;
if (node.italic) string = `<i>${string}</i>`; if (node.italic) string = `<i>${string}</i>`;
if (node.underline) string = `<u>${string}</u>`; if (node.underline) string = `<u>${string}</u>`;
if (node.strikeThrough) string = `<del>${string}</del>`; if (node.strikeThrough) string = `<s>${string}</s>`;
if (node.code) string = `<code>${string}</code>`; if (node.code) string = `<code>${string}</code>`;
if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`; if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
} }
@@ -51,10 +58,19 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
case BlockType.UnorderedList: case BlockType.UnorderedList:
return `<ul>${children}</ul>`; return `<ul>${children}</ul>`;
case BlockType.Mention: case BlockType.Mention: {
return `<a href="https://matrix.to/#/${encodeURIComponent(node.id)}">${sanitizeText( let fragment = node.id;
node.name
)}</a>`; if (node.eventId) {
fragment += `/${node.eventId}`;
}
if (node.viaServers && node.viaServers.length > 0) {
fragment += `?${node.viaServers.map((server) => `via=${server}`).join('&')}`;
}
const matrixTo = `https://matrix.to/#/${fragment}`;
return `<a href="${encodeURI(matrixTo)}">${sanitizeText(node.name)}</a>`;
}
case BlockType.Emoticon: case BlockType.Emoticon:
return node.key.startsWith('mxc://') return node.key.startsWith('mxc://')
? `<img data-mx-emoticon src="${node.key}" alt="${sanitizeText( ? `<img data-mx-emoticon src="${node.key}" alt="${sanitizeText(
@@ -62,7 +78,7 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
)}" title="${sanitizeText(node.shortcode)}" height="32" />` )}" title="${sanitizeText(node.shortcode)}" height="32" />`
: sanitizeText(node.key); : sanitizeText(node.key);
case BlockType.Link: case BlockType.Link:
return `<a href="${encodeURIComponent(node.href)}">${node.children}</a>`; return `<a href="${encodeURI(node.href)}">${node.children}</a>`;
case BlockType.Command: case BlockType.Command:
return `/${sanitizeText(node.command)}`; return `/${sanitizeText(node.command)}`;
default: default:
@@ -92,7 +108,8 @@ export const toMatrixCustomHTML = (
allowBlockMarkdown: false, allowBlockMarkdown: false,
}) })
.replace(/<br\/>$/, '\n') .replace(/<br\/>$/, '\n')
.replace(/^&gt;/, '>'); .replace(/^(\\*)&gt;/, '$1>');
markdownLines += line; markdownLines += line;
if (index === targetNodes.length - 1) { if (index === targetNodes.length - 1) {
return parseBlockMD(markdownLines, ignoreHTMLParseInlineMD); return parseBlockMD(markdownLines, ignoreHTMLParseInlineMD);
@@ -147,11 +164,14 @@ const elementToPlainText = (node: CustomElement, children: string): string => {
} }
}; };
export const toPlainText = (node: Descendant | Descendant[]): string => { export const toPlainText = (node: Descendant | Descendant[], isMarkdown: boolean): string => {
if (Array.isArray(node)) return node.map((n) => toPlainText(n)).join(''); if (Array.isArray(node)) return node.map((n) => toPlainText(n, isMarkdown)).join('');
if (Text.isText(node)) return node.text; if (Text.isText(node))
return isMarkdown
? unescapeMarkdownBlockSequences(node.text, unescapeMarkdownInlineSequences)
: node.text;
const children = node.children.map((n) => toPlainText(n)).join(''); const children = node.children.map((n) => toPlainText(n, isMarkdown)).join('');
return elementToPlainText(node, children); return elementToPlainText(node, children);
}; };
@@ -170,9 +190,42 @@ export const customHtmlEqualsPlainText = (customHtml: string, plain: string): bo
export const trimCustomHtml = (customHtml: string) => customHtml.replace(/<br\/>$/g, '').trim(); export const trimCustomHtml = (customHtml: string) => customHtml.replace(/<br\/>$/g, '').trim();
export const trimCommand = (cmdName: string, str: string) => { export const trimCommand = (cmdName: string, str: string) => {
const cmdRegX = new RegExp(`^(\\s+)?(\\/${cmdName})([^\\S\n]+)?`); const cmdRegX = new RegExp(`^(\\s+)?(\\/${sanitizeForRegex(cmdName)})([^\\S\n]+)?`);
const match = str.match(cmdRegX); const match = str.match(cmdRegX);
if (!match) return str; if (!match) return str;
return str.slice(match[0].length); return str.slice(match[0].length);
}; };
export type MentionsData = {
room: boolean;
users: Set<string>;
};
export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): MentionsData => {
const mentionData: MentionsData = {
room: false,
users: new Set(),
};
const parseMentions = (node: Descendant): void => {
if (Text.isText(node)) return;
if (node.type === BlockType.CodeBlock) return;
if (node.type === BlockType.Mention) {
if (node.id === getCanonicalAliasOrRoomId(mx, roomId)) {
mentionData.room = true;
}
if (isUserId(node.id) && node.id !== mx.getUserId()) {
mentionData.users.add(node.id);
}
return;
}
node.children.forEach(parseMentions);
};
editor.children.forEach(parseMentions);
return mentionData;
};

View File

@@ -29,6 +29,8 @@ export type LinkElement = {
export type MentionElement = { export type MentionElement = {
type: BlockType.Mention; type: BlockType.Mention;
id: string; id: string;
eventId?: string;
viaServers?: string[];
highlight: boolean; highlight: boolean;
name: string; name: string;
children: Text[]; children: Text[];

View File

@@ -158,10 +158,14 @@ export const resetEditorHistory = (editor: Editor) => {
export const createMentionElement = ( export const createMentionElement = (
id: string, id: string,
name: string, name: string,
highlight: boolean highlight: boolean,
eventId?: string,
viaServers?: string[]
): MentionElement => ({ ): MentionElement => ({
type: BlockType.Mention, type: BlockType.Mention,
id, id,
eventId,
viaServers,
highlight, highlight,
name, name,
children: [{ text: '' }], children: [{ text: '' }],

View File

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

View File

@@ -19,8 +19,11 @@ import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix'; import { getMxIdLocalPart } from '../../utils/matrix';
import * as css from './EventReaders.css'; import * as css from './EventReaders.css';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { openProfileViewer } from '../../../client/action/navigation';
import { UserAvatar } from '../user-avatar'; import { UserAvatar } from '../user-avatar';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { getMouseEventCords } from '../../utils/dom';
export type EventReadersProps = { export type EventReadersProps = {
room: Room; room: Room;
@@ -30,7 +33,10 @@ export type EventReadersProps = {
export const EventReaders = as<'div', EventReadersProps>( export const EventReaders = as<'div', EventReadersProps>(
({ className, room, eventId, requestClose, ...props }, ref) => { ({ className, room, eventId, requestClose, ...props }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const latestEventReaders = useRoomEventReaders(room, eventId); const latestEventReaders = useRoomEventReaders(room, eventId);
const openProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const getName = (userId: string) => const getName = (userId: string) =>
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
@@ -55,18 +61,32 @@ export const EventReaders = as<'div', EventReadersProps>(
<Box className={css.Content} direction="Column"> <Box className={css.Content} direction="Column">
{latestEventReaders.map((readerId) => { {latestEventReaders.map((readerId) => {
const name = getName(readerId); const name = getName(readerId);
const avatarUrl = room const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl();
.getMember(readerId) const avatarUrl = avatarMxcUrl
?.getAvatarUrl(mx.baseUrl, 100, 100, 'crop', undefined, false); ? mx.mxcUrlToHttp(
avatarMxcUrl,
100,
100,
'crop',
undefined,
false,
useAuthentication
)
: undefined;
return ( return (
<MenuItem <MenuItem
key={readerId} key={readerId}
style={{ padding: `0 ${config.space.S200}` }} style={{ padding: `0 ${config.space.S200}` }}
radii="400" radii="400"
onClick={() => { onClick={(event) => {
requestClose(); openProfile(
openProfileViewer(readerId, room.roomId); room.roomId,
space?.roomId,
readerId,
getMouseEventCords(event.nativeEvent),
'Bottom'
);
}} }}
before={ before={
<Avatar size="200"> <Avatar size="200">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import * as css from './ImageViewer.css'; import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom'; import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan'; import { usePan } from '../../hooks/usePan';
import { downloadMedia } from '../../utils/matrix';
export type ImageViewerProps = { export type ImageViewerProps = {
alt: string; alt: string;
@@ -18,8 +19,9 @@ export const ImageViewer = as<'div', ImageViewerProps>(
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2); const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1); const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const handleDownload = () => { const handleDownload = async () => {
FileSaver.saveAs(src, alt); const fileContent = await downloadMedia(src);
FileSaver.saveAs(fileContent, alt);
}; };
return ( return (

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,131 @@
import React, { FormEventHandler, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Dialog,
Overlay,
OverlayCenter,
OverlayBackdrop,
Header,
config,
Box,
Text,
IconButton,
Icon,
Icons,
Button,
Input,
color,
} from 'folds';
import { stopPropagation } from '../../utils/keyboard';
import { isRoomAlias, isRoomId } from '../../utils/matrix';
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';
import { tryDecodeURIComponent } from '../../utils/dom';
type JoinAddressProps = {
onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void;
onCancel: () => void;
};
export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
const [invalid, setInvalid] = useState(false);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
setInvalid(false);
const target = evt.target as HTMLFormElement | undefined;
const addressInput = target?.addressInput as HTMLInputElement | undefined;
const address = addressInput?.value.trim();
if (!address) return;
if (isRoomId(address) || isRoomAlias(address)) {
onOpen(address);
return;
}
if (testMatrixTo(address)) {
const decodedAddress = tryDecodeURIComponent(address);
const toRoom = parseMatrixToRoom(decodedAddress);
if (toRoom) {
onOpen(toRoom.roomIdOrAlias, toRoom.viaServers);
return;
}
const toEvent = parseMatrixToRoomEvent(decodedAddress);
if (toEvent) {
onOpen(toEvent.roomIdOrAlias, toEvent.viaServers, toEvent.eventId);
return;
}
}
setInvalid(true);
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onCancel,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Join with Address</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
as="form"
onSubmit={handleSubmit}
style={{ padding: config.space.S400, paddingTop: 0 }}
direction="Column"
gap="400"
>
<Box direction="Column" gap="200">
<Text priority="400" size="T300">
Enter public address to join the community. Addresses looks like:
</Text>
<Text as="ul" size="T200" priority="300" style={{ paddingLeft: config.space.S400 }}>
<li>#community:server</li>
<li>https://matrix.to/#/#community:server</li>
<li>https://matrix.to/#/!xYzAj?via=server</li>
</Text>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Address</Text>
<Input
size="500"
autoFocus
name="addressInput"
variant="Background"
placeholder="#community:server"
required
/>
{invalid && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>Invalid Address</b>
</Text>
)}
</Box>
<Button type="submit" variant="Primary">
<Text size="B400">Open</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View File

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

View File

@@ -0,0 +1,53 @@
import React, { ReactNode } from 'react';
import { as, Avatar, Box, Icon, Icons, Text } from 'folds';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { UserAvatar } from '../user-avatar';
import * as css from './style.css';
const getName = (room: Room, member: RoomMember) =>
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
type MemberTileProps = {
mx: MatrixClient;
room: Room;
member: RoomMember;
useAuthentication: boolean;
after?: ReactNode;
};
export const MemberTile = as<'button', MemberTileProps>(
({ as: AsMemberTile = 'button', mx, room, member, useAuthentication, after, ...props }, ref) => {
const name = getName(room, member);
const username = getMxIdLocalPart(member.userId);
const avatarMxcUrl = member.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication)
: undefined;
return (
<AsMemberTile className={css.MemberTile} {...props} ref={ref}>
<Avatar size="300" radii="400">
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="300" src={Icons.User} filled />}
/>
</Avatar>
<Box grow="Yes" as="span" direction="Column">
<Text as="span" size="T300" truncate>
<b>{name}</b>
</Text>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="100">
<Text as="span" size="T200" priority="300" truncate>
{username}
</Text>
</Box>
</Box>
{after}
</AsMemberTile>
);
}
);

View File

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

View File

@@ -0,0 +1,32 @@
import { style } from '@vanilla-extract/css';
import { color, config, DefaultReset, Disabled, FocusOutline } from 'folds';
export const MemberTile = style([
DefaultReset,
{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
padding: config.space.S100,
borderRadius: config.radii.R500,
selectors: {
'button&': {
cursor: 'pointer',
},
'&[aria-pressed=true]': {
backgroundColor: color.Surface.ContainerActive,
},
'button&:hover, &:focus-visible': {
backgroundColor: color.Surface.ContainerHover,
},
'button&:active': {
backgroundColor: color.Surface.ContainerActive,
},
},
},
FocusOutline,
Disabled,
]);

View File

@@ -1,22 +1,81 @@
import { Badge, Box, Text, as, toRem } from 'folds'; import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
import React from 'react'; import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver';
import { mimeTypeToExt } from '../../utils/mimeTypes'; import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../utils/matrix';
const badgeStyles = { maxWidth: toRem(100) }; const badgeStyles = { maxWidth: toRem(100) };
type FileDownloadButtonProps = {
filename: string;
url: string;
mimeType: string;
encInfo?: EncryptedAttachmentInfo;
};
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, filename);
return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, filename])
);
const downloading = downloadState.status === AsyncStatus.Loading;
const hasError = downloadState.status === AsyncStatus.Error;
return (
<IconButton
disabled={downloading}
onClick={download}
variant={hasError ? 'Critical' : 'SurfaceVariant'}
size="300"
radii="300"
>
{downloading ? (
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
) : (
<Icon size="100" src={Icons.Download} />
)}
</IconButton>
);
}
export type FileHeaderProps = { export type FileHeaderProps = {
body: string; body: string;
mimeType: string; mimeType: string;
after?: ReactNode;
}; };
export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, ...props }, ref) => ( export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, after, ...props }, ref) => (
<Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}> <Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}>
<Badge style={badgeStyles} variant="Secondary" radii="Pill"> <Box shrink="No">
<Text size="O400" truncate> <Badge style={badgeStyles} variant="Secondary" radii="Pill">
{mimeTypeToExt(mimeType)} <Text size="O400" truncate>
{mimeTypeToExt(mimeType)}
</Text>
</Badge>
</Box>
<Box grow="Yes">
<Text size="T300" truncate>
{body}
</Text> </Text>
</Badge> </Box>
<Text size="T300" truncate> {after}
{body}
</Text>
</Box> </Box>
)); ));

View File

@@ -1,4 +1,4 @@
import React, { ReactNode } from 'react'; import React, { CSSProperties, ReactNode } from 'react';
import { Box, Chip, Icon, Icons, Text, toRem } from 'folds'; import { Box, Chip, Icon, Icons, Text, toRem } from 'folds';
import { IContent } from 'matrix-js-sdk'; import { IContent } from 'matrix-js-sdk';
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex'; import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
@@ -22,11 +22,13 @@ import {
IThumbnailContent, IThumbnailContent,
IVideoContent, IVideoContent,
IVideoInfo, IVideoInfo,
MATRIX_SPOILER_PROPERTY_NAME,
MATRIX_SPOILER_REASON_PROPERTY_NAME,
} from '../../../types/matrix/common'; } from '../../../types/matrix/common';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes'; import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common'; import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment'; import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader } from './FileHeader'; import { FileHeader, FileDownloadButton } from './FileHeader';
export function MBadEncrypted() { export function MBadEncrypted() {
return ( return (
@@ -72,8 +74,9 @@ type MTextProps = {
content: Record<string, unknown>; content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode; renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode; renderUrlsPreview?: (urls: string[]) => ReactNode;
style?: CSSProperties;
}; };
export function MText({ edited, content, renderBody, renderUrlsPreview }: MTextProps) { export function MText({ edited, content, renderBody, renderUrlsPreview, style }: MTextProps) {
const { body, formatted_body: customBody } = content; const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />; if (typeof body !== 'string') return <BrokenContent />;
@@ -86,6 +89,7 @@ export function MText({ edited, content, renderBody, renderUrlsPreview }: MTextP
<MessageTextBody <MessageTextBody
preWrap={typeof customBody !== 'string'} preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)} jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
style={style}
> >
{renderBody({ {renderBody({
body: trimmedBody, body: trimmedBody,
@@ -172,10 +176,13 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
type RenderImageContentProps = { type RenderImageContentProps = {
body: string; body: string;
filename?: string;
info?: IImageInfo & IThumbnailContent; info?: IImageInfo & IThumbnailContent;
mimeType?: string; mimeType?: string;
url: string; url: string;
encInfo?: IEncryptedFile; encInfo?: IEncryptedFile;
markedAsSpoiler?: boolean;
spoilerReason?: string;
}; };
type MImageProps = { type MImageProps = {
content: IImageContent; content: IImageContent;
@@ -203,6 +210,8 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
mimeType: imgInfo?.mimetype, mimeType: imgInfo?.mimetype,
url: mxcUrl, url: mxcUrl,
encInfo: content.file, encInfo: content.file,
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})} })}
</AttachmentBox> </AttachmentBox>
</Attachment> </Attachment>
@@ -236,8 +245,24 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400); const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
const filename = content.filename ?? content.body ?? 'Video';
return ( return (
<Attachment outlined={outlined}> <Attachment outlined={outlined}>
<AttachmentHeader>
<FileHeader
body={filename}
mimeType={safeMimeType}
after={
<FileDownloadButton
filename={filename}
url={mxcUrl}
mimeType={safeMimeType}
encInfo={content.file}
/>
}
/>
</AttachmentHeader>
<AttachmentBox <AttachmentBox
style={{ style={{
height: toRem(height < 48 ? 48 : height), height: toRem(height < 48 ? 48 : height),
@@ -279,10 +304,22 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
return <BrokenContent />; return <BrokenContent />;
} }
const filename = content.filename ?? content.body ?? 'Audio';
return ( return (
<Attachment outlined={outlined}> <Attachment outlined={outlined}>
<AttachmentHeader> <AttachmentHeader>
<FileHeader body={content.body ?? 'Audio'} mimeType={safeMimeType} /> <FileHeader
body={filename}
mimeType={safeMimeType}
after={
<FileDownloadButton
filename={filename}
url={mxcUrl}
mimeType={safeMimeType}
encInfo={content.file}
/>
}
/>
</AttachmentHeader> </AttachmentHeader>
<AttachmentBox> <AttachmentBox>
<AttachmentContent> <AttachmentContent>
@@ -322,14 +359,14 @@ export function MFile({ content, renderFileContent, outlined }: MFileProps) {
<Attachment outlined={outlined}> <Attachment outlined={outlined}>
<AttachmentHeader> <AttachmentHeader>
<FileHeader <FileHeader
body={content.body ?? 'Unnamed File'} body={content.filename ?? content.body ?? 'Unnamed File'}
mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE} mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
/> />
</AttachmentHeader> </AttachmentHeader>
<AttachmentBox> <AttachmentBox>
<AttachmentContent> <AttachmentContent>
{renderFileContent({ {renderFileContent({
body: content.body ?? 'File', body: content.filename ?? content.body ?? 'File',
info: fileInfo ?? {}, info: fileInfo ?? {},
mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE, mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE,
url: mxcUrl, url: mxcUrl,

View File

@@ -5,7 +5,7 @@ import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import * as css from './Reaction.css'; import * as css from './Reaction.css';
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji'; import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
import { getMemberDisplayName } from '../../utils/room'; import { getMemberDisplayName } from '../../utils/room';
import { eventWithShortcode, getMxIdLocalPart } from '../../utils/matrix'; import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
export const Reaction = as< export const Reaction = as<
'button', 'button',
@@ -13,8 +13,9 @@ export const Reaction = as<
mx: MatrixClient; mx: MatrixClient;
count: number; count: number;
reaction: string; reaction: string;
useAuthentication?: boolean;
} }
>(({ className, mx, count, reaction, ...props }, ref) => ( >(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => (
<Box <Box
as="button" as="button"
className={classNames(css.Reaction, className)} className={classNames(css.Reaction, className)}
@@ -28,7 +29,8 @@ export const Reaction = as<
{reaction.startsWith('mxc://') ? ( {reaction.startsWith('mxc://') ? (
<img <img
className={css.ReactionImg} className={css.ReactionImg}
src={mx.mxcUrlToHttp(reaction) ?? reaction} src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
}
alt={reaction} alt={reaction}
/> />
) : ( ) : (

View File

@@ -1,13 +1,10 @@
import React from 'react'; import React from 'react';
import parse, { HTMLReactParserOptions } from 'html-react-parser'; import parse, { HTMLReactParserOptions } from 'html-react-parser';
import Linkify from 'linkify-react'; import Linkify from 'linkify-react';
import { Opts } from 'linkifyjs';
import { MessageEmptyContent } from './content'; import { MessageEmptyContent } from './content';
import { sanitizeCustomHtml } from '../../utils/sanitize'; import { sanitizeCustomHtml } from '../../utils/sanitize';
import { import { highlightText, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
LINKIFY_OPTS,
highlightText,
scaleSystemEmoji,
} from '../../plugins/react-custom-html-parser';
type RenderBodyProps = { type RenderBodyProps = {
body: string; body: string;
@@ -15,12 +12,14 @@ type RenderBodyProps = {
highlightRegex?: RegExp; highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions; htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
}; };
export function RenderBody({ export function RenderBody({
body, body,
customBody, customBody,
highlightRegex, highlightRegex,
htmlReactParserOptions, htmlReactParserOptions,
linkifyOpts,
}: RenderBodyProps) { }: RenderBodyProps) {
if (body === '') <MessageEmptyContent />; if (body === '') <MessageEmptyContent />;
if (customBody) { if (customBody) {
@@ -28,7 +27,7 @@ export function RenderBody({
return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions); return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
} }
return ( return (
<Linkify options={LINKIFY_OPTS}> <Linkify options={linkifyOpts}>
{highlightRegex {highlightRegex
? highlightText(highlightRegex, scaleSystemEmoji(body)) ? highlightText(highlightRegex, scaleSystemEmoji(body))
: scaleSystemEmoji(body)} : scaleSystemEmoji(body)}

View File

@@ -5,6 +5,19 @@ export const ReplyBend = style({
flexShrink: 0, flexShrink: 0,
}); });
export const ThreadIndicator = style({
opacity: config.opacity.P300,
selectors: {
'button&': {
cursor: 'pointer',
},
':hover&': {
opacity: config.opacity.P500,
},
},
});
export const Reply = style({ export const Reply = style({
marginBottom: toRem(1), marginBottom: toRem(1),
minWidth: 0, minWidth: 0,

View File

@@ -1,10 +1,7 @@
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds'; import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk'; import { EventTimelineSet, Room } from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
import React, { ReactNode, useEffect, useMemo, useState } from 'react';
import to from 'await-to-js';
import classNames from 'classnames'; import classNames from 'classnames';
import colorMXID from '../../../util/colorMXID';
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room'; import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix'; import { getMxIdLocalPart } from '../../utils/matrix';
import { LinePlaceholder } from './placeholder'; import { LinePlaceholder } from './placeholder';
@@ -12,6 +9,9 @@ import { randomNumberBetween } from '../../utils/common';
import * as css from './Reply.css'; import * as css from './Reply.css';
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content'; import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { useRoomEvent } from '../../hooks/useRoomEvent';
import colorMXID from '../../../util/colorMXID';
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
type ReplyLayoutProps = { type ReplyLayoutProps = {
userColor?: string; userColor?: string;
@@ -37,78 +37,102 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
) )
); );
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
<Box
shrink="No"
className={css.ThreadIndicator}
alignItems="Center"
gap="100"
{...props}
ref={ref}
>
<Icon size="50" src={Icons.Thread} />
<Text size="L400">Thread</Text>
</Box>
));
type ReplyProps = { type ReplyProps = {
mx: MatrixClient;
room: Room; room: Room;
timelineSet?: EventTimelineSet; timelineSet?: EventTimelineSet | undefined;
eventId: string; replyEventId: string;
threadRootId?: string | undefined;
onClick?: MouseEventHandler | undefined;
getMemberPowerTag?: GetMemberPowerTag;
accessibleTagColors?: Map<string, string>;
legacyUsernameColor?: boolean;
}; };
export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, ...props }, ref) => { export const Reply = as<'div', ReplyProps>(
const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>( (
timelineSet?.findEventById(eventId) {
); room,
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []); timelineSet,
replyEventId,
threadRootId,
onClick,
getMemberPowerTag,
accessibleTagColors,
legacyUsernameColor,
...props
},
ref
) => {
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
const getFromLocalTimeline = useCallback(
() => timelineSet?.findEventById(replyEventId),
[timelineSet, replyEventId]
);
const replyEvent = useRoomEvent(room, replyEventId, getFromLocalTimeline);
const { body } = replyEvent?.getContent() ?? {}; const { body } = replyEvent?.getContent() ?? {};
const sender = replyEvent?.getSender(); const sender = replyEvent?.getSender();
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
const fallbackBody = replyEvent?.isRedacted() ? ( const usernameColor = legacyUsernameColor ? colorMXID(sender ?? replyEventId) : tagColor;
<MessageDeletedContent />
) : (
<MessageFailedContent />
);
useEffect(() => { const fallbackBody = replyEvent?.isRedacted() ? (
let disposed = false; <MessageDeletedContent />
const loadEvent = async () => { ) : (
const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, eventId)); <MessageFailedContent />
const mEvent = new MatrixEvent(evt); );
if (disposed) return;
if (err) {
setReplyEvent(null);
return;
}
if (mEvent.isEncrypted() && mx.getCrypto()) {
await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
}
setReplyEvent(mEvent);
};
if (replyEvent === undefined) loadEvent();
return () => {
disposed = true;
};
}, [replyEvent, mx, room, eventId]);
const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted'; const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody; const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
return ( return (
<ReplyLayout <Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
userColor={sender ? colorMXID(sender) : undefined} {threadRootId && (
username={ <ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
sender && ( )}
<Text size="T300" truncate> <ReplyLayout
<b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b> as="button"
</Text> userColor={usernameColor}
) username={
} sender && (
{...props} <Text size="T300" truncate>
ref={ref} <b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b>
> </Text>
{replyEvent !== undefined ? ( )
<Text size="T300" truncate> }
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX} data-event-id={replyEventId}
</Text> onClick={onClick}
) : ( >
<LinePlaceholder {replyEvent !== undefined ? (
style={{ <Text size="T300" truncate>
backgroundColor: color.SurfaceVariant.ContainerActive, {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
maxWidth: toRem(placeholderWidth), </Text>
width: '100%', ) : (
}} <LinePlaceholder
/> style={{
)} backgroundColor: color.SurfaceVariant.ContainerActive,
</ReplyLayout> width: toRem(placeholderWidth),
); maxWidth: '100%',
}); }}
/>
)}
</ReplyLayout>
</Box>
);
}
);

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