Compare commits

...

135 Commits

Author SHA1 Message Date
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
448 changed files with 28855 additions and 12622 deletions

View File

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

15
.github/renovate.json vendored
View File

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

View File

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

View File

@@ -12,7 +12,7 @@ jobs:
- 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'
# Beta Release
uses: cla-assistant/github-action@v2.4.0
uses: cla-assistant/github-action@v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# 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' }}
steps:
- name: Download pr number
uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11
uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
@@ -24,7 +24,7 @@ jobs:
id: pr
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
- name: Download artifact
uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11
uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}

View File

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

View File

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

View File

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

View File

@@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4.1.7
uses: actions/checkout@v4.2.0
- name: Setup node
uses: actions/setup-node@v4.0.3
uses: actions/setup-node@v4.3.0
with:
node-version: 20.12.2
cache: 'npm'
@@ -52,7 +52,7 @@ jobs:
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
- name: Upload tagged release
uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda
with:
files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz
@@ -66,31 +66,31 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4.1.7
uses: actions/checkout@v4.2.0
- 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
uses: docker/setup-buildx-action@v3.6.1
uses: docker/setup-buildx-action@v3.10.0
- name: Login to Docker Hub
uses: docker/login-action@v3.3.0
uses: docker/login-action@v3.4.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to the Container registry
uses: docker/login-action@v3.3.0
uses: docker/login-action@v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5.5.1
uses: docker/metadata-action@v5.7.0
with:
images: |
${{ secrets.DOCKER_USERNAME }}/cinny
ghcr.io/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@v6.5.0
uses: docker/build-push-action@v6.15.0
with:
context: .
platforms: linux/amd64,linux/arm64

3
.npmrc
View File

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

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
FROM nginx:1.27.0-alpine
FROM nginx:1.27.4-alpine
COPY --from=builder /src/dist /app
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -19,27 +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">
## 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).
You can serve the application with a webserver of your choice by simply copying `dist/` directory to the webroot.
To set default Homeserver on login, register and Explore Community page, place a customized [`config.json`](config.json) in webroot of your choice.
You will also need to setup redirects to serve the assests. An example setting of redirects for netlify is done in [`netlify.toml`](netlify.toml). You can also set `hashRouter.enabled = true` in [`config.json`](config.json) if you have trouble setting redirects.
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 change `base: '/app'`.
## Self-hosting
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).
* Alternatively you can just pull the [DockerHub image](https://hub.docker.com/r/ajbura/cinny) by:
```
docker pull ajbura/cinny
```
or [ghcr image](https://github.com/cinnyapp/cinny/pkgs/container/cinny) by:
```
docker pull ghcr.io/cinnyapp/cinny:latest
```
* The default homeservers and explore pages are defined in [`config.json`](config.json).
<details>
<summary>PGP Public Key to verify tarball</summary>
* 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).
* 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-----
@@ -87,8 +82,8 @@ mxFo+ioe/ABCufSmyqFye0psX3Sp
</details>
## Local development
> 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).
> [!TIP]
> We recommend using a version manager as versions change very quickly. You will likely need to switch between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Iron LTS (v20).
Execute the following commands to start a development server:
```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 {
listen 80;
listen [::]:80;
server_name cinny.domain.tld;
listen 80;
listen [::]:80;
server_name cinny.domain.tld;
location / {
return 301 https://$host$request_uri;
}
location / {
return 301 https://$host$request_uri;
}
location /.well-known/acme-challenge/ {
alias /var/lib/letsencrypt/.well-known/acme-challenge/;
}
location /.well-known/acme-challenge/ {
alias /var/lib/letsencrypt/.well-known/acme-challenge/;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl;
server_name cinny.domain.tld;
listen 443 ssl http2;
listen [::]:443 ssl;
server_name cinny.domain.tld;
location / {
root /opt/cinny/dist/;
location / {
root /opt/cinny/dist/;
rewrite ^/config.json$ /config.json break;
rewrite ^/manifest.json$ /manifest.json break;
rewrite ^/config.json$ /config.json break;
rewrite ^/manifest.json$ /manifest.json break;
rewrite ^.*/olm.wasm$ /olm.wasm break;
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
rewrite ^/sw.js$ /sw.js break;
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
rewrite ^/public/(.*)$ /public/$1 break;
rewrite ^/assets/(.*)$ /assets/$1 break;
rewrite ^/public/(.*)$ /public/$1 break;
rewrite ^/assets/(.*)$ /assets/$1 break;
rewrite ^(.+)$ /index.html break;
}
rewrite ^(.+)$ /index.html break;
}
}

View File

@@ -1,16 +1,19 @@
server {
location / {
root /usr/share/nginx/html;
listen 80;
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 ^.*/olm.wasm$ /olm.wasm break;
rewrite ^/sw.js$ /sw.js break;
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
rewrite ^/public/(.*)$ /public/$1 break;
rewrite ^/assets/(.*)$ /assets/$1 break;
rewrite ^(.+)$ /index.html break;
}
}
}
}

View File

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

8128
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "cinny",
"version": "4.1.0",
"version": "4.7.1",
"description": "Yet another matrix client",
"main": "index.js",
"type": "module",
@@ -24,7 +24,6 @@
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14",
"@matrix-org/olm": "3.2.14",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0",
@@ -35,43 +34,50 @@
"await-to-js": "3.0.0",
"blurhash": "2.0.4",
"browser-encrypt-attachment": "0.3.0",
"chroma-js": "3.1.2",
"classnames": "2.3.2",
"dateformat": "5.0.3",
"dayjs": "1.11.10",
"domhandler": "5.0.3",
"emojibase": "6.1.0",
"emojibase-data": "7.0.1",
"emojibase": "15.3.1",
"emojibase-data": "15.3.2",
"file-saver": "2.0.5",
"flux": "4.0.3",
"focus-trap-react": "10.0.2",
"folds": "2.0.0",
"folds": "2.1.0",
"formik": "2.4.6",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"i18next": "23.12.2",
"i18next-browser-languagedetector": "8.0.0",
"i18next-http-backend": "2.5.2",
"immer": "9.0.16",
"is-hotkey": "0.2.0",
"jotai": "2.6.0",
"linkify-react": "4.1.3",
"linkifyjs": "4.1.3",
"matrix-js-sdk": "29.1.0",
"matrix-js-sdk": "37.5.0",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
"prismjs": "1.29.0",
"prismjs": "1.30.0",
"prop-types": "15.8.1",
"react": "18.2.0",
"react-aria": "3.29.1",
"react-autosize-textarea": "7.1.0",
"react-blurhash": "0.2.0",
"react-colorful": "5.6.1",
"react-dom": "18.2.0",
"react-error-boundary": "4.0.13",
"react-google-recaptcha": "2.1.0",
"react-i18next": "15.0.0",
"react-modal": "3.16.1",
"react-range": "1.8.14",
"react-router-dom": "6.20.0",
"sanitize-html": "2.12.1",
"slate": "0.94.1",
"slate-history": "0.93.0",
"slate-react": "0.98.4",
"slate": "0.112.0",
"slate-dom": "0.112.2",
"slate-history": "0.110.3",
"slate-react": "0.112.1",
"tippy.js": "6.3.7",
"ua-parser-js": "1.0.35"
},
@@ -79,7 +85,9 @@
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1",
"@types/chroma-js": "3.1.1",
"@types/file-saver": "2.0.5",
"@types/is-hotkey": "0.1.10",
"@types/node": "18.11.18",
"@types/prismjs": "1.26.0",
"@types/react": "18.2.39",
@@ -101,8 +109,9 @@
"prettier": "2.8.1",
"sass": "1.56.2",
"typescript": "4.9.4",
"vite": "5.0.13",
"vite": "5.4.15",
"vite-plugin-pwa": "0.20.5",
"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

@@ -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,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

@@ -19,7 +19,7 @@ export function CapabilitiesAndMediaConfigLoader({
[]
>(
useCallback(async () => {
const result = await Promise.allSettled([mx.getCapabilities(true), mx.getMediaConfig()]);
const result = await Promise.allSettled([mx.getCapabilities(), mx.getMediaConfig()]);
const capabilities = promiseFulfilledResult(result[0]);
const mediaConfig = promiseFulfilledResult(result[1]);
return [capabilities, mediaConfig];

View File

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

@@ -2,6 +2,7 @@ import React from 'react';
import { MsgType } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { config } from 'folds';
import {
AudioContent,
DownloadFile,
@@ -29,6 +30,7 @@ import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to';
import { IImageContent } from '../../types/matrix/common';
type RenderMessageContentProps = {
displayName: string;
@@ -67,38 +69,63 @@ export function RenderMessageContent({
</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 = () => (
<MFile
content={getContent()}
renderFileContent={({ body, mimeType, info, encInfo, url }) => (
<FileContent
body={body}
mimeType={mimeType}
renderAsPdfFile={() => (
<ReadPdfFile
body={body}
mimeType={mimeType}
url={url}
encInfo={encInfo}
renderViewer={(p) => <PdfViewer {...p} />}
/>
)}
renderAsTextFile={() => (
<ReadTextFile
body={body}
mimeType={mimeType}
url={url}
encInfo={encInfo}
renderViewer={(p) => <TextViewer {...p} />}
/>
)}
>
<DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} />
</FileContent>
)}
outlined={outlineAttachment}
/>
<>
<MFile
content={getContent()}
renderFileContent={({ body, mimeType, info, encInfo, url }) => (
<FileContent
body={body}
mimeType={mimeType}
renderAsPdfFile={() => (
<ReadPdfFile
body={body}
mimeType={mimeType}
url={url}
encInfo={encInfo}
renderViewer={(p) => <PdfViewer {...p} />}
/>
)}
renderAsTextFile={() => (
<ReadTextFile
body={body}
mimeType={mimeType}
url={url}
encInfo={encInfo}
renderViewer={(p) => <TextViewer {...p} />}
/>
)}
>
<DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} />
</FileContent>
)}
outlined={outlineAttachment}
/>
{renderCaption()}
</>
);
if (msgType === MsgType.Text) {
@@ -158,63 +185,72 @@ export function RenderMessageContent({
if (msgType === MsgType.Image) {
return (
<MImage
content={getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
outlined={outlineAttachment}
/>
<>
<MImage
content={getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
outlined={outlineAttachment}
/>
{renderCaption()}
</>
);
}
if (msgType === MsgType.Video) {
return (
<MVideo
content={getContent()}
renderAsFile={renderFile}
renderVideoContent={({ body, info, mimeType, url, encInfo }) => (
<VideoContent
body={body}
info={info}
mimeType={mimeType}
url={url}
encInfo={encInfo}
renderThumbnail={
mediaAutoLoad
? () => (
<ThumbnailContent
info={info}
renderImage={(src) => (
<Image alt={body} title={body} src={src} loading="lazy" />
)}
/>
)
: undefined
}
renderVideo={(p) => <Video {...p} />}
/>
)}
outlined={outlineAttachment}
/>
<>
<MVideo
content={getContent()}
renderAsFile={renderFile}
renderVideoContent={({ body, info, mimeType, url, encInfo }) => (
<VideoContent
body={body}
info={info}
mimeType={mimeType}
url={url}
encInfo={encInfo}
renderThumbnail={
mediaAutoLoad
? () => (
<ThumbnailContent
info={info}
renderImage={(src) => (
<Image alt={body} title={body} src={src} loading="lazy" />
)}
/>
)
: undefined
}
renderVideo={(p) => <Video {...p} />}
/>
)}
outlined={outlineAttachment}
/>
{renderCaption()}
</>
);
}
if (msgType === MsgType.Audio) {
return (
<MAudio
content={getContent()}
renderAsFile={renderFile}
renderAudioContent={(props) => (
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
)}
outlined={outlineAttachment}
/>
<>
<MAudio
content={getContent()}
renderAsFile={renderFile}
renderAudioContent={(props) => (
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
)}
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

@@ -13,7 +13,6 @@ import {
IconButton,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';
export type UIAFlowOverlayProps = {
currentStep: number;
@@ -29,7 +28,7 @@ export function UIAFlowOverlay({
}: UIAFlowOverlayProps) {
return (
<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 grow="Yes" direction="Column" alignItems="Center" justifyContent="Center">
{children}

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

@@ -13,6 +13,8 @@ import { CommandElement, EmoticonElement, LinkElement, MentionElement } from './
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getBeginCommand } from './utils';
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:
// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405
@@ -76,6 +78,7 @@ function RenderEmoticonElement({
children,
}: { element: EmoticonElement } & RenderElementProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const selected = useSelected();
const focused = useFocused();
@@ -90,7 +93,7 @@ function RenderEmoticonElement({
{element.key.startsWith('mxc://') ? (
<img
className={css.EmoticonImg}
src={mx.mxcUrlToHttp(element.key) ?? element.key}
src={mxcUrlToHttp(mx, element.key, useAuthentication) ?? element.key}
alt={element.shortcode}
/>
) : (

View File

@@ -257,7 +257,9 @@ export function Toolbar() {
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
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');
return (

View File

@@ -5,6 +5,7 @@ import { Header, Menu, Scroll, config } from 'folds';
import * as css from './AutocompleteMenu.css';
import { preventScrollWithArrowKey, stopPropagation } from '../../../utils/keyboard';
import { useAlive } from '../../../hooks/useAlive';
type AutocompleteMenuProps = {
requestClose: () => void;
@@ -12,13 +13,22 @@ type AutocompleteMenuProps = {
children: ReactNode;
};
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 (
<div className={css.AutocompleteMenuBase}>
<div className={css.AutocompleteMenuContainer}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => requestClose(),
onPostDeactivate: handleDeactivate,
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true,
allowOutsideClick: true,

View File

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

View File

@@ -65,7 +65,6 @@ type RoomMentionAutocompleteProps = {
};
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 20,
matchOptions: {
contain: true,
},
@@ -97,7 +96,7 @@ export function RoomMentionAutocomplete({
SEARCH_OPTIONS
);
const autoCompleteRoomIds = result ? result.items : allRooms.slice(0, 20);
const autoCompleteRoomIds = result ? result.items.slice(0, 20) : allRooms.slice(0, 20);
useEffect(() => {
if (query.text) search(query.text);

View File

@@ -18,6 +18,8 @@ import { useKeyDown } from '../../../hooks/useKeyDown';
import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix';
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
import { UserAvatar } from '../../user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Membership } from '../../../../types/matrix/room';
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
@@ -66,8 +68,13 @@ type UserMentionAutocompleteProps = {
requestClose: () => void;
};
const withAllowedMembership = (member: RoomMember): boolean =>
member.membership === Membership.Join ||
member.membership === Membership.Invite ||
member.membership === Membership.Knock;
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 20,
limit: 1000,
matchOptions: {
contain: true,
},
@@ -84,12 +91,15 @@ export function UserMentionAutocomplete({
requestClose,
}: UserMentionAutocompleteProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const roomId: string = room.roomId!;
const roomAliasOrId = room.getCanonicalAlias() || roomId;
const members = useRoomMembers(mx, roomId);
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(() => {
if (query.text) search(query.text);
@@ -143,7 +153,10 @@ export function UserMentionAutocomplete({
/>
) : (
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 (
<MenuItem
key={roomMember.userId}

View File

@@ -26,48 +26,75 @@ import {
testMatrixTo,
} from '../../plugins/matrix-to';
import { tryDecodeURIComponent } from '../../utils/dom';
import {
escapeMarkdownInlineSequences,
escapeMarkdownBlockSequences,
} from '../../plugins/markdown';
const markNodeToType: Record<string, MarkType> = {
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,
};
type ProcessTextCallback = (text: string) => string;
const elementToTextMark = (node: Element): MarkType | undefined => {
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 => {
const getText = (node: ChildNode): string => {
if (isText(node)) {
return node.data;
}
if (isTag(node)) {
return node.children.map((child) => parseNodeText(child)).join('');
return node.children.map((child) => getText(child)).join('');
}
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) {
const { src, alt } = node.attribs;
if (!src) return undefined;
@@ -79,13 +106,13 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
if (testMatrixTo(href)) {
const userMention = parseMatrixToUser(href);
if (userMention) {
return createMentionElement(userMention, parseNodeText(node) || userMention, false);
return createMentionElement(userMention, getText(node) || userMention, false);
}
const roomMention = parseMatrixToRoom(href);
if (roomMention) {
return createMentionElement(
roomMention.roomIdOrAlias,
parseNodeText(node) || roomMention.roomIdOrAlias,
getText(node) || roomMention.roomIdOrAlias,
false,
undefined,
roomMention.viaServers
@@ -95,7 +122,7 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
if (eventMention) {
return createMentionElement(
eventMention.roomIdOrAlias,
parseNodeText(node) || eventMention.roomIdOrAlias,
getText(node) || eventMention.roomIdOrAlias,
false,
eventMention.eventId,
eventMention.viaServers
@@ -106,44 +133,40 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
return undefined;
};
const parseInlineNodes = (node: ChildNode): InlineElement[] => {
const getInlineElement = (node: ChildNode, processText: ProcessTextCallback): InlineElement[] => {
if (isText(node)) {
return [{ text: node.data }];
return [{ text: processText(node.data) }];
}
if (isTag(node)) {
const markType = elementToTextMark(node);
const markType = getInlineNodeMarkType(node);
if (markType) {
const children = node.children.flatMap(parseInlineNodes);
if (node.attribs['data-md'] !== undefined) {
children.unshift({ text: node.attribs['data-md'] });
children.push({ text: node.attribs['data-md'] });
} else {
children.forEach((child) => {
if (Text.isText(child)) {
child[markType] = true;
}
});
}
return children;
return getInlineMarkElement(markType, node, (child) => {
if (markType === MarkType.Code) return [{ text: getText(child) }];
return getInlineElement(child, processText);
});
}
const inlineNode = elementToInlineNode(node);
const inlineNode = getInlineNonMarkElement(node);
if (inlineNode) return [inlineNode];
if (node.name === 'a') {
const children = node.childNodes.flatMap(parseInlineNodes);
const children = node.childNodes.flatMap((child) => getInlineElement(child, processText));
children.unshift({ text: '[' });
children.push({ text: `](${node.attribs.href})` });
return children;
}
return node.childNodes.flatMap(parseInlineNodes);
return node.childNodes.flatMap((child) => getInlineElement(child, processText));
}
return [];
};
const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElement[] => {
const parseBlockquoteNode = (
node: Element,
processText: ProcessTextCallback
): BlockQuoteElement[] | ParagraphElement[] => {
const quoteLines: Array<InlineElement[]> = [];
let lineHolder: InlineElement[] = [];
@@ -156,7 +179,7 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
node.children.forEach((child) => {
if (isText(child)) {
lineHolder.push({ text: child.data });
lineHolder.push({ text: processText(child.data) });
return;
}
if (isTag(child)) {
@@ -168,19 +191,20 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
if (child.name === 'p') {
appendLine();
quoteLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
quoteLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
return;
}
parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
lineHolder.push(...getInlineElement(child, processText));
}
});
appendLine();
if (node.attribs['data-md'] !== undefined) {
const mdSequence = node.attribs['data-md'];
if (mdSequence !== undefined) {
return quoteLines.map((lineChildren) => ({
type: BlockType.Paragraph,
children: [{ text: `${node.attribs['data-md']} ` }, ...lineChildren],
children: [{ text: `${mdSequence} ` }, ...lineChildren],
}));
}
@@ -195,22 +219,19 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
];
};
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 pLines = codeLines.map<ParagraphElement>((lineText) => ({
const mdSequence = node.attribs['data-md'];
if (mdSequence !== undefined) {
const pLines = codeLines.map<ParagraphElement>((text) => ({
type: BlockType.Paragraph,
children: [
{
text: lineText,
},
],
children: [{ text }],
}));
const childCode = node.children[0];
const className =
isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : '';
const prefix = { text: `${node.attribs['data-md']}${className.replace('language-', '')}` };
const suffix = { text: node.attribs['data-md'] };
const prefix = { text: `${mdSequence}${className.replace('language-', '')}` };
const suffix = { text: mdSequence };
return [
{ type: BlockType.Paragraph, children: [prefix] },
...pLines,
@@ -221,19 +242,16 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
return [
{
type: BlockType.CodeBlock,
children: codeLines.map<CodeLineElement>((lineTxt) => ({
children: codeLines.map<CodeLineElement>((text) => ({
type: BlockType.CodeLine,
children: [
{
text: lineTxt,
},
],
children: [{ text }],
})),
},
];
};
const parseListNode = (
node: Element
node: Element,
processText: ProcessTextCallback
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
const listLines: Array<InlineElement[]> = [];
let lineHolder: InlineElement[] = [];
@@ -247,7 +265,7 @@ const parseListNode = (
node.children.forEach((child) => {
if (isText(child)) {
lineHolder.push({ text: child.data });
lineHolder.push({ text: processText(child.data) });
return;
}
if (isTag(child)) {
@@ -259,17 +277,18 @@ const parseListNode = (
if (child.name === 'li') {
appendLine();
listLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
listLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
return;
}
parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
lineHolder.push(...getInlineElement(child, processText));
}
});
appendLine();
if (node.attribs['data-md'] !== undefined) {
const prefix = node.attribs['data-md'] || '-';
const mdSequence = node.attribs['data-md'];
if (mdSequence !== undefined) {
const prefix = mdSequence || '-';
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
return listLines.map((lineChildren) => ({
type: BlockType.Paragraph,
@@ -302,17 +321,21 @@ const parseListNode = (
},
];
};
const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => {
const children = node.children.flatMap((child) => parseInlineNodes(child));
const parseHeadingNode = (
node: Element,
processText: ProcessTextCallback
): HeadingElement | ParagraphElement => {
const children = node.children.flatMap((child) => getInlineElement(child, processText));
const headingMatch = node.name.match(/^h([123456])$/);
const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
const level = parseInt(g1AsLevel, 10);
if (node.attribs['data-md'] !== undefined) {
const mdSequence = node.attribs['data-md'];
if (mdSequence !== undefined) {
return {
type: BlockType.Paragraph,
children: [{ text: `${node.attribs['data-md']} ` }, ...children],
children: [{ text: `${mdSequence} ` }, ...children],
};
}
@@ -323,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[] = [];
let lineHolder: InlineElement[] = [];
@@ -340,7 +367,14 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
domNodes.forEach((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;
}
if (isTag(node)) {
@@ -354,14 +388,14 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
appendLine();
children.push({
type: BlockType.Paragraph,
children: node.children.flatMap((child) => parseInlineNodes(child)),
children: node.children.flatMap((child) => getInlineElement(child, processText)),
});
return;
}
if (node.name === 'blockquote') {
appendLine();
children.push(...parseBlockquoteNode(node));
children.push(...parseBlockquoteNode(node, processText));
return;
}
if (node.name === 'pre') {
@@ -371,17 +405,17 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
}
if (node.name === 'ol' || node.name === 'ul') {
appendLine();
children.push(...parseListNode(node));
children.push(...parseListNode(node, processText));
return;
}
if (node.name.match(/^h[123456]$/)) {
appendLine();
children.push(parseHeadingNode(node));
children.push(parseHeadingNode(node, processText));
return;
}
parseInlineNodes(node).forEach((inlineNode) => lineHolder.push(inlineNode));
lineHolder.push(...getInlineElement(node, processText));
}
});
appendLine();
@@ -389,21 +423,31 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
return children;
};
export const htmlToEditorInput = (unsafeHtml: string): Descendant[] => {
export const htmlToEditorInput = (unsafeHtml: string, markdown?: boolean): Descendant[] => {
const sanitizedHtml = sanitizeCustomHtml(unsafeHtml);
const processText = (partText: string) => {
if (!markdown) return partText;
return escapeMarkdownInlineSequences(partText);
};
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;
};
export const plainToEditorInput = (text: string): Descendant[] => {
export const plainToEditorInput = (text: string, markdown?: boolean): Descendant[] => {
const editorNodes: Descendant[] = text.split('\n').map((lineText) => {
const paragraphNode: ParagraphElement = {
type: BlockType.Paragraph,
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 { BlockType } from './types';
import { CustomElement } from './slate';
import { parseBlockMD, parseInlineMD } from '../../plugins/markdown';
import {
parseBlockMD,
parseInlineMD,
unescapeMarkdownBlockSequences,
unescapeMarkdownInlineSequences,
} from '../../plugins/markdown';
import { findAndReplace } from '../../utils/findAndReplace';
import { sanitizeForRegex } from '../../utils/regex';
import { getCanonicalAliasOrRoomId, isUserId } from '../../utils/matrix';
export type OutputOptions = {
allowTextFormatting?: boolean;
@@ -18,7 +25,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
if (node.bold) string = `<strong>${string}</strong>`;
if (node.italic) string = `<i>${string}</i>`;
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.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
}
@@ -62,7 +69,7 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
}
const matrixTo = `https://matrix.to/#/${fragment}`;
return `<a href="${encodeURIComponent(matrixTo)}">${sanitizeText(node.name)}</a>`;
return `<a href="${encodeURI(matrixTo)}">${sanitizeText(node.name)}</a>`;
}
case BlockType.Emoticon:
return node.key.startsWith('mxc://')
@@ -71,7 +78,7 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
)}" title="${sanitizeText(node.shortcode)}" height="32" />`
: sanitizeText(node.key);
case BlockType.Link:
return `<a href="${encodeURIComponent(node.href)}">${node.children}</a>`;
return `<a href="${encodeURI(node.href)}">${node.children}</a>`;
case BlockType.Command:
return `/${sanitizeText(node.command)}`;
default:
@@ -101,7 +108,8 @@ export const toMatrixCustomHTML = (
allowBlockMarkdown: false,
})
.replace(/<br\/>$/, '\n')
.replace(/^&gt;/, '>');
.replace(/^(\\*)&gt;/, '$1>');
markdownLines += line;
if (index === targetNodes.length - 1) {
return parseBlockMD(markdownLines, ignoreHTMLParseInlineMD);
@@ -156,11 +164,14 @@ const elementToPlainText = (node: CustomElement, children: string): string => {
}
};
export const toPlainText = (node: Descendant | Descendant[]): string => {
if (Array.isArray(node)) return node.map((n) => toPlainText(n)).join('');
if (Text.isText(node)) return node.text;
export const toPlainText = (node: Descendant | Descendant[], isMarkdown: boolean): string => {
if (Array.isArray(node)) return node.map((n) => toPlainText(n, isMarkdown)).join('');
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);
};
@@ -179,9 +190,42 @@ export const customHtmlEqualsPlainText = (customHtml: string, plain: string): bo
export const trimCustomHtml = (customHtml: string) => customHtml.replace(/<br\/>$/g, '').trim();
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);
if (!match) return str;
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

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

View File

@@ -21,6 +21,7 @@ import * as css from './EventReaders.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { openProfileViewer } from '../../../client/action/navigation';
import { UserAvatar } from '../user-avatar';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
export type EventReadersProps = {
room: Room;
@@ -30,6 +31,7 @@ export type EventReadersProps = {
export const EventReaders = as<'div', EventReadersProps>(
({ className, room, eventId, requestClose, ...props }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const latestEventReaders = useRoomEventReaders(room, eventId);
const getName = (userId: string) =>
@@ -55,9 +57,10 @@ export const EventReaders = as<'div', EventReadersProps>(
<Box className={css.Content} direction="Column">
{latestEventReaders.map((readerId) => {
const name = getName(readerId);
const avatarUrl = room
const avatarMxcUrl = room
.getMember(readerId)
?.getAvatarUrl(mx.baseUrl, 100, 100, 'crop', undefined, false);
?.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication) : undefined;
return (
<MenuItem

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,55 @@
import React, { useCallback, useMemo } from 'react';
import { Room } from 'matrix-js-sdk';
import { usePowerLevels, usePowerLevelsAPI } 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';
type RoomImagePackProps = {
room: Room;
stateKey: string;
};
export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const powerLevels = usePowerLevels(room);
const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
const canEditImagePack = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(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 { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
import { downloadMedia } from '../../utils/matrix';
export type ImageViewerProps = {
alt: string;
@@ -18,8 +19,9 @@ export const ImageViewer = as<'div', ImageViewerProps>(
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const handleDownload = () => {
FileSaver.saveAs(src, alt);
const handleDownload = async () => {
const fileContent = await downloadMedia(src);
FileSaver.saveAs(fileContent, alt);
};
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,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 React from 'react';
import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver';
import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../utils/matrix';
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 = {
body: 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}>
<Badge style={badgeStyles} variant="Secondary" radii="Pill">
<Text size="O400" truncate>
{mimeTypeToExt(mimeType)}
<Box shrink="No">
<Badge style={badgeStyles} variant="Secondary" radii="Pill">
<Text size="O400" truncate>
{mimeTypeToExt(mimeType)}
</Text>
</Badge>
</Box>
<Box grow="Yes">
<Text size="T300" truncate>
{body}
</Text>
</Badge>
<Text size="T300" truncate>
{body}
</Text>
</Box>
{after}
</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 { IContent } from 'matrix-js-sdk';
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
@@ -22,11 +22,13 @@ import {
IThumbnailContent,
IVideoContent,
IVideoInfo,
MATRIX_SPOILER_PROPERTY_NAME,
MATRIX_SPOILER_REASON_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader } from './FileHeader';
import { FileHeader, FileDownloadButton } from './FileHeader';
export function MBadEncrypted() {
return (
@@ -72,8 +74,9 @@ type MTextProps = {
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => 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;
if (typeof body !== 'string') return <BrokenContent />;
@@ -86,6 +89,7 @@ export function MText({ edited, content, renderBody, renderUrlsPreview }: MTextP
<MessageTextBody
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
style={style}
>
{renderBody({
body: trimmedBody,
@@ -172,10 +176,13 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
type RenderImageContentProps = {
body: string;
filename?: string;
info?: IImageInfo & IThumbnailContent;
mimeType?: string;
url: string;
encInfo?: IEncryptedFile;
markedAsSpoiler?: boolean;
spoilerReason?: string;
};
type MImageProps = {
content: IImageContent;
@@ -203,6 +210,8 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
mimeType: imgInfo?.mimetype,
url: mxcUrl,
encInfo: content.file,
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})}
</AttachmentBox>
</Attachment>
@@ -236,8 +245,24 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
const filename = content.filename ?? content.body ?? 'Video';
return (
<Attachment outlined={outlined}>
<AttachmentHeader>
<FileHeader
body={filename}
mimeType={safeMimeType}
after={
<FileDownloadButton
filename={filename}
url={mxcUrl}
mimeType={safeMimeType}
encInfo={content.file}
/>
}
/>
</AttachmentHeader>
<AttachmentBox
style={{
height: toRem(height < 48 ? 48 : height),
@@ -279,10 +304,22 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
return <BrokenContent />;
}
const filename = content.filename ?? content.body ?? 'Audio';
return (
<Attachment outlined={outlined}>
<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>
<AttachmentBox>
<AttachmentContent>
@@ -322,14 +359,14 @@ export function MFile({ content, renderFileContent, outlined }: MFileProps) {
<Attachment outlined={outlined}>
<AttachmentHeader>
<FileHeader
body={content.body ?? 'Unnamed File'}
body={content.filename ?? content.body ?? 'Unnamed File'}
mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
/>
</AttachmentHeader>
<AttachmentBox>
<AttachmentContent>
{renderFileContent({
body: content.body ?? 'File',
body: content.filename ?? content.body ?? 'File',
info: fileInfo ?? {},
mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE,
url: mxcUrl,

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { Range } from 'react-range';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { getFileSrcUrl } from './util';
import { IAudioInfo } from '../../../../types/matrix/common';
import {
PlayTimeCallback,
@@ -17,6 +16,13 @@ import {
} from '../../../hooks/media';
import { useThrottle } from '../../../hooks/useThrottle';
import { secondsToMinutesAndSeconds } from '../../../utils/common';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
const PLAY_TIME_THROTTLE_OPS = {
wait: 500,
@@ -44,12 +50,16 @@ export function AudioContent({
renderMediaControl,
}: AudioContentProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [srcState, loadSrc] = useAsyncCallback(
useCallback(
() => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
[mx, url, mimeType, encInfo]
)
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo])
);
const audioRef = useRef<HTMLAudioElement | null>(null);

View File

@@ -1,6 +1,7 @@
import { Box, Icon, IconSrc } from 'folds';
import React, { ReactNode } from 'react';
import { CompactLayout, ModernLayout } from '..';
import { MessageLayout } from '../../../state/settings';
export type EventContentProps = {
messageLayout: number;
@@ -11,9 +12,9 @@ export type EventContentProps = {
export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
const beforeJSX = (
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
{messageLayout === 1 && time}
{messageLayout === MessageLayout.Compact && time}
<Box
grow={messageLayout === 1 ? undefined : 'Yes'}
grow={messageLayout === MessageLayout.Compact ? undefined : 'Yes'}
alignItems="Center"
justifyContent="Center"
>
@@ -25,11 +26,11 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
const msgContentJSX = (
<Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200">
{content}
{messageLayout !== 1 && time}
{messageLayout !== MessageLayout.Compact && time}
</Box>
);
return messageLayout === 1 ? (
return messageLayout === MessageLayout.Compact ? (
<CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
) : (
<ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>

View File

@@ -20,7 +20,6 @@ import FocusTrap from 'focus-trap-react';
import { IFileInfo } from '../../../../types/matrix/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getFileSrcUrl, getSrcFile } from './util';
import { bytesToSize } from '../../../utils/common';
import {
READABLE_EXT_TO_MIME_TYPE,
@@ -28,8 +27,15 @@ import {
getFileNameExt,
mimeTypeToExt,
} from '../../../utils/mimeTypes';
import * as css from './style.css';
import { stopPropagation } from '../../../utils/keyboard';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
const renderErrorButton = (retry: () => void, text: string) => (
<TooltipProvider
@@ -75,21 +81,20 @@ type ReadTextFileProps = {
};
export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: ReadTextFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [textViewer, setTextViewer] = useState(false);
const loadSrc = useCallback(
() => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
[mx, url, mimeType, encInfo]
);
const [textState, loadText] = useAsyncCallback(
useCallback(async () => {
const src = await loadSrc();
const blob = await getSrcFile(src);
const text = blob.text();
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
const text = fileContent.text();
setTextViewer(true);
return text;
}, [loadSrc])
}, [mx, useAuthentication, mimeType, encInfo, url])
);
return (
@@ -106,7 +111,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
}}
>
<Modal
className={css.ModalWide}
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>
@@ -166,14 +171,18 @@ export type ReadPdfFileProps = {
};
export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: ReadPdfFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [pdfViewer, setPdfViewer] = useState(false);
const [pdfState, loadPdf] = useAsyncCallback(
useCallback(async () => {
const httpUrl = await getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo);
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
setPdfViewer(true);
return httpUrl;
}, [mx, url, mimeType, encInfo])
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo])
);
return (
@@ -190,7 +199,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
}}
>
<Modal
className={css.ModalWide}
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>
@@ -240,13 +249,19 @@ export type DownloadFileProps = {
};
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
const httpUrl = await getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo);
FileSaver.saveAs(httpUrl, body);
return httpUrl;
}, [mx, url, mimeType, encInfo, body])
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, body);
return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, body])
);
return downloadState.status === AsyncStatus.Error ? (

View File

@@ -3,6 +3,7 @@ import {
Badge,
Box,
Button,
Chip,
Icon,
Icons,
Modal,
@@ -22,11 +23,14 @@ import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getFileSrcUrl } from './util';
import * as css from './style.css';
import { bytesToSize } from '../../../utils/common';
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
import { stopPropagation } from '../../../utils/keyboard';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash';
type RenderViewerProps = {
src: string;
@@ -49,6 +53,8 @@ export type ImageContentProps = {
info?: IImageInfo;
encInfo?: EncryptedAttachmentInfo;
autoPlay?: boolean;
markedAsSpoiler?: boolean;
spoilerReason?: string;
renderViewer: (props: RenderViewerProps) => ReactNode;
renderImage: (props: RenderImageProps) => ReactNode;
};
@@ -62,6 +68,8 @@ export const ImageContent = as<'div', ImageContentProps>(
info,
encInfo,
autoPlay,
markedAsSpoiler,
spoilerReason,
renderViewer,
renderImage,
...props
@@ -69,17 +77,25 @@ export const ImageContent = as<'div', ImageContentProps>(
ref
) => {
const mx = useMatrixClient();
const blurHash = info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
const useAuthentication = useMediaAuthentication();
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const [load, setLoad] = useState(false);
const [error, setError] = useState(false);
const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [srcState, loadSrc] = useAsyncCallback(
useCallback(
() => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType || FALLBACK_MIMETYPE, encInfo),
[mx, url, mimeType, encInfo]
)
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
if (encInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo)
);
return URL.createObjectURL(fileContent);
}
return mediaUrl;
}, [mx, url, useAuthentication, mimeType, encInfo])
);
const handleLoad = () => {
@@ -113,7 +129,7 @@ export const ImageContent = as<'div', ImageContentProps>(
}}
>
<Modal
className={css.ModalWide}
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>
@@ -136,7 +152,7 @@ export const ImageContent = as<'div', ImageContentProps>(
punch={1}
/>
)}
{!autoPlay && srcState.status === AsyncStatus.Idle && (
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Button
variant="Secondary"
@@ -151,7 +167,7 @@ export const ImageContent = as<'div', ImageContentProps>(
</Box>
)}
{srcState.status === AsyncStatus.Success && (
<Box className={css.AbsoluteContainer}>
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
{renderImage({
alt: body,
title: body,
@@ -163,8 +179,42 @@ export const ImageContent = as<'div', ImageContentProps>(
})}
</Box>
)}
{blurred && !error && srcState.status !== AsyncStatus.Error && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<TooltipProvider
tooltip={
typeof spoilerReason === 'string' && (
<Tooltip variant="Secondary">
<Text>{spoilerReason}</Text>
</Tooltip>
)
}
position="Top"
align="Center"
>
{(triggerRef) => (
<Chip
ref={triggerRef}
variant="Secondary"
radii="Pill"
size="500"
outlined
onClick={() => {
setBlurred(false);
if (srcState.status === AsyncStatus.Idle) {
loadSrc();
}
}}
>
<Text size="B300">Spoiler</Text>
</Chip>
)}
</TooltipProvider>
</Box>
)}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load && (
!load &&
!markedAsSpoiler && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" />
</Box>

View File

@@ -2,7 +2,9 @@ import { ReactNode, useCallback, useEffect } from 'react';
import { IThumbnailContent } from '../../../../types/matrix/common';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { getFileSrcUrl } from './util';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
export type ThumbnailContentProps = {
info: IThumbnailContent;
@@ -10,20 +12,27 @@ export type ThumbnailContentProps = {
};
export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
useCallback(() => {
useCallback(async () => {
const thumbInfo = info.thumbnail_info;
const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
const encInfo = info.thumbnail_file;
if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
throw new Error('Failed to load thumbnail');
}
return getFileSrcUrl(
mx.mxcUrlToHttp(thumbMxcUrl) ?? '',
thumbInfo.mimetype,
info.thumbnail_file
);
}, [mx, info])
const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? thumbMxcUrl;
if (encInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo)
);
return URL.createObjectURL(fileContent);
}
return mediaUrl;
}, [mx, info, useAuthentication])
);
useEffect(() => {

View File

@@ -22,9 +22,16 @@ import {
import * as css from './style.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { getFileSrcUrl } from './util';
import { bytesToSize } from '../../../../util/common';
import { millisecondsToMinutesAndSeconds } from '../../../utils/common';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash';
type RenderVideoProps = {
title: string;
@@ -61,16 +68,22 @@ export const VideoContent = as<'div', VideoContentProps>(
ref
) => {
const mx = useMatrixClient();
const blurHash = info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
const useAuthentication = useMediaAuthentication();
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const [load, setLoad] = useState(false);
const [error, setError] = useState(false);
const [srcState, loadSrc] = useAsyncCallback(
useCallback(
() => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
[mx, url, mimeType, encInfo]
)
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo)
)
: await downloadMedia(mediaUrl);
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo])
);
const handleLoad = () => {

View File

@@ -31,7 +31,9 @@ export const AbsoluteFooter = style([
},
]);
export const ModalWide = style({
minWidth: '85vw',
minHeight: '90vh',
});
export const Blur = style([
DefaultReset,
{
filter: 'blur(44px)',
},
]);

View File

@@ -1,23 +0,0 @@
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { decryptFile } from '../../../utils/matrix';
export const getFileSrcUrl = async (
httpUrl: string,
mimeType: string,
encInfo?: EncryptedAttachmentInfo
): Promise<string> => {
if (encInfo) {
if (typeof httpUrl !== 'string') throw new Error('Malformed event');
const encRes = await fetch(httpUrl, { method: 'GET' });
const encData = await encRes.arrayBuffer();
const decryptedBlob = await decryptFile(encData, mimeType, encInfo);
return URL.createObjectURL(decryptedBlob);
}
return httpUrl;
};
export const getSrcFile = async (src: string): Promise<Blob> => {
const res = await fetch(src, { method: 'GET' });
const blob = await res.blob();
return blob;
};

View File

@@ -24,6 +24,10 @@ export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...pro
<AsUsername className={classNames(css.Username, className)} {...props} ref={ref} />
));
export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...props }, ref) => (
<AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} />
));
export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?: boolean }>(
({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
<Text

View File

@@ -157,6 +157,10 @@ export const Username = style({
},
});
export const UsernameBold = style({
fontWeight: 550,
});
export const MessageTextBody = recipe({
base: {
wordBreak: 'break-word',

View File

@@ -1,22 +1,27 @@
import React from 'react';
import { as, toRem } from 'folds';
import React, { useMemo } from 'react';
import { as, ContainerColor, toRem } from 'folds';
import { randomNumberBetween } from '../../../utils/common';
import { LinePlaceholder } from './LinePlaceholder';
import { CompactLayout, MessageBase } from '../layout';
import { CompactLayout } from '../layout';
export const CompactPlaceholder = as<'div'>(({ ...props }, ref) => (
<MessageBase>
<CompactLayout
{...props}
ref={ref}
before={
<>
<LinePlaceholder style={{ maxWidth: toRem(50) }} />
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(40, 100)) }} />
</>
}
>
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(120, 500)) }} />
</CompactLayout>
</MessageBase>
));
export const CompactPlaceholder = as<'div', { variant?: ContainerColor }>(
({ variant, ...props }, ref) => {
const nameSize = useMemo(() => randomNumberBetween(40, 100), []);
const msgSize = useMemo(() => randomNumberBetween(120, 500), []);
return (
<CompactLayout
{...props}
ref={ref}
before={
<>
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(50) }} />
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(nameSize) }} />
</>
}
>
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(msgSize) }} />
</CompactLayout>
);
}
);

View File

@@ -1,25 +1,39 @@
import React, { CSSProperties } from 'react';
import { Avatar, Box, as, color, toRem } from 'folds';
import React, { CSSProperties, useMemo } from 'react';
import { Avatar, Box, ContainerColor, as, color, toRem } from 'folds';
import { randomNumberBetween } from '../../../utils/common';
import { LinePlaceholder } from './LinePlaceholder';
import { MessageBase, ModernLayout } from '../layout';
import { ModernLayout } from '../layout';
const contentMargin: CSSProperties = { marginTop: toRem(3) };
const avatarBg: CSSProperties = { backgroundColor: color.SurfaceVariant.Container };
export const DefaultPlaceholder = as<'div'>(({ ...props }, ref) => (
<MessageBase>
<ModernLayout {...props} ref={ref} before={<Avatar style={avatarBg} size="300" />}>
<Box style={contentMargin} grow="Yes" direction="Column" gap="200">
<Box grow="Yes" gap="200" alignItems="Center" justifyContent="SpaceBetween">
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(40, 100)) }} />
<LinePlaceholder style={{ maxWidth: toRem(50) }} />
export const DefaultPlaceholder = as<'div', { variant?: ContainerColor }>(
({ variant, ...props }, ref) => {
const nameSize = useMemo(() => randomNumberBetween(40, 100), []);
const msgSize = useMemo(() => randomNumberBetween(80, 200), []);
const msg2Size = useMemo(() => randomNumberBetween(80, 200), []);
return (
<ModernLayout
{...props}
ref={ref}
before={
<Avatar
style={{ backgroundColor: color[variant ?? 'SurfaceVariant'].Container }}
size="300"
/>
}
>
<Box style={contentMargin} grow="Yes" direction="Column" gap="200">
<Box grow="Yes" gap="200" alignItems="Center" justifyContent="SpaceBetween">
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(nameSize) }} />
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(50) }} />
</Box>
<Box grow="Yes" gap="200" wrap="Wrap">
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(msgSize) }} />
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(msg2Size) }} />
</Box>
</Box>
<Box grow="Yes" gap="200" wrap="Wrap">
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(80, 200)) }} />
<LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(80, 200)) }} />
</Box>
</Box>
</ModernLayout>
</MessageBase>
));
</ModernLayout>
);
}
);

View File

@@ -1,12 +1,35 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
import { ComplexStyleRule } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { ContainerColor, DefaultReset, color, config, toRem } from 'folds';
export const LinePlaceholder = style([
DefaultReset,
{
width: '100%',
height: toRem(16),
borderRadius: config.radii.R300,
backgroundColor: color.SurfaceVariant.Container,
const getVariant = (variant: ContainerColor): ComplexStyleRule => ({
backgroundColor: color[variant].Container,
});
export const LinePlaceholder = recipe({
base: [
DefaultReset,
{
width: '100%',
height: toRem(16),
borderRadius: config.radii.R300,
},
],
variants: {
variant: {
Background: getVariant('Background'),
Surface: getVariant('Surface'),
SurfaceVariant: getVariant('SurfaceVariant'),
Primary: getVariant('Primary'),
Secondary: getVariant('Secondary'),
Success: getVariant('Success'),
Warning: getVariant('Warning'),
Critical: getVariant('Critical'),
},
},
]);
defaultVariants: {
variant: 'SurfaceVariant',
},
});
export type LinePlaceholderVariants = RecipeVariants<typeof LinePlaceholder>;

View File

@@ -3,6 +3,13 @@ import { Box, as } from 'folds';
import classNames from 'classnames';
import * as css from './LinePlaceholder.css';
export const LinePlaceholder = as<'div'>(({ className, ...props }, ref) => (
<Box className={classNames(css.LinePlaceholder, className)} shrink="No" {...props} ref={ref} />
));
export const LinePlaceholder = as<'div', css.LinePlaceholderVariants>(
({ className, variant, ...props }, ref) => (
<Box
className={classNames(css.LinePlaceholder({ variant }), className)}
shrink="No"
{...props}
ref={ref}
/>
)
);

View File

@@ -27,14 +27,14 @@ export function PageRoot({ nav, children }: PageRootProps) {
type ClientDrawerLayoutProps = {
children: ReactNode;
};
export function PageNav({ children }: ClientDrawerLayoutProps) {
export function PageNav({ size, children }: ClientDrawerLayoutProps & css.PageNavVariants) {
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
return (
<Box
grow={isMobile ? 'Yes' : undefined}
className={css.PageNav}
className={css.PageNav({ size })}
shrink={isMobile ? 'Yes' : 'No'}
>
<Box grow="Yes" direction="Column">
@@ -44,15 +44,17 @@ export function PageNav({ children }: ClientDrawerLayoutProps) {
);
}
export const PageNavHeader = as<'header'>(({ className, ...props }, ref) => (
<Header
className={classNames(css.PageNavHeader, className)}
variant="Background"
size="600"
{...props}
ref={ref}
/>
));
export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
({ className, outlined, ...props }, ref) => (
<Header
className={classNames(css.PageNavHeader({ outlined }), className)}
variant="Background"
size="600"
{...props}
ref={ref}
/>
)
);
export function PageNavContent({
scrollRef,
@@ -88,11 +90,11 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
));
export const PageHeader = as<'div', css.PageHeaderVariants>(
({ className, balance, ...props }, ref) => (
({ className, outlined, balance, ...props }, ref) => (
<Header
as="header"
size="600"
className={classNames(css.PageHeader({ balance }), className)}
className={classNames(css.PageHeader({ balance, outlined }), className)}
{...props}
ref={ref}
/>

View File

@@ -2,30 +2,55 @@ import { style } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
export const PageNav = style({
width: toRem(256),
});
export const PageNavHeader = style({
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
flexShrink: 0,
borderBottomWidth: 1,
selectors: {
'button&': {
cursor: 'pointer',
},
'button&[aria-pressed=true]': {
backgroundColor: color.Background.ContainerActive,
},
'button&:hover, button&:focus-visible': {
backgroundColor: color.Background.ContainerHover,
},
'button&:active': {
backgroundColor: color.Background.ContainerActive,
export const PageNav = recipe({
variants: {
size: {
'400': {
width: toRem(256),
},
'300': {
width: toRem(222),
},
},
},
defaultVariants: {
size: '400',
},
});
export type PageNavVariants = RecipeVariants<typeof PageNav>;
export const PageNavHeader = recipe({
base: {
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
flexShrink: 0,
selectors: {
'button&': {
cursor: 'pointer',
},
'button&[aria-pressed=true]': {
backgroundColor: color.Background.ContainerActive,
},
'button&:hover, button&:focus-visible': {
backgroundColor: color.Background.ContainerHover,
},
'button&:active': {
backgroundColor: color.Background.ContainerActive,
},
},
},
variants: {
outlined: {
true: {
borderBottomWidth: 1,
},
},
},
defaultVariants: {
outlined: true,
},
});
export type PageNavHeaderVariants = RecipeVariants<typeof PageNavHeader>;
export const PageNavContent = style({
minHeight: '100%',
@@ -38,7 +63,6 @@ export const PageHeader = recipe({
base: {
paddingLeft: config.space.S400,
paddingRight: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
},
variants: {
balance: {
@@ -46,6 +70,14 @@ export const PageHeader = recipe({
paddingLeft: config.space.S200,
},
},
outlined: {
true: {
borderBottomWidth: config.borderWidth.B300,
},
},
},
defaultVariants: {
outlined: true,
},
});
export type PageHeaderVariants = RecipeVariants<typeof PageHeader>;

View File

@@ -6,7 +6,7 @@ type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> &
size: '400' | '500';
};
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
({ variant, size, style, after, ...props }, ref) => {
({ variant = 'Background', size, style, after, ...props }, ref) => {
const paddingRight: string = size === '500' ? config.space.S300 : config.space.S200;
return (

View File

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

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