Compare commits

...

246 Commits

Author SHA1 Message Date
Ajay Bura
3ed8260877 Release v4.8.1 (#2360) 2025-06-10 23:48:55 +10:00
Ajay Bura
44347db6e4 Add allow from currently selected space if no m.space.parent found (#2359) 2025-06-10 23:47:46 +10:00
Ajay Bura
91632aa193 Fix space navigation & view space timeline dev-option (#2358)
* fix inaccessible space on alias change

* fix new room in space open in home

* allow opening space timeline

* hide event timeline feature behind dev tool

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

* add comment

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

* filter invites and add more options

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

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

* Add command for deleting multiple messages

* remove console logs and improve ban command description

* improve commands description

* add server acl command

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

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

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

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

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

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

* add chroma js library

* add hook for accessible tag color

* disable reply user color - temporary

* render user color based on tag in room timeline

* remove default tag icons

* move accessible color function to plugins

* render user power color in reply

* increase username weight in timeline

* add default color for member power level tag

* show red slash in power color badge with no color

* show power level color in room input reply

* show power level username color in notifications

* show power level color in notification reply

* show power level color in message search

* render power level color in room pin menu

* add toggle for legacy username colors

* drop over saturation from member default color

* change border color of power color badge

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

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

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

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

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

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

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

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

* join rule setting - WIP

* show emojis & stickers in room settings - WIP

* restyle join rule switcher

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

* add join rule hook

* open room settings from global state

* open new room settings from all places

* rearrange settings menu item

* add option for creating new image pack

* room devtools - WIP

* render room state events as list

* add option to open state event

* add option to edit state event

* refactor text area code editor into hook

* add option to send message and state event

* add cutout card component

* add hook for room account data

* display room account data - WIP

* refactor global account data editor component

* add account data editor in room

* fix font style in devtool

* show state events in compact form

* add option to delete room image pack

* add server badge component

* add member tile component

* render members in room settings

* add search in room settings member

* add option to reset member search

* add filter in room members

* fix member virtual item key

* remove color from serve badge in room members

* show room in settings

* fix loading indicator position

* power level tags in room setting - WIP

* generate fallback tag in backward compatible way

* add color picker

* add powers editor - WIP

* add props to stop adding emoji to recent usage

* add beta feature notice badge

* add types for power level tag icon

* refactor image pack rooms code to hook

* option for adding new power levels tags

* remove console log

* refactor power icon

* add option to edit power level tags

* remove power level from powers pill

* fix power level labels

* add option to delete power levels

* fix long power level name shrinks power integer

* room permissions - WIP

* add power level selector component

* add room permissions

* move user default permission setting to other group

* add power permission peek menu

* fix weigh of power switch text

* hide above for max power in permission switcher

* improve beta badge description

* render room profile in room settings

* add option to edit room profile

* make room topic input text area

* add option to enable room encryption in room settings

* add option to change message history visibility

* add option to change join rule

* add option for addresses in room settings

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* open account data inside setting window

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

* stop sending/receiving typing status

* send private read receipt when setting toggle is activated

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

* add image preview on upload

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

* collapse autocomplete on escape

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

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

* Less jank typing

* Mention the reply author in m.mentions

* Improve typing

* Fix typing in m.mentions finder

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

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

* Formatting

* Add intentional mentions to edited messages

* refactor reusable code and fix todo

* parse mentions from all nodes

---------

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

* Clarify variable names and wording

* Restore list atom

* Improve spoilered image UX with autoload off

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

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

* add enable param to level hierarchy hook

* improve HierarchyItem types

* fix type errors in lobby

* load space hierarachy per level

* fix menu item visibility

* fix unknown spaces over federation

* show inaccessible rooms only to admins

* fix unknown room renders loading content twice

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

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

* fix typo

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

* recover inline escape sequences on edit

* remove escape sequences from plain text body

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

* escape block markdown sequences

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

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

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

* add hook for email notification status

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

* remove search limit from user mention

* remove limit from room mention autocomplete

* increase user search limit to 1000

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Update General.tsx

* Update General.tsx

---------

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

* fix all pinned message show edited

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

* access notification from window variable

* fix Notification check

* catch notification variable error

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

* account settings - WIP

* add missing key prop

* add object url hook

* extract wide modal styles

* profile settings and image editor - WIP

* add outline style to upload card

* remove file param from bind upload atom hook

* add compact variant to upload card

* add  compact upload card renderer

* add option to update profile avatar

* add option to change profile displayname

* allow displayname change based on capabilities check

* rearrange settings components into folders

* add system notification settings

* add initial page param in settings

* convert account data hook to typescript

* add push rule hook

* add notification mode hook

* add notification mode switcher component

* add all messages notification settings options

* add special messages notification settings

* add keyword notifications

* add ignored users section

* improve ignore user list strings

* add about settings

* add access token option in about settings

* add developer tools settings

* add expand button to account data dev tool option

* update folds

* fix editable active element textarea check

* do not close dialog when editable element in focus

* add text area plugins

* add text area intent handler hook

* add newline intent mod in text area

* add next line hotkey in text area intent hook

* add syntax error position dom utility function

* add account data editor

* add button to send new account data in dev tools

* improve custom emoji plugin

* add more custom emojis hooks

* add text util css

* add word break in setting tile title and description

* emojis and sticker user settings - WIP

* view image packs from settings

* emoji pack editing - WIP

* add option to edit pack meta

* change saved changes message

* add image edit and delete controls

* add option to upload pack images and apply changes

* fix state event type when updating image pack

* lazy load pack image tile img

* hide upload image button when user can not edit pack

* add option to add or remove global image packs

* upgrade to rust crypto (#2168)

* update matrix js sdk

* remove dead code

* use rust crypto

* update setPowerLevel usage

* fix types

* fix deprecated isRoomEncrypted method uses

* fix deprecated room.currentState uses

* fix deprecated import/export room keys func

* fix merge issues in image pack file

* fix remaining issues in image pack file

* start indexedDBStore

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

* user session settings - WIP

* add useAsync hook

* add password stage uia

* add uia flow matrix error hook

* add UIA action component

* add options to delete sessions

* add sso uia stage

* fix SSO stage complete error

* encryption - WIP

* update user settings encryption terminology

* add default variant to password input

* use password input in uia password stage

* add options for local backup in user settings

* remove typo in import local backup password input label

* online backup - WIP

* fix uia sso action

* move access token settings from about to developer tools

* merge encryption tab into sessions and rename it to devices

* add device placeholder tile

* add logout dialog

* add logout button for current device

* move other devices in component

* render unverified device verification tile

* add learn more section for current device verification

* add device verification status badge

* add info card component

* add index file for password input component

* add types for secret storage

* add component to access secret storage key

* manual verification - WIP

* update matrix-js-sdk to v35

* add manual verification

* use react query for device list

* show unverified tab on sidebar

* fix device list updates

* add session key details to current device

* render restore encryption backup

* fix loading state of restore backup

* fix unverified tab settings closes after verification

* key backup tile - WIP

* fix unverified tab badge

* rename session key to device key in device tile

* improve backup restore functionality

* fix restore button enabled after layout reload during restoring backup

* update backup info on status change

* add backup disconnection failures

* add device verification using sas

* restore backup after verification

* show option to logout on startup error screen

* fix key backup hook update on decryption key cached

* add option to enable device verification

* add device verification reset dialog

* add logout button in settings drawer

* add encrypted message lost on logout

* fix backup restore never finish with 0 keys

* fix setup dialog hides when enabling device verification

* show backup details in menu

* update setup device verification body copy

* replace deprecated method

* fix displayname appear as mxid in settings

* remove old refactored codes

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

* add more checks around membership change

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

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

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

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

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

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

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

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

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

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

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

* Handle sending captions for images

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

* Remove unused renderBody() parameter

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

* Add caption rendering for generic files

+ Fix video and audio not properly sending captions

* Use m.text for captions & render on demand

* Allow custom HTML in sending captions

* Don't *send* captions

* mvoe content const into renderCaption()

---------

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

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

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

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

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

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

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

* feat: sort autocomplete emojis

Fixes #1632

* fix: sort function after concat

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

* room pinned message - WIP

* add room event hook

* fetch pinned messages before displaying

* use react-query in room event hook

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

* use room event hook in reply component

* render pinned messages

* add option to pin/unpin messages

* remove message base from message placeholders and add variant

* display message placeholder while loading pinned messages

* render pinned event error

* show no pinned message placeholder

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

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

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

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

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

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

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

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

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

* fix media download in room timeline

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

* add media authentication hook

* fix service worker types

* fix service worker not working in dev mode

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

* feat: Authenticated media support

* chore: Use Vite PWA for service worker support

* fix: Fix Vite PWA SW entry point

Forget this. :P

* fix: Also add Nginx rewrite for sw.js

* fix: Correct Nginx rewrite

* fix: Add Netlify redirect for sw.js

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

* fix: Account for subpath when regisering service worker

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

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

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

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

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

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

* Fix types

* Update to v4

* Fix auto formatting mess

* Add threaded reply indicators

* Fix reply overflow

* Fix replying to edited threaded replies

* Add thread indicator to room input

* Fix editing encrypted events

* Use `toRem` function for converting units

---------

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

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

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

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

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

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

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

* List the alternative

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

* change copy link to matrix.to links

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

* improve editor mention to include viaServers and eventId

* fix mention custom attributes

* always try to open room in current space

* jump to latest remove target eventId from url

* add create direct search options to open/create dm with url
2024-07-30 22:18:59 +10:00
Ajay Bura
74dc76e22e fix room opens at home after leave rejoin (#1848) 2024-07-28 23:40:21 +10:00
Krishan
44161c4157 Release v4.0.3 (#1840) 2024-07-25 15:54:58 +10:00
Krishan
e8d04c0603 Update gpg public key after renew (#1839) 2024-07-25 10:58:14 +05:30
Krishan
96415a8d2a Release v4.0.0 (#1836)
* Release v4.0.0

* add more rooms in featured
2024-07-24 18:30:49 +05:30
Ajay Bura
2157f9a322 add ngnix conf file for docker build (#1837) 2024-07-24 22:51:03 +10:00
Ajay Bura
b387370aaf Add setting for page zoom (#1835)
* add setting for page zoom

* parse integer in zoom change listener

* fix zoom input width

* fix null gets saved as page zoom
2024-07-23 23:52:53 +10:00
dependabot[bot]
3110505b21 Bump docker/setup-qemu-action from 3.1.0 to 3.2.0 (#1830)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.1.0 to 3.2.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3.1.0...v3.2.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>
2024-07-23 15:17:12 +10:00
dependabot[bot]
da536c8c3f Bump docker/login-action from 3.2.0 to 3.3.0 (#1831)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.2.0 to 3.3.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3.2.0...v3.3.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>
2024-07-23 15:16:56 +10:00
dependabot[bot]
98a378ad8a Bump docker/setup-buildx-action from 3.4.0 to 3.5.0 (#1832)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.4.0 to 3.5.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.4.0...v3.5.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>
2024-07-23 15:16:38 +10:00
dependabot[bot]
ab73225f00 Bump softprops/action-gh-release from 2.0.6 to 2.0.8 (#1833)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.0.6 to 2.0.8.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](a74c6b72af...c062e08bd5)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  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-07-23 15:16:16 +10:00
dependabot[bot]
cc4c222975 Bump docker/build-push-action from 6.4.0 to 6.5.0 (#1834)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.4.0 to 6.5.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.4.0...v6.5.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-07-23 15:15:51 +10:00
Ajay Bura
a32c8bf228 Load room member even when member drawer is closed (#1825) 2024-07-23 15:15:17 +10:00
Ajay Bura
e6d6b0349e Fix unread reset and notification settings (#1824)
* reset unread with client sync state change

* fix notification toggle setting not working

* revert formatOnSave vscode setting
2024-07-23 15:14:32 +10:00
Ajay Bura
e2228a18c1 handle error in loading screen (#1823)
* handle client boot error in loading screen

* use sync state hook in client root

* add loading screen options

* removed extra condition in loading finish

* add sync connection status bar
2024-07-22 20:47:19 +10:00
dependabot[bot]
e046c59f7c Bump docker/setup-buildx-action from 3.3.0 to 3.4.0 (#1814)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.3.0 to 3.4.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.3.0...v3.4.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>
2024-07-21 15:44:43 +10:00
dependabot[bot]
fbe27d69c0 Bump docker/build-push-action from 6.3.0 to 6.4.0 (#1815)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.3.0 to 6.4.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.3.0...v6.4.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-07-21 15:44:27 +10:00
dependabot[bot]
021a2c0e2e Bump actions/setup-node from 4.0.2 to 4.0.3 (#1816)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.0.2 to 4.0.3.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4.0.2...v4.0.3)

---
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-07-21 15:43:58 +10:00
Ajay Bura
c243b6104c Update color theme to match with new design (#1821)
* update silver theme

* update unread badge style to look more slim

* update nav item style to look less sharp

* fix type focus message input typo

* decrease navigation drawer width to bring main chat layout to little more center

* increase sidebar width to make it less congested

* fix sidebar item style

* decrease dark theme contrast

* improve dark theme

* revert sidebar width change

* add join with address option in home context menu

* match legacy theme with latest themes
2024-07-21 15:43:33 +10:00
Ajay Bura
a1a822c5b6 Fix selecting tombstone room opens replacement room (#1820) 2024-07-18 23:20:51 +10:00
Ajay Bura
c4abe39375 Make hotkeys work again (#1819) 2024-07-18 23:20:20 +10:00
Ajay Bura
c52c4f7d32 fix crash when adding existing room to space (#1806) 2024-07-15 00:21:19 +10:00
Ajay Bura
653ddd9f11 fix space lobby button shrink 2024-07-10 18:44:28 +05:30
dependabot[bot]
e854b88394 Bump formik from 2.2.9 to 2.4.6 (#1715)
Bumps [formik](https://github.com/jaredpalmer/formik) from 2.2.9 to 2.4.6.
- [Release notes](https://github.com/jaredpalmer/formik/releases)
- [Commits](https://github.com/jaredpalmer/formik/compare/formik@2.2.9...formik@2.4.6)

---
updated-dependencies:
- dependency-name: formik
  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-07-09 23:49:06 +10:00
dependabot[bot]
66478143df Bump linkify-react from 4.1.1 to 4.1.3 (#1742)
updated-dependencies:
- dependency-name: linkify-react
  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-07-09 23:43:53 +10:00
dependabot[bot]
4b461f87ff Bump linkifyjs from 4.0.2 to 4.1.3 (#1672)
Bumps [linkifyjs](https://github.com/Hypercontext/linkifyjs/tree/HEAD/packages/linkifyjs) from 4.0.2 to 4.1.3.
- [Release notes](https://github.com/Hypercontext/linkifyjs/releases)
- [Changelog](https://github.com/Hypercontext/linkifyjs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Hypercontext/linkifyjs/commits/v4.1.3/packages/linkifyjs)

---
updated-dependencies:
- dependency-name: linkifyjs
  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-07-09 23:40:46 +10:00
dependabot[bot]
fc2b5744f4 Bump react-error-boundary from 4.0.10 to 4.0.13 (#1664)
Bumps [react-error-boundary](https://github.com/bvaughn/react-error-boundary) from 4.0.10 to 4.0.13.
- [Release notes](https://github.com/bvaughn/react-error-boundary/releases)
- [Commits](https://github.com/bvaughn/react-error-boundary/compare/4.0.10...4.0.13)

---
updated-dependencies:
- dependency-name: react-error-boundary
  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-07-09 23:36:45 +10:00
dependabot[bot]
65ad070878 Bump docker/build-push-action from 6.0.0 to 6.3.0 (#1799)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.0.0 to 6.3.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.0.0...v6.3.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-07-09 23:25:38 +10:00
dependabot[bot]
f1668999a5 Bump docker/setup-qemu-action from 3.0.0 to 3.1.0 (#1798)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.0.0 to 3.1.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3.0.0...v3.1.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>
2024-07-09 23:24:56 +10:00
dependabot[bot]
9db81d1913 Bump actions/upload-artifact from 4.3.3 to 4.3.4 (#1797)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.3.3 to 4.3.4.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.3.3...v4.3.4)

---
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-07-09 23:23:56 +10:00
dependabot[bot]
7c795b800d Bump softprops/action-gh-release from 2.0.5 to 2.0.6 (#1785)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.0.5 to 2.0.6.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](69320dbe05...a74c6b72af)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  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-07-09 23:23:18 +10:00
Ajay Bura
e058a9ae6c fix notification, favicon and sound (#1802) 2024-07-09 22:50:33 +10:00
Ajay Bura
4f09e6bbb5 (chore) remove outdated code (#1765)
* optimize room typing members hook

* remove unused code - WIP

* remove old code from initMatrix

* remove twemojify function

* remove old sanitize util

* delete old markdown util

* delete Math atom component

* uninstall unused dependencies

* remove old notification system

* decrypt message in inbox notification center and fix refresh in background

* improve notification

---------

Co-authored-by: Krishan <33421343+kfiven@users.noreply.github.com>
2024-07-08 21:27:10 +10:00
dependabot[bot]
60e022035f Bump actions/checkout from 4.1.6 to 4.1.7 (#1775)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.1.6 to 4.1.7.
- [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.6...v4.1.7)

---
updated-dependencies:
- dependency-name: actions/checkout
  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-06-19 14:02:12 +10:00
dependabot[bot]
7a3e8dba92 Bump docker/build-push-action from 5.4.0 to 6.0.0 (#1777)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5.4.0 to 6.0.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5.4.0...v6.0.0)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  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>
2024-06-19 14:00:11 +10:00
dependabot[bot]
c4615bd256 Bump dawidd6/action-download-artifact from 3.1.4 to 6 (#1776)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 3.1.4 to 6.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](09f2f74827...bf251b5aa9)

---
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>
2024-06-19 13:55:34 +10:00
dependabot[bot]
b6157707db Bump docker/build-push-action from 5.3.0 to 5.4.0 (#1766)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5.3.0 to 5.4.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5.3.0...v5.4.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-06-15 23:28:03 +10:00
Kimiblock Moe
09a0a2d7da Prevent Safari iOS from auto zooming (#1756)
Thanks @pixlxip:beeper.com
2024-06-05 18:13:19 +05:30
dependabot[bot]
9db4b3a9c2 Bump docker/login-action from 3.1.0 to 3.2.0 (#1758)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.1.0 to 3.2.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3.1.0...v3.2.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>
2024-06-04 14:26:21 +10:00
dependabot[bot]
6987332ba8 Bump nginx from 1.26.0-alpine to 1.27.0-alpine (#1759)
Bumps nginx from 1.26.0-alpine to 1.27.0-alpine.

---
updated-dependencies:
- dependency-name: nginx
  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-06-04 14:25:07 +10:00
Ajay Bura
4c76a7fd18 URL navigation in interface and other improvements (#1633)
* load room on url change

* add direct room list

* render space room list

* fix css syntax error

* update scroll virtualizer

* render subspaces room list

* improve sidebar notification badge perf

* add nav category components

* add space recursive direct component

* use nav category component in home, direct and space room list

* add empty home and direct list layout

* fix unread room menu ref

* add more navigation items in room, direct and space tab

* add more navigation

* fix unread room menu to links

* fix space lobby and search link

* add explore navigation section

* add notifications navigation menu

* redirect to initial path after login

* include unsupported room in rooms

* move router hooks in hooks/router folder

* add featured explore - WIP

* load featured room with room summary

* fix room card topic line clamp

* add react query

* load room summary using react query

* add join button in room card

* add content component

* use content component in featured community content

* fix content width

* add responsive room card grid

* fix async callback error status

* add room card error button

* fix client drawer shrink

* add room topic viewer

* open room card topic in viewer

* fix room topic close btn

* add get orphan parent util

* add room card error dialog

* add view featured room or space btn

* refactor orphanParent to orphanParents

* WIP - explore server

* show space hint in room card

* add room type filters

* add per page item limit popout

* reset scroll on public rooms load

* refactor explore ui

* refactor public rooms component

* reset search on server change

* fix typo

* add empty featured section info

* display user server on top

* make server room card view btn clickable

* add user server as default redirect for explore path

* make home empty btn clickable

* add thirdparty instance filter in server explore

* remove since param on instance change

* add server button in explore menu

* rename notifications path to inbox

* update react-virtual

* Add notification messages inbox - WIP

* add scroll top container component

* add useInterval hook

* add visibility change callback prop to scroll top container component

* auto refresh notifications every 10 seconds

* make message related component reusable

* refactor matrix event renderer hoook

* render notification message content

* refactor matrix event renderer hook

* update sequence card styles

* move room navigate hook in global hooks

* add open message button in notifications

* add mark room as read button in notification group

* show error in notification messages

* add more featured spaces

* render reply in notification messages

* make notification message reply clickable

* add outline prop for attachments

* make old settings dialog viewable

* add open featured communities as default config option

* add invite count notification badge in sidebar and inbox menu

* add element size observer hook

* improve element size observer hook props

* improve screen size hook

* fix room avatar util function

* allow Text props in Time component

* fix dm room util function

* add invitations

* add no invites and notification cards

* fix inbox tab unread badge visible without invite count

* update folds and change inbox icon

* memo search param construction

* add message search in home

* fix default message search order

* fix display edited message new content

* highlight search text in search messages

* fix message search loading

* disable log in production

* add use space context

* add useRoom context

* fix space room list

* fix inbox tab active state

* add hook to get space child room recursive

* add search for space

* add virtual tile component

* virtualize home and directs room list

* update nav category component

* use virtual tile component in more places

* fix message highlight when click on reply twice

* virtualize space room list

* fix space room list lag issue

* update folds

* add room nav item component in space room list

* use room nav item in home and direct room list

* make space categories closable and save it in local storage

* show unread room when category is collapsed

* make home and direct room list category closable

* rename room nav item show avatar prop

* fix explore server category text alignment

* rename closedRoomCategories to closedNavCategories

* add nav category handler hook

* save and restore last navigation path on space select

* filter space rooms category by activity when it is closed

* save and restore home and direct nav path state

* save and restore inbox active path on open

* save and restore explore tab active path

* remove notification badge unread menu

* add join room or space before navigate screen

* move room component to features folder and add new room header

* update folds

* add room header menu

* fix home room list activity sorting

* do not hide selected room item on category closed in home and direct tab

* replace old select room/tab call with navigate hook

* improve state event hooks

* show room card summary for joined rooms

* prevent room from opening in wrong tab

* only show message sender id on hover in modern layout

* revert state event hooks changes

* add key prop to room provider components

* add welcome page

* prevent excessive redirects

* fix sidebar style with no spaces

* move room settings in popup window

* remove invite option from room settings

* fix open room list search

* add leave room prompt

* standardize room and user avatar

* fix avatar text size

* add new reply layout

* rename space hierarchy hook

* add room topic hook

* add room name hook

* add room avatar hook and add direct room avatar util

* space lobby - WIP

* hide invalid space child event from space hierarchy in lobby

* move lobby to features

* fix element size observer hook width and height

* add lobby header and hero section

* add hierarchy room item error and loading state

* add first and last child prop in sequence card

* redirect to lobby from index path

* memo and retry hierarchy room summary error

* fix hierarchy room item styles

* rename lobby hierarchy item card to room item card

* show direct room avatar in space lobby

* add hierarchy space item

* add space item unknown room join button

* fix space hierarchy hook refresh after new space join

* change user avatar color and fallback render to user icon

* change room avatar fallback to room icon

* rename room/user avatar renderInitial prop to renderFallback

* add room join and view button in space lobby

* make power level api more reusable

* fix space hierarchy not updating on child update

* add menu to suggest or remove space children

* show reply arrow in place of reply bend in message

* fix typeerror in search because of wrong js-sdk t.ds

* do not refetch hierarchy room summary on window focus

* make room/user avatar un-draggable

* change welcome page support button copy

* drag-and-drop ordering of lobby spaces/rooms - WIP

* add ASCIILexicalTable algorithms

* fix wrong power level check in lobby items options

* fix lobby can drop checks

* fix join button error crash

* fix reply spacing

* fix m direct updated with other account data

* add option to open room/space settings from lobby

* add option in lobby to add new or existing room/spaces

* fix room nav item selected styles

* add space children reorder mechanism

* fix space child reorder bug

* fix hierarchy item sort function

* Apply reorder of lobby into room list

* add and improve space lobby menu items

* add existing spaces menu in lobby

* change restricted room allow params when dragging outside space

* move featured servers config from homeserver list

* removed unused features from space settings

* add canonical alias as name fallback in lobby item

* fix unreliable unread count update bug

* fix after login redirect

* fix room card topic hover style

* Add dnd and folders in sidebar spaces

* fix orphan space not visible in sidebar

* fix sso login has mix of icon and button

* fix space children not  visible in home upon leaving space

* recalculate notification on updating any space child

* fix user color saturation/lightness

* add user color to user avatar

* add background colors to room avatar

* show 2 length initial in sidebar space avatar

* improve link color

* add nav button component

* open legacy create room and create direct

* improve page route structure

* handle hash router in path utils

* mobile friendly router and navigation

* make room header member drawer icon mobile friendly

* setup index redirect for inbox and explore server route

* add leave space prompt

* improve member drawer filter menu

* add space context menu

* add context menu in home

* add leave button in lobby items

* render user tab avatar on sidebar

* force overwrite netlify - test

* netlify test

* fix reset-password path without server redirected to login

* add message link copy button in message menu

* reset unread on sync prepared

* fix stuck typing notifications

* show typing indication in room nav item

* refactor closedNavCategories atom to use userId in store key

* refactor closedLobbyCategoriesAtom to include userId in store key

* refactor navToActivePathAtom to use userId in storage key

* remove unused file

* refactor openedSidebarFolderAtom to include userId in storage key

* add context menu for sidebar space tab

* fix eslint not working

* add option to pin/unpin child spaces

* add context menu for directs tab

* add context menu for direct and home tab

* show lock icon for non-public space in header

* increase matrix max listener count

* wrap lobby add space room in callback hook
2024-06-01 00:19:46 +10:00
Majan Paul
2b7d825694 Ignroe webstorm idea folder (#1638) 2024-05-22 21:56:44 +10:00
dependabot[bot]
07bfa0cf10 --- (#1741)
updated-dependencies:
- dependency-name: actions/checkout
  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-05-22 21:54:48 +10:00
dependabot[bot]
e15b16b19b Bump nginx from 1.25.5-alpine to 1.26.0-alpine (#1718)
Bumps nginx from 1.25.5-alpine to 1.26.0-alpine.

---
updated-dependencies:
- dependency-name: nginx
  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-05-14 14:18:40 +10:00
dependabot[bot]
76d60b0958 Bump vite-plugin-static-copy from 0.13.0 to 1.0.4 (#1722)
* Bump vite-plugin-static-copy from 0.13.0 to 1.0.4

Bumps [vite-plugin-static-copy](https://github.com/sapphi-red/vite-plugin-static-copy) from 0.13.0 to 1.0.4.
- [Release notes](https://github.com/sapphi-red/vite-plugin-static-copy/releases)
- [Changelog](https://github.com/sapphi-red/vite-plugin-static-copy/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sapphi-red/vite-plugin-static-copy/compare/v0.13.0...vite-plugin-static-copy@1.0.4)

---
updated-dependencies:
- dependency-name: vite-plugin-static-copy
  dependency-type: direct:development
  update-type: version-update:semver-major
...

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

* Change type to module

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Krishan <33421343+kfiven@users.noreply.github.com>
2024-05-14 14:01:45 +10:00
aceArt-GmbH
97d02fd7c8 Scroll tab target into view (#1580) 2024-05-14 09:19:04 +05:30
dependabot[bot]
5817186129 Bump softprops/action-gh-release from 2.0.4 to 2.0.5 (#1734)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.0.4 to 2.0.5.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](9d7c94cfd0...69320dbe05)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  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-05-14 13:34:57 +10:00
dependabot[bot]
2d6dd3b0b2 Bump cla-assistant/github-action from 2.3.2 to 2.4.0 (#1735)
Bumps [cla-assistant/github-action](https://github.com/cla-assistant/github-action) from 2.3.2 to 2.4.0.
- [Release notes](https://github.com/cla-assistant/github-action/releases)
- [Commits](https://github.com/cla-assistant/github-action/compare/v2.3.2...v2.4.0)

---
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-05-14 13:33:58 +10:00
Krishan
cd5d8e1c20 Fix pdf opening (#1732) 2024-05-12 16:14:34 +10:00
dependabot[bot]
fe2332ee87 Bump actions/checkout from 4.1.4 to 4.1.5 (#1721)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.1.4 to 4.1.5.
- [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.4...v4.1.5)

---
updated-dependencies:
- dependency-name: actions/checkout
  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-05-12 14:39:43 +10:00
Krishan
215537a261 Fix crash when img without src tag (#1731) 2024-05-12 10:06:35 +05:30
renovate[bot]
ec65b98874 Update dependency eslint-plugin-import to v2.29.1 (#1730)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-12 14:27:02 +10:00
renovate[bot]
f1c4a38a49 Update dependency sanitize-html to v2.12.1 [SECURITY] (#1729)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-12 14:25:07 +10:00
Krishan
5259f11679 Remove svg loader as available in Vite by default (#1728) 2024-05-12 09:47:41 +05:30
dependabot[bot]
565a6563e1 Bump pdfjs-dist from 3.10.111 to 4.2.67 (#1717)
* Bump pdfjs-dist from 3.10.111 to 4.2.67

Bumps [pdfjs-dist](https://github.com/mozilla/pdfjs-dist) from 3.10.111 to 4.2.67.
- [Commits](https://github.com/mozilla/pdfjs-dist/commits)

---
updated-dependencies:
- dependency-name: pdfjs-dist
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

* Fix pdfjs top level await

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Krishan <33421343+kfiven@users.noreply.github.com>
2024-05-12 14:06:53 +10:00
dependabot[bot]
8267990e6f Bump docker/setup-buildx-action from 2.7.0 to 3.3.0 (#1710)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2.7.0 to 3.3.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2.7.0...v3.3.0)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  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>
2024-04-25 23:07:32 +10:00
dependabot[bot]
e8020acabf Bump actions/checkout from 4.1.3 to 4.1.4 (#1709)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.1.3 to 4.1.4.
- [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.3...v4.1.4)

---
updated-dependencies:
- dependency-name: actions/checkout
  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-04-25 23:07:12 +10:00
dependabot[bot]
e5b980fbc7 Bump thollander/actions-comment-pull-request from 2.4.3 to 2.5.0 (#1711)
Bumps [thollander/actions-comment-pull-request](https://github.com/thollander/actions-comment-pull-request) from 2.4.3 to 2.5.0.
- [Release notes](https://github.com/thollander/actions-comment-pull-request/releases)
- [Commits](1d3973dc4b...fabd468d3a)

---
updated-dependencies:
- dependency-name: thollander/actions-comment-pull-request
  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-04-25 23:06:48 +10:00
dependabot[bot]
b803ce99e3 Bump nwtgck/actions-netlify from 2.1.0 to 3.0.0 (#1708)
Bumps [nwtgck/actions-netlify](https://github.com/nwtgck/actions-netlify) from 2.1.0 to 3.0.0.
- [Release notes](https://github.com/nwtgck/actions-netlify/releases)
- [Changelog](https://github.com/nwtgck/actions-netlify/blob/develop/CHANGELOG.md)
- [Commits](7a92f00dde...4cbaf4c08f)

---
updated-dependencies:
- dependency-name: nwtgck/actions-netlify
  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>
2024-04-25 23:06:24 +10:00
dependabot[bot]
3ae1e58ff2 Bump softprops/action-gh-release from 1 to 2 (#1703)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](de2c0eb89a...9d7c94cfd0)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  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>
2024-04-25 23:00:52 +10:00
dependabot[bot]
ce347a0ff4 Bump docker/build-push-action from 4.1.1 to 5.3.0 (#1704)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 4.1.1 to 5.3.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v4.1.1...v5.3.0)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  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>
2024-04-25 22:58:56 +10:00
dependabot[bot]
d3f97ef93e Bump cla-assistant/github-action from 2.3.0 to 2.3.2 (#1705)
Bumps [cla-assistant/github-action](https://github.com/cla-assistant/github-action) from 2.3.0 to 2.3.2.
- [Release notes](https://github.com/cla-assistant/github-action/releases)
- [Commits](https://github.com/cla-assistant/github-action/compare/v2.3.0...v2.3.2)

---
updated-dependencies:
- dependency-name: cla-assistant/github-action
  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-04-25 22:57:30 +10:00
dependabot[bot]
53cd08f0da Bump dawidd6/action-download-artifact from 2.27.0 to 3.1.4 (#1706)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 2.27.0 to 3.1.4.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](246dbf436b...09f2f74827)

---
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>
2024-04-25 22:57:14 +10:00
dependabot[bot]
da5ebf7ab3 Bump actions/setup-node from 3.8.1 to 4.0.2 (#1707)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3.8.1 to 4.0.2.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v3.8.1...v4.0.2)

---
updated-dependencies:
- dependency-name: actions/setup-node
  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>
2024-04-25 22:56:29 +10:00
dependabot[bot]
ca3535b1a5 Bump docker/metadata-action from 4.6.0 to 5.5.1 (#1658)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 4.6.0 to 5.5.1.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Upgrade guide](https://github.com/docker/metadata-action/blob/master/UPGRADE.md)
- [Commits](https://github.com/docker/metadata-action/compare/v4.6.0...v5.5.1)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  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>
2024-04-25 22:31:41 +10:00
dependabot[bot]
2c1e51a8b8 Bump docker/login-action from 2.2.0 to 3.1.0 (#1661)
Bumps [docker/login-action](https://github.com/docker/login-action) from 2.2.0 to 3.1.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2.2.0...v3.1.0)

---
updated-dependencies:
- dependency-name: docker/login-action
  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>
2024-04-25 22:30:16 +10:00
dependabot[bot]
71b2859440 Bump docker/setup-qemu-action from 2.2.0 to 3.0.0 (#1662)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2.2.0 to 3.0.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v2.2.0...v3.0.0)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  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>
2024-04-25 22:28:46 +10:00
dependabot[bot]
1d799185d6 Bump actions/upload-artifact from 3.1.2 to 4.3.3 (#1698)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3.1.2 to 4.3.3.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v3.1.2...v4.3.3)

---
updated-dependencies:
- dependency-name: actions/upload-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>
2024-04-25 22:26:46 +10:00
dependabot[bot]
b97f410731 Bump nginx from 1.25.1-alpine to 1.25.5-alpine (#1700)
Bumps nginx from 1.25.1-alpine to 1.25.5-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>
2024-04-25 00:34:04 +10:00
dependabot[bot]
a18c2e5be1 Bump actions/checkout from 3.5.3 to 4.1.3 (#1699)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3.5.3 to 4.1.3.
- [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/v3.5.3...v4.1.3)

---
updated-dependencies:
- dependency-name: actions/checkout
  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>
2024-04-25 00:32:24 +10:00
Krishan
3025133d18 Update node to latest LTS (#1687)
* Update node to latest LTS

* Update node in Dockerfile
2024-04-25 00:31:01 +10:00
Arnaldo Gabriel
743e916d12 Fix placement of emoji/sticker buttons (#1693) 2024-04-24 18:14:32 +05:30
Ajay Bura
8c5a1d15cb fix negative audio duration info crash react-range (#1701) 2024-04-24 22:42:52 +10:00
renovate[bot]
372d4d5c34 chore(deps): update dependency vite to v5.0.13 [security] (#1680)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-08 15:18:29 +10:00
renovate[bot]
b0796f72d3 fix(deps): update dependency katex to v0.16.10 [security] (#1654)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-30 12:57:56 +11:00
Ajay Bura
689adde8ae fix: login with sso when app using hash router (#1631)
* fix login with sso when app using hash router

* disable hash router
2024-01-23 18:37:22 +05:30
Ajay Bura
983d533452 feat: check IndexedDB support (#1630)
* check indexed db support and display message

* fix typo
2024-01-23 18:36:55 +05:30
aceArt-GmbH
ef2733df48 Load assets from relative path (#1588) 2024-01-23 18:35:50 +05:30
Ajay Bura
20db27fa7e feat: URL navigation in auth (#1603)
* bump to react 18 and install react-router-dom

* Upgrade to react 18 root

* update vite

* add cs api's

* convert state/auth to ts

* add client config context

* add auto discovery context

* add spec version context

* add auth flow context

* add background dot pattern css

* add promise utils

* init url based routing

* update auth route server path as effect

* add auth server hook

* always use server from discovery info in context

* login - WIP

* upgrade jotai to v2

* add atom with localStorage util

* add multi account sessions atom

* add default IGNORE res to auto discovery

* add error type in async callback hook

* handle password login error

* fix async callback hook

* allow password login

* Show custom server not allowed error in mxId login

* add sso login component

* add token login

* fix hardcoded m.login.password in login func

* update server input on url change

* Improve sso login labels

* update folds

* fix async callback batching state update in safari

* wrap async callback set state in queueMicrotask

* wip

* wip - register

* arrange auth file structure

* add error codes

* extract filed error component form password login

* add register util function

* handle register flow - WIP

* update unsupported auth flow method reasons

* improve password input styles

* Improve UIA flow next stage calculation
complete stages can have any order so we will look for first stage which is not in completed

* process register UIA flow stages

* Extract register UIA stages component

* improve register error messages

* add focus trap & step count in UIA stages

* add reset password path and path utils

* add path with origin hook

* fix sso redirect url

* rename register token query param to token

* restyle auth screen header

* add reset password component - WIP

* add reset password form

* add netlify rewrites

* fix netlify file indentation

* test netlify redirect

* fix vite to include netlify toml

* add more netlify redirects

* add splat to public and assets path

* fix vite base name

* add option to use hash router in config and remove appVersion

* add splash screen component

* add client config loading and error screen

* fix server picker bug

* fix reset password email input type

* make auth page small screen responsive

* fix typo in reset password screen
2024-01-21 18:20:56 +05:30
Ajay Bura
bb88eb7154 Up-mx-js-sdk-29 (#1533)
* update matrix-js-sdk

* replace deprecated resolveRoomAlias
2023-12-24 19:38:17 +05:30
Krishan
2a1bf4a42a Update default server list (#1571)
Remvoe 0wnz.at from list as it seems to need registeration token which we don't support.
2023-12-03 09:28:01 +05:30
Jan Jurzitza
2889a72b81 Make small images not scale up in image viewer (#1554)
Instead show them in real resolution
2023-11-28 20:22:20 +05:30
Krishan
9ecb233763 Release v3.2.0 (#1531)
* Release v3.2.0

* Update cons.js
2023-10-31 21:20:49 +11:00
Ajay Bura
1db0a9eaa8 fix typo in codeblock markdown output 2023-10-31 08:57:59 +05:30
Ajay Bura
687ad8d0f0 Fix blockcode with empty lines not rendered (#1524) 2023-10-31 14:18:30 +11:00
Ajay Bura
c3f564605f Render reaction with string only key (#1522) 2023-10-31 14:17:57 +11:00
Ajay Bura
c854c7f9d2 Timeline Perf Improvement (#1521)
* emojify msg txt find&replace instead of recursion

* move findAndReplace func in its own file

* improve find and replace

* move markdown file to plugins

* make find and replace work without g flag regex

* fix pagination stop on msg arrive

* render blurhash in small size
2023-10-30 11:28:47 +05:30
Krishan
3713125f57 Fix grammer in membership event messages (#1520) 2023-10-30 11:28:30 +05:30
Ajay Bura
9f9173c691 Add URL preview (#1511)
* URL preview - WIP

* fix url preview regex

* update url match regex

* add url preview components

* add scroll btn url preview holder

* add message body component

* add url preview toggle in settings

* update url regex

* improve url regex

* increase thumbnail size in url preview

* hide url preview in encrypted rooms

* add encrypted room url preview toggle
2023-10-30 07:14:58 +11:00
Ajay Bura
a98903a85b Fix regex to ignore html tag in editor output (#1515) 2023-10-29 22:42:05 +11:00
Ajay Bura
a2cbe79787 Fix broken emoji with md pattern in shortcode (#1514)
* fix broken emoji with md pattern in shortcode

* fix html regex when generating editor output
2023-10-29 21:53:44 +11:00
820 changed files with 54091 additions and 30321 deletions

View File

@@ -61,4 +61,12 @@ module.exports = {
"@typescript-eslint/no-unused-vars": "error", "@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-shadow": "error" "@typescript-eslint/no-shadow": "error"
}, },
overrides: [
{
files: ['*.ts'],
rules: {
'no-undef': 'off',
},
},
],
}; };

View File

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

15
.github/renovate.json vendored
View File

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

View File

@@ -12,20 +12,20 @@ jobs:
PR_NUMBER: ${{github.event.number}} PR_NUMBER: ${{github.event.number}}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3.5.3 uses: actions/checkout@v4.2.0
- name: Setup node - name: Setup node
uses: actions/setup-node@v3.8.1 uses: actions/setup-node@v4.3.0
with: with:
node-version: 18.12.1 node-version: 20.12.2
cache: "npm" cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Build app - name: Build app
env: env:
NODE_OPTIONS: "--max_old_space_size=4096" NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build run: npm run build
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v3.1.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: preview name: preview
path: dist path: dist
@@ -33,7 +33,7 @@ jobs:
- name: Save pr number - name: Save pr number
run: echo ${PR_NUMBER} > ./pr.txt run: echo ${PR_NUMBER} > ./pr.txt
- name: Upload pr number - name: Upload pr number
uses: actions/upload-artifact@v3.1.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: pr name: pr
path: ./pr.txt path: ./pr.txt

View File

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

View File

@@ -15,7 +15,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps: steps:
- name: Download pr number - name: Download pr number
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295
with: with:
workflow: ${{ github.event.workflow.id }} workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }} run_id: ${{ github.event.workflow_run.id }}
@@ -24,7 +24,7 @@ jobs:
id: pr id: pr
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
- name: Download artifact - name: Download artifact
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295
with: with:
workflow: ${{ github.event.workflow.id }} workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }} run_id: ${{ github.event.workflow_run.id }}
@@ -32,7 +32,7 @@ jobs:
path: dist path: dist
- name: Deploy to Netlify - name: Deploy to Netlify
id: netlify id: netlify
uses: nwtgck/actions-netlify@7a92f00dde8c92a5a9e8385ec2919775f7647352 uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
with: with:
publish-dir: dist publish-dir: dist
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}" deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
@@ -45,7 +45,7 @@ jobs:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
timeout-minutes: 1 timeout-minutes: 1
- name: Comment preview on PR - name: Comment preview on PR
uses: thollander/actions-comment-pull-request@1d3973dc4b8e1399c0620d3f2b1aa5e795465308 uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ name: Deploy to Netlify (dev)
on: on:
push: push:
branches: branches:
- dev - dev
jobs: jobs:
deploy-to-netlify: deploy-to-netlify:
@@ -11,23 +11,23 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3.5.3 uses: actions/checkout@v4.2.0
- name: Setup node - name: Setup node
uses: actions/setup-node@v3.8.1 uses: actions/setup-node@v4.3.0
with: with:
node-version: 18.12.1 node-version: 20.12.2
cache: "npm" cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Build app - name: Build app
env: env:
NODE_OPTIONS: "--max_old_space_size=4096" NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build run: npm run build
- name: Deploy to Netlify - name: Deploy to Netlify
uses: nwtgck/actions-netlify@7a92f00dde8c92a5a9e8385ec2919775f7647352 uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
with: with:
publish-dir: dist publish-dir: dist
deploy-message: "Dev deploy ${{ github.sha }}" deploy-message: 'Dev deploy ${{ github.sha }}'
enable-commit-comment: false enable-commit-comment: false
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
production-deploy: true production-deploy: true

View File

@@ -10,23 +10,23 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3.5.3 uses: actions/checkout@v4.2.0
- name: Setup node - name: Setup node
uses: actions/setup-node@v3.8.1 uses: actions/setup-node@v4.3.0
with: with:
node-version: 18.12.1 node-version: 20.12.2
cache: "npm" cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Build app - name: Build app
env: env:
NODE_OPTIONS: "--max_old_space_size=4096" NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build run: npm run build
- name: Deploy to Netlify - name: Deploy to Netlify
uses: nwtgck/actions-netlify@7a92f00dde8c92a5a9e8385ec2919775f7647352 uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
with: with:
publish-dir: dist publish-dir: dist
deploy-message: "Prod deploy ${{ github.ref_name }}" deploy-message: 'Prod deploy ${{ github.ref_name }}'
enable-commit-comment: false enable-commit-comment: false
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
production-deploy: true production-deploy: true
@@ -52,7 +52,7 @@ jobs:
gpg --export | xxd -p gpg --export | xxd -p
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
- name: Upload tagged release - name: Upload tagged release
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda
with: with:
files: | files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz cinny-${{ steps.vars.outputs.tag }}.tar.gz
@@ -66,31 +66,31 @@ jobs:
packages: write packages: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3.5.3 uses: actions/checkout@v4.2.0
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2.2.0 uses: docker/setup-qemu-action@v3.6.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.7.0 uses: docker/setup-buildx-action@v3.10.0
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2.2.0 uses: docker/login-action@v3.4.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to the Container registry - name: Login to the Container registry
uses: docker/login-action@v2.2.0 uses: docker/login-action@v3.4.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v4.6.0 uses: docker/metadata-action@v5.7.0
with: with:
images: | images: |
${{ secrets.DOCKER_USERNAME }}/cinny ${{ secrets.DOCKER_USERNAME }}/cinny
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v4.1.1 uses: docker/build-push-action@v6.15.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ node_modules
devAssets devAssets
.DS_Store .DS_Store
.idea

3
.npmrc
View File

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

128
CODE_OF_CONDUCT.md Normal file
View File

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

View File

@@ -1,5 +1,5 @@
## Builder ## Builder
FROM node:18.12.1-alpine3.15 as builder FROM node:20.12.2-alpine3.18 as builder
WORKDIR /src WORKDIR /src
@@ -11,9 +11,10 @@ RUN npm run build
## App ## App
FROM nginx:1.25.1-alpine FROM nginx:1.27.4-alpine
COPY --from=builder /src/dist /app COPY --from=builder /src/dist /app
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
RUN rm -rf /usr/share/nginx/html \ RUN rm -rf /usr/share/nginx/html \
&& ln -s /app /usr/share/nginx/html && ln -s /app /usr/share/nginx/html

View File

@@ -19,25 +19,22 @@ A Matrix client focusing primarily on simple, elegant and secure interface. The
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380"> <img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
## Getting started ## Getting started
Web app is available at https://app.cinny.in and gets updated on each new release. The `dev` branch is continuously deployed at https://dev.cinny.in but keep in mind that it could have things broken. The web app is available at [app.cinny.in](https://app.cinny.in/) and gets updated on each new release. The `dev` branch is continuously deployed at [dev.cinny.in](https://dev.cinny.in) but keep in mind that it could have things broken.
You can also download our desktop app from [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop). You can also download our desktop app from the [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop).
To host Cinny on your own, download tarball of the app from [GitHub release](https://github.com/cinnyapp/cinny/releases/latest). ## Self-hosting
You can serve the application with a webserver of your choice by simply copying `dist/` directory to the webroot. To host Cinny on your own, simply download the tarball from [GitHub releases](https://github.com/cinnyapp/cinny/releases/latest), and serve the files from `dist/` using your preferred webserver. Alternatively, you can just pull the docker image from [DockerHub](https://hub.docker.com/r/ajbura/cinny) or [GitHub Container Registry](https://github.com/cinnyapp/cinny/pkgs/container/cinny).
To set default Homeserver on login and register page, place a customized [`config.json`](config.json) in webroot of your choice.
Alternatively you can just pull the [DockerHub image](https://hub.docker.com/r/ajbura/cinny) by: * The default homeservers and explore pages are defined in [`config.json`](config.json).
```
docker pull ajbura/cinny
```
or [ghcr image](https://github.com/cinnyapp/cinny/pkgs/container/cinny) by:
```
docker pull ghcr.io/cinnyapp/cinny:latest
```
<details> * You need to set up redirects to serve the assests. Example configurations; [netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), [caddy](contrib/caddy/caddyfile).
<summary>PGP Public Key to verify tarball</summary> * If you have trouble configuring redirects you can [enable hash routing](config.json#L35) — the url in the browser will have a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/` instead of `app.cinny.in/home/`) but you won't have to configure your webserver.
* To deploy on subdirectory, you need to rebuild the app youself after updating the `base` path in [`build.config.ts`](build.config.ts).
* For example, if you want to deploy on `https://cinny.in/app`, then set `base: '/app'`.
<details><summary><b>PGP Public Key to verify tarball</b></summary>
``` ```
-----BEGIN PGP PUBLIC KEY BLOCK----- -----BEGIN PGP PUBLIC KEY BLOCK-----
@@ -51,16 +48,16 @@ Frn9ttCEzV55Y+so4X2e4ZnB+5gOnNw+ecifGVdj/+UyWnqvqqDvLrEjjK890nLb
Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm
jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG
4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J 4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J
AdQEEwEIAD4WIQSRri2MHidaaZv+vvuUMwx6UK/M8wUCYnD+DQIbAwUJA8JnAAUL AdQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQSRri2MHidaaZv+
CQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRCUMwx6UK/M88ApC/9HAdbum1lYBC0s vvuUMwx6UK/M8wUCZqEDwAUJFvwIswAKCRCUMwx6UK/M877qC/4lxXOQIoWnLLkK
1k7GwP2A7B4sQtBWjy771BzybWlHeaeG+BGJwg4YiuowXZMm5dubFJFoI/CfeY07 YiRCTkGsH6NdxgeYr6wpXT4xuQ45ZxCytwHpOGQmO/5up5961TxWW8D1frRIJHjj
B5aK40/bmT6Xcfkp0VA74c1wUpubBUEJN7tH5HG/OGd9BKeq9E/HHtVaJLVT1k3w AZGoRCL3EKEuY8nt3D99fpf3DvZrs1uoVAhiyn737hRlZAg+QsJheeGCmdSJ0hX5
Rhv9VuHO6nR30EEp7IDthftotl5S4lio3+W0pKk4TAKV8vjaCNp3y/lAHzoP1BU9 Yud8SE+9zxLS1+CEjMrsUd/RGre/phme+wNXfaHfREAC9ewolgVChPIbMxG2f+vs
bUSao+7GXVeArKBjuqxN+t1uuiaxPH4L0oe2pMVjTig04zGJM5fTVoly859MEcC/ K8Xv52BFng7ta9fgsl1XuOjpuaSbQv6g+4ONk/lxKF0SmnhEGM3dmIYPONxW47Yf
R7Taq9RWGfXFmgCXy8Dviz3eOD90vqpCzhX4+ypK0cp2X0UwhMH4dpKUzExmdbhl atnIjRra/YhPTNwrNBGMmG4IFKaOsMbjW/eakjWTWOVKKJNBMoDdRcYYWIMCpLy8
eBO5GcHB4VxvloRBNf9/Lr7YOTgWejMUw+MlhZE2RE8unfW1LnM/cjL4dhXzO/XB AQUrMtQEsHSnqCwrw818S5A6rrhcfVGk36RGm0nOy6LS5g5jmqaYsvbCcBGY9B2c
FUHHNq8d6d4e02rfWqw7mZo2/NVJgFRcvzw2rgx7w7CKtCNwF4lNjUetB2waZzDb SUAVNm17oo7TtEajk8hcSXoZod1t++pyjcVKEmSn3nFK7v5m3V+cPhNTxZMK459P
fAE0kwhK4Iuwvy12JOBzL0Yy9MxANtwUryr/LQz9AmdT4Rwnp0S5AY0EYnD+DQEM 3x1Ucqj/kTqrxKw6s2Uknuk0ajmw0ljV+BQwgL6maguo9BKgCNW5AY0EYnD+DQEM
ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+ ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+
5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0 5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0
Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq
@@ -69,24 +66,24 @@ s1+eh00n/tVpi2Jj9pCm7S0csSXvXj8v2OTdK1jt4YjpzR0/rwh4+/xlOjDjZEqH
vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c
LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx
H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx
NwARAQABiQG8BBgBCAAmFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmJw/g0CGwwF NwARAQABiQG8BBgBCAAmAhsMFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmahA9IF
CQPCZwAACgkQlDMMelCvzPPT7Qv8CjXUEhphZFLwpBfaNOzRNfIXJST9aDit8zHW CRb8CMUACgkQlDMMelCvzPPQgQv/d5/z+fxgKqgfhQX+V49X4WgTVxZ/CzztDoJ1
IMmfSpORVfpU71IyIB3o/DtTUPwCeb8nvNJs7aj1QT1ZUSsqFa3yY2S16V/g8+WN XAq1dzTNEy8AFguXIo6eVXPSpMxec7ZreN3+UPQBnCf3eR5YxWNYOYKmk0G4E8D2
sHca6oDSc1J+A0eEpEL1HbG1b5OPBC0AeGvvMOoqrbqThBZVKg1Jc/0SD3cvKElv KGUJept7TSA42/8N2ov6tToXFg4CgzKZj0fYLwgutly7K8eiWmSU6ptaO8aEQBHB
aHeCZCNNmfcZ2Ib4HYhhc8//ZtC9TeI+5J/YesctY1M12EoWMxMrc27Y3P5Pa0BI gTGIOO3h6vJMGVycmoeRnHjv4wV84YWSVFSoJ7cY0he4Z9UznJBbE/KHZjrkXsPo
Uc3qxWggPq1vOFYsEshL0w99HyJvREJmQA7Fa0crV+rICxyrBxJeNnEvjH/0KCBU N+Gg5lDuOP5xjKzM5SogV9lhxBAhMWAg3URUF15yruZBiA8uV1FOK8sal/9C1G7V
LCkEonLY1QwrxyeeV3VpxGE3zHHE3azOdAjTIoAdzX5f/qhbgYlM68GL2f8xdDkp M6ygA6uOZqXlZtcdA94RoSsW2pZ9eLVPsxz2B3Zko7tu11MpNP/wYmfGTI3KxZBj
O0igSGHWhO4F8BfmE7IOTx1Bi7daczp8nCFxh73cKpKB0RUsd9xxrqYpovjmEAlo n/eodvwjJSgHpGOFSmbNzvPJo3to5nNlp7wH1KxIMc6Uuu9hgfDfwkFZgV2bnFIa
w7aHpdzt64NQcsrbK10OSVDF3gFa9Vz20/NQvdUrp8jGmAb/8+nYqI94Jsc28H36 Q6gyF548Ub48z7Dz83+WwLgbX19ve4oZx+dqSdczP6ILHRQomtrzrkkP2LU52oI5
UeGsouhyuITLwEhScounZDqop+Dx mxFo+ioe/ABCufSmyqFye0psX3Sp
=Zg+6 =WtqZ
-----END PGP PUBLIC KEY BLOCK----- -----END PGP PUBLIC KEY BLOCK-----
``` ```
</details> </details>
## Local development ## Local development
> We recommend using a version manager as versions change very quickly. You will likely need to switch > [!TIP]
between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Also recommended nodejs version Hydrogen LTS (v18). > We recommend using a version manager as versions change very quickly. You will likely need to switch between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Iron LTS (v20).
Execute the following commands to start a development server: Execute the following commands to start a development server:
```sh ```sh

View File

@@ -1,3 +0,0 @@
# Redirects from what the browser requests to what we serve
/login /
/register /

3
build.config.ts Normal file
View File

@@ -0,0 +1,3 @@
export default {
base: '/',
};

View File

@@ -1,7 +1,6 @@
{ {
"defaultHomeserver": 3, "defaultHomeserver": 2,
"homeserverList": [ "homeserverList": [
"0wnz.at",
"converser.eu", "converser.eu",
"envs.net", "envs.net",
"matrix.org", "matrix.org",
@@ -9,5 +8,31 @@
"mozilla.org", "mozilla.org",
"xmr.se" "xmr.se"
], ],
"allowCustomHomeservers": true "allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [
"#cinny-space:matrix.org",
"#community:matrix.org",
"#space:envs.net",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
],
"rooms": [
"#cinny:matrix.org",
"#freesoftware:matrix.org",
"#pcapdroid:matrix.org",
"#gentoo:matrix.org",
"#PrivSec.dev:arcticfoxes.net",
"#disroot:aria-net.org"
],
"servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"]
},
"hashRouter": {
"enabled": false,
"basename": "/"
}
} }

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,27 +1,34 @@
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name cinny.domain.tld; server_name cinny.domain.tld;
location / { location / {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {
alias /var/lib/letsencrypt/.well-known/acme-challenge/; alias /var/lib/letsencrypt/.well-known/acme-challenge/;
} }
} }
server { server {
listen 443 ssl http2; listen 443 ssl http2;
listen [::]:443 ssl; listen [::]:443 ssl;
server_name cinny.domain.tld; server_name cinny.domain.tld;
location / { location / {
root /opt/cinny/dist/; root /opt/cinny/dist/;
index index.html;
} rewrite ^/config.json$ /config.json break;
location ~* ^\/(login|register) { rewrite ^/manifest.json$ /manifest.json break;
try_files $uri $uri/ /index.html;
} 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;
}
} }

19
docker-nginx.conf Normal file
View File

@@ -0,0 +1,19 @@
server {
listen 80;
listen [::]:80;
location / {
root /usr/share/nginx/html;
rewrite ^/config.json$ /config.json break;
rewrite ^/manifest.json$ /manifest.json 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

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>Cinny</title> <title>Cinny</title>
<meta name="name" content="Cinny" /> <meta name="name" content="Cinny" />
<meta name="author" content="Ajay Bura" /> <meta name="author" content="Ajay Bura" />
@@ -90,12 +90,6 @@
window.global ||= window; window.global ||= window;
</script> </script>
<div id="root"></div> <div id="root"></div>
<audio id="notificationSound"> <script type="module" src="./src/index.tsx"></script>
<source src="./public/sound/notification.ogg" type="audio/ogg" />
</audio>
<audio id="inviteSound">
<source src="./public/sound/invite.ogg" type="audio/ogg" />
</audio>
<script type="module" src="./src/index.jsx"></script>
</body> </body>
</html> </html>

36
netlify.toml Normal file
View File

@@ -0,0 +1,36 @@
[[redirects]]
from = "/config.json"
to = "/config.json"
status = 200
[[redirects]]
from = "/manifest.json"
to = "/manifest.json"
status = 200
[[redirects]]
from = "/sw.js"
to = "/sw.js"
status = 200
[[redirects]]
from = "/pdf.worker.min.js"
to = "/pdf.worker.min.js"
status = 200
[[redirects]]
from = "/public/*"
to = "/public/:splat"
status = 200
[[redirects]]
from = "/assets/*"
to = "/assets/:splat"
status = 200
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
force = true

9322
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
{ {
"name": "cinny", "name": "cinny",
"version": "3.1.0", "version": "4.8.1",
"description": "Yet another matrix client", "description": "Yet another matrix client",
"main": "index.js", "main": "index.js",
"type": "module",
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
}, },
@@ -19,88 +20,99 @@
"author": "Ajay Bura", "author": "Ajay Bura",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14", "@fontsource/inter": "4.5.14",
"@khanacademy/simple-markdown": "0.8.6", "@tanstack/react-query": "5.24.1",
"@matrix-org/olm": "3.2.14", "@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.0.0-beta.54", "@tanstack/react-virtual": "3.2.0",
"@tippyjs/react": "4.2.6", "@tippyjs/react": "4.2.6",
"@vanilla-extract/css": "1.9.3", "@vanilla-extract/css": "1.9.3",
"@vanilla-extract/recipes": "0.3.0", "@vanilla-extract/recipes": "0.3.0",
"@vanilla-extract/vite-plugin": "3.7.1", "@vanilla-extract/vite-plugin": "3.7.1",
"await-to-js": "3.0.0", "await-to-js": "3.0.0",
"badwords-list": "2.0.1-4",
"blurhash": "2.0.4", "blurhash": "2.0.4",
"browser-encrypt-attachment": "0.3.0", "browser-encrypt-attachment": "0.3.0",
"chroma-js": "3.1.2",
"classnames": "2.3.2", "classnames": "2.3.2",
"dateformat": "5.0.3", "dateformat": "5.0.3",
"dayjs": "1.11.10", "dayjs": "1.11.10",
"domhandler": "5.0.3", "domhandler": "5.0.3",
"emojibase": "6.1.0", "emojibase": "15.3.1",
"emojibase-data": "7.0.1", "emojibase-data": "15.3.2",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"flux": "4.0.3", "flux": "4.0.3",
"focus-trap-react": "10.0.2", "focus-trap-react": "10.0.2",
"folds": "1.5.0", "folds": "2.2.0",
"formik": "2.2.9", "formik": "2.4.6",
"html-dom-parser": "4.0.0", "html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0", "html-react-parser": "4.2.0",
"i18next": "23.12.2",
"i18next-browser-languagedetector": "8.0.0",
"i18next-http-backend": "2.5.2",
"immer": "9.0.16", "immer": "9.0.16",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "1.12.0", "jotai": "2.6.0",
"katex": "0.16.4", "linkify-react": "4.1.3",
"linkify-html": "4.0.2", "linkifyjs": "4.1.3",
"linkify-react": "4.1.1", "matrix-js-sdk": "37.5.0",
"linkifyjs": "4.0.2",
"matrix-js-sdk": "24.1.0",
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "3.10.111", "pdfjs-dist": "4.2.67",
"prismjs": "1.29.0", "prismjs": "1.30.0",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"react": "17.0.2", "react": "18.2.0",
"react-aria": "3.29.1", "react-aria": "3.29.1",
"react-autosize-textarea": "7.1.0", "react-autosize-textarea": "7.1.0",
"react-blurhash": "0.2.0", "react-blurhash": "0.2.0",
"react-dnd": "15.1.2", "react-colorful": "5.6.1",
"react-dnd-html5-backend": "15.1.3", "react-dom": "18.2.0",
"react-dom": "17.0.2", "react-error-boundary": "4.0.13",
"react-error-boundary": "4.0.10",
"react-google-recaptcha": "2.1.0", "react-google-recaptcha": "2.1.0",
"react-i18next": "15.0.0",
"react-modal": "3.16.1", "react-modal": "3.16.1",
"react-range": "1.8.14", "react-range": "1.8.14",
"sanitize-html": "2.8.0", "react-router-dom": "6.20.0",
"slate": "0.94.1", "sanitize-html": "2.12.1",
"slate-history": "0.93.0", "slate": "0.112.0",
"slate-react": "0.98.4", "slate-dom": "0.112.2",
"slate-history": "0.110.3",
"slate-react": "0.112.1",
"tippy.js": "6.3.7", "tippy.js": "6.3.7",
"twemoji": "14.0.2",
"ua-parser-js": "1.0.35" "ua-parser-js": "1.0.35"
}, },
"devDependencies": { "devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3", "@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1", "@rollup/plugin-wasm": "6.1.1",
"@types/chroma-js": "3.1.1",
"@types/file-saver": "2.0.5", "@types/file-saver": "2.0.5",
"@types/is-hotkey": "0.1.10",
"@types/node": "18.11.18", "@types/node": "18.11.18",
"@types/prismjs": "1.26.0", "@types/prismjs": "1.26.0",
"@types/react": "18.0.26", "@types/react": "18.2.39",
"@types/react-dom": "18.0.9", "@types/react-dom": "18.2.17",
"@types/react-google-recaptcha": "2.1.8",
"@types/sanitize-html": "2.9.0", "@types/sanitize-html": "2.9.0",
"@types/ua-parser-js": "0.7.36", "@types/ua-parser-js": "0.7.36",
"@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "5.46.1", "@typescript-eslint/parser": "5.46.1",
"@vitejs/plugin-react": "3.0.0", "@vitejs/plugin-react": "4.2.0",
"buffer": "6.0.3", "buffer": "6.0.3",
"eslint": "8.29.0", "eslint": "8.29.0",
"eslint-config-airbnb": "19.0.4", "eslint-config-airbnb": "19.0.4",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "8.5.0",
"eslint-plugin-import": "2.26.0", "eslint-plugin-import": "2.29.1",
"eslint-plugin-jsx-a11y": "6.6.1", "eslint-plugin-jsx-a11y": "6.6.1",
"eslint-plugin-react": "7.31.11", "eslint-plugin-react": "7.31.11",
"eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-hooks": "4.6.0",
"mini-svg-data-uri": "1.4.4",
"prettier": "2.8.1", "prettier": "2.8.1",
"sass": "1.56.2", "sass": "1.56.2",
"typescript": "4.9.4", "typescript": "4.9.4",
"vite": "4.3.9", "vite": "5.4.15",
"vite-plugin-static-copy": "0.13.0" "vite-plugin-pwa": "0.20.5",
"vite-plugin-static-copy": "1.0.4",
"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

@@ -6,52 +6,52 @@
"lang": "en-US", "lang": "en-US",
"display": "standalone", "display": "standalone",
"orientation": "portrait", "orientation": "portrait",
"start_url": "/", "start_url": "./",
"background_color": "#fff", "background_color": "#fff",
"theme_color": "#fff", "theme_color": "#fff",
"icons": [ "icons": [
{ {
"src": "/public/android/android-chrome-36x36.png", "src": "./public/android/android-chrome-36x36.png",
"sizes": "36x36", "sizes": "36x36",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/public/android/android-chrome-48x48.png", "src": "./public/android/android-chrome-48x48.png",
"sizes": "48x48", "sizes": "48x48",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/public/android/android-chrome-72x72.png", "src": "./public/android/android-chrome-72x72.png",
"sizes": "72x72", "sizes": "72x72",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/public/android/android-chrome-96x96.png", "src": "./public/android/android-chrome-96x96.png",
"sizes": "96x96", "sizes": "96x96",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/public/android/android-chrome-144x144.png", "src": "./public/android/android-chrome-144x144.png",
"sizes": "144x144", "sizes": "144x144",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/public/android/android-chrome-192x192.png", "src": "./public/android/android-chrome-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/public/android/android-chrome-256x256.png", "src": "./public/android/android-chrome-256x256.png",
"sizes": "256x256", "sizes": "256x256",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/public/android/android-chrome-384x384.png", "src": "./public/android/android-chrome-384x384.png",
"sizes": "384x384", "sizes": "384x384",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/public/android/android-chrome-512x512.png", "src": "./public/android/android-chrome-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
} }

View File

@@ -2,17 +2,13 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './Avatar.scss'; import './Avatar.scss';
import { twemojify } from '../../../util/twemojify';
import Text from '../text/Text'; import Text from '../text/Text';
import RawIcon from '../system-icons/RawIcon'; import RawIcon from '../system-icons/RawIcon';
import ImageBrokenSVG from '../../../../public/res/svg/image-broken.svg'; import ImageBrokenSVG from '../../../../public/res/svg/image-broken.svg';
import { avatarInitials } from '../../../util/common'; import { avatarInitials } from '../../../util/common';
const Avatar = React.forwardRef(({ const Avatar = React.forwardRef(({ text, bgColor, iconSrc, iconColor, imageSrc, size }, ref) => {
text, bgColor, iconSrc, iconColor, imageSrc, size,
}, ref) => {
let textSize = 's1'; let textSize = 's1';
if (size === 'large') textSize = 'h1'; if (size === 'large') textSize = 'h1';
if (size === 'small') textSize = 'b1'; if (size === 'small') textSize = 'b1';
@@ -20,34 +16,34 @@ const Avatar = React.forwardRef(({
return ( return (
<div ref={ref} className={`avatar-container avatar-container__${size} noselect`}> <div ref={ref} className={`avatar-container avatar-container__${size} noselect`}>
{ {imageSrc !== null ? (
imageSrc !== null <img
? ( draggable="false"
<img src={imageSrc}
draggable="false" onLoad={(e) => {
src={imageSrc} e.target.style.backgroundColor = 'transparent';
onLoad={(e) => { e.target.style.backgroundColor = 'transparent'; }} }}
onError={(e) => { e.target.src = ImageBrokenSVG; }} onError={(e) => {
alt="" e.target.src = ImageBrokenSVG;
/> }}
) alt=""
: ( />
<span ) : (
style={{ backgroundColor: iconSrc === null ? bgColor : 'transparent' }} <span
className={`avatar__border${iconSrc !== null ? '--active' : ''}`} style={{ backgroundColor: iconSrc === null ? bgColor : 'transparent' }}
> className={`avatar__border${iconSrc !== null ? '--active' : ''}`}
{ >
iconSrc !== null {iconSrc !== null ? (
? <RawIcon size={size} src={iconSrc} color={iconColor} /> <RawIcon size={size} src={iconSrc} color={iconColor} />
: text !== null && ( ) : (
<Text variant={textSize} primary> text !== null && (
{twemojify(avatarInitials(text))} <Text variant={textSize} primary>
</Text> {avatarInitials(text)}
) </Text>
} )
</span> )}
) </span>
} )}
</div> </div>
); );
}); });

View File

@@ -22,8 +22,7 @@
height: 16px; height: 16px;
background-color: var(--tc-surface-low); background-color: var(--tc-surface-low);
border-radius: calc(var(--bo-radius) / 2); border-radius: calc(var(--bo-radius) / 2);
transition: transform 200ms ease-in-out, transition: transform 200ms ease-in-out, opacity 200ms ease-in-out;
opacity 200ms ease-in-out;
opacity: 0.6; opacity: 0.6;
} }
@@ -36,8 +35,8 @@
@include dir.prop(transform, var(--ltr), var(--rtl)); @include dir.prop(transform, var(--ltr), var(--rtl));
transform: translateX(calc(125%)); transform: translateX(calc(125%));
background-color: white; background-color: var(--bg-surface);
opacity: 1; opacity: 1;
} }
} }
} }

View File

@@ -1,33 +0,0 @@
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import './Math.scss';
import katex from 'katex';
import 'katex/dist/katex.min.css';
import 'katex/dist/contrib/copy-tex';
const Math = React.memo(({
content, throwOnError, errorColor, displayMode,
}) => {
const ref = useRef(null);
useEffect(() => {
katex.render(content, ref.current, { throwOnError, errorColor, displayMode });
}, [content, throwOnError, errorColor, displayMode]);
return <span ref={ref} />;
});
Math.defaultProps = {
throwOnError: null,
errorColor: null,
displayMode: null,
};
Math.propTypes = {
content: PropTypes.string.isRequired,
throwOnError: PropTypes.bool,
errorColor: PropTypes.string,
displayMode: PropTypes.bool,
};
export default Math;

View File

@@ -1,3 +0,0 @@
.katex-display {
margin: 0 !important;
}

View File

@@ -41,8 +41,9 @@ TabItem.propTypes = {
function Tabs({ items, defaultSelected, onSelect }) { function Tabs({ items, defaultSelected, onSelect }) {
const [selectedItem, setSelectedItem] = useState(items[defaultSelected]); const [selectedItem, setSelectedItem] = useState(items[defaultSelected]);
const handleTabSelection = (item, index) => { const handleTabSelection = (item, index, target) => {
if (selectedItem === item) return; if (selectedItem === item) return;
target.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
setSelectedItem(item); setSelectedItem(item);
onSelect(item, index); onSelect(item, index);
}; };
@@ -57,7 +58,7 @@ function Tabs({ items, defaultSelected, onSelect }) {
selected={selectedItem.text === item.text} selected={selectedItem.text === item.text}
iconSrc={item.iconSrc} iconSrc={item.iconSrc}
disabled={item.disabled} disabled={item.disabled}
onClick={() => handleTabSelection(item, index)} onClick={(e) => handleTabSelection(item, index, e.currentTarget)}
> >
{item.text} {item.text}
</TabItem> </TabItem>

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,64 @@
import { ReactNode, useCallback, useEffect, useMemo } from 'react';
import { MatrixError, createClient } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo';
import { promiseFulfilledResult, promiseRejectedResult } from '../utils/common';
import {
AuthFlows,
RegisterFlowStatus,
RegisterFlowsResponse,
parseRegisterErrResp,
} from '../hooks/useAuthFlows';
type AuthFlowsLoaderProps = {
fallback?: () => ReactNode;
error?: (err: unknown) => ReactNode;
children: (authFlows: AuthFlows) => ReactNode;
};
export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderProps) {
const autoDiscoveryInfo = useAutoDiscoveryInfo();
const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url;
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
const [state, load] = useAsyncCallback(
useCallback(async () => {
const result = await Promise.allSettled([mx.loginFlows(), mx.registerRequest({})]);
const loginFlows = promiseFulfilledResult(result[0]);
const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined;
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
if (typeof registerResp === 'object' && registerResp.httpStatus) {
registerFlows = parseRegisterErrResp(registerResp);
}
if (!loginFlows) {
throw new Error('Missing auth flow!');
}
if ('errcode' in loginFlows) {
throw new Error('Failed to load auth flow!');
}
const authFlows: AuthFlows = {
loginFlows,
registerFlows,
};
return authFlows;
}, [mx])
);
useEffect(() => {
load();
}, [load]);
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
return fallback?.();
}
if (state.status === AsyncStatus.Error) {
return error?.(state.error);
}
return children(state.data);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
import { ReactNode, useCallback, useEffect } from 'react';
import { Capabilities } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
type CapabilitiesLoaderProps = {
children: (capabilities: Capabilities | undefined) => ReactNode;
};
export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) {
const mx = useMatrixClient();
const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(), [mx]));
useEffect(() => {
load();
}, [load]);
return children(state.status === AsyncStatus.Success ? state.data : undefined);
}

View File

@@ -0,0 +1,38 @@
import { ReactNode, useCallback, useEffect, useState } from 'react';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ClientConfig } from '../hooks/useClientConfig';
import { trimTrailingSlash } from '../utils/common';
const getClientConfig = async (): Promise<ClientConfig> => {
const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`;
const config = await fetch(url, { method: 'GET' });
return config.json();
};
type ClientConfigLoaderProps = {
fallback?: () => ReactNode;
error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode;
children: (config: ClientConfig) => ReactNode;
};
export function ClientConfigLoader({ fallback, error, children }: ClientConfigLoaderProps) {
const [state, load] = useAsyncCallback(getClientConfig);
const [ignoreError, setIgnoreError] = useState(false);
const ignoreCallback = useCallback(() => setIgnoreError(true), []);
useEffect(() => {
load();
}, [load]);
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
return fallback?.();
}
if (!ignoreError && state.status === AsyncStatus.Error) {
return error?.(state.error, load, ignoreCallback);
}
const config: ClientConfig = state.status === AsyncStatus.Success ? state.data : {};
return children(config);
}

View File

@@ -0,0 +1,35 @@
import { ReactNode, RefObject, useCallback, useRef, useState } from 'react';
import { useDebounce } from '../hooks/useDebounce';
type ConfirmPasswordMatchProps = {
initialValue: boolean;
children: (
match: boolean,
doMatch: () => void,
passRef: RefObject<HTMLInputElement>,
confPassRef: RefObject<HTMLInputElement>
) => ReactNode;
};
export function ConfirmPasswordMatch({ initialValue, children }: ConfirmPasswordMatchProps) {
const [match, setMatch] = useState(initialValue);
const passRef = useRef<HTMLInputElement>(null);
const confPassRef = useRef<HTMLInputElement>(null);
const doMatch = useDebounce(
useCallback(() => {
const pass = passRef.current?.value;
const confPass = confPassRef.current?.value;
if (!confPass) {
setMatch(initialValue);
return;
}
setMatch(pass === confPass);
}, [initialValue]),
{
wait: 500,
immediate: false,
}
);
return children(match, doMatch, passRef, confPassRef);
}

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,19 @@
import { ReactNode, useCallback, useEffect } from 'react';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { MediaConfig } from '../hooks/useMediaConfig';
type MediaConfigLoaderProps = {
children: (mediaConfig: MediaConfig | undefined) => ReactNode;
};
export function MediaConfigLoader({ children }: MediaConfigLoaderProps) {
const mx = useMatrixClient();
const [state, load] = useAsyncCallback(useCallback(() => mx.getMediaConfig(), [mx]));
useEffect(() => {
load();
}, [load]);
return children(state.status === AsyncStatus.Success ? state.data : undefined);
}

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React, { FormEventHandler, useEffect, useRef, useState } from 'react'; import React, { FormEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
Box, Box,
@@ -13,6 +13,7 @@ import {
Input, Input,
Menu, Menu,
PopOut, PopOut,
RectCords,
Scroll, Scroll,
Spinner, Spinner,
Text, Text,
@@ -25,6 +26,7 @@ import * as css from './PdfViewer.css';
import { AsyncStatus } from '../../hooks/useAsyncCallback'; import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom'; import { useZoom } from '../../hooks/useZoom';
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist'; import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
import { stopPropagation } from '../../utils/keyboard';
export type PdfViewerProps = { export type PdfViewerProps = {
name: string; name: string;
@@ -48,7 +50,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
const isError = const isError =
pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error; pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
const [pageNo, setPageNo] = useState(1); const [pageNo, setPageNo] = useState(1);
const [openJump, setOpenJump] = useState(false); const [jumpAnchor, setJumpAnchor] = useState<RectCords>();
useEffect(() => { useEffect(() => {
loadPdfJS(); loadPdfJS();
@@ -86,7 +88,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
if (!jumpInput) return; if (!jumpInput) return;
const jumpTo = parseInt(jumpInput.value, 10); const jumpTo = parseInt(jumpInput.value, 10);
setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo))); setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
setOpenJump(false); setJumpAnchor(undefined);
}; };
const handlePrevPage = () => { const handlePrevPage = () => {
@@ -98,6 +100,10 @@ export const PdfViewer = as<'div', PdfViewerProps>(
setPageNo((n) => Math.min(n + 1, docState.data.numPages)); setPageNo((n) => Math.min(n + 1, docState.data.numPages));
}; };
const handleOpenJump: MouseEventHandler<HTMLButtonElement> = (evt) => {
setJumpAnchor(evt.currentTarget.getBoundingClientRect());
};
return ( return (
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}> <Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
<Header className={css.PdfViewerHeader} size="400"> <Header className={css.PdfViewerHeader} size="400">
@@ -187,15 +193,16 @@ export const PdfViewer = as<'div', PdfViewerProps>(
</Chip> </Chip>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200"> <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<PopOut <PopOut
open={openJump} anchor={jumpAnchor}
align="Center" align="Center"
position="Top" position="Top"
content={ content={
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
onDeactivate: () => setOpenJump(false), onDeactivate: () => setJumpAnchor(undefined),
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}} }}
> >
<Menu variant="Surface"> <Menu variant="Surface">
@@ -227,17 +234,14 @@ export const PdfViewer = as<'div', PdfViewerProps>(
</FocusTrap> </FocusTrap>
} }
> >
{(anchorRef) => ( <Chip
<Chip onClick={handleOpenJump}
onClick={() => setOpenJump(!openJump)} variant="SurfaceVariant"
ref={anchorRef} radii="300"
variant="SurfaceVariant" aria-pressed={jumpAnchor !== undefined}
radii="300" >
aria-pressed={openJump} <Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
> </Chip>
<Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
</Chip>
)}
</PopOut> </PopOut>
</Box> </Box>
<Chip <Chip

View File

@@ -0,0 +1,270 @@
import React from 'react';
import { MsgType } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { config } from 'folds';
import {
AudioContent,
DownloadFile,
FileContent,
ImageContent,
MAudio,
MBadEncrypted,
MEmote,
MFile,
MImage,
MLocation,
MNotice,
MText,
MVideo,
ReadPdfFile,
ReadTextFile,
RenderBody,
ThumbnailContent,
UnsupportedContent,
VideoContent,
} from './message';
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
import { Image, MediaControl, Video } from './media';
import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to';
import { IImageContent } from '../../types/matrix/common';
type RenderMessageContentProps = {
displayName: string;
msgType: string;
ts: number;
edited?: boolean;
getContent: <T>() => T;
mediaAutoLoad?: boolean;
urlPreview?: boolean;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
outlineAttachment?: boolean;
};
export function RenderMessageContent({
displayName,
msgType,
ts,
edited,
getContent,
mediaAutoLoad,
urlPreview,
highlightRegex,
htmlReactParserOptions,
linkifyOpts,
outlineAttachment,
}: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => {
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
if (filteredUrls.length === 0) return undefined;
return (
<UrlPreviewHolder>
{filteredUrls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={ts} />
))}
</UrlPreviewHolder>
);
};
const renderCaption = () => {
const content: IImageContent = getContent();
if (content.filename && content.filename !== content.body) {
return (
<MText
style={{ marginTop: config.space.S200 }}
edited={edited}
content={content}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/>
);
}
return null;
};
const renderFile = () => (
<>
<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) {
return (
<MText
edited={edited}
content={getContent()}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/>
);
}
if (msgType === MsgType.Emote) {
return (
<MEmote
displayName={displayName}
edited={edited}
content={getContent()}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/>
);
}
if (msgType === MsgType.Notice) {
return (
<MNotice
edited={edited}
content={getContent()}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/>
);
}
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}
/>
{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}
/>
{renderCaption()}
</>
);
}
if (msgType === MsgType.Audio) {
return (
<>
<MAudio
content={getContent()}
renderAsFile={renderFile}
renderAudioContent={(props) => (
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
)}
outlined={outlineAttachment}
/>
{renderCaption()}
</>
);
}
if (msgType === MsgType.File) {
return renderFile();
}
if (msgType === MsgType.Location) {
return <MLocation content={getContent()} />;
}
if (msgType === 'm.bad.encrypted') {
return <MBadEncrypted />;
}
return <UnsupportedContent />;
}

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,90 @@
import { ReactNode, useCallback, useState } from 'react';
import { MatrixClient, Room } from 'matrix-js-sdk';
import { useQuery } from '@tanstack/react-query';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { LocalRoomSummary, useLocalRoomSummary } from '../hooks/useLocalRoomSummary';
import { AsyncState, AsyncStatus } from '../hooks/useAsyncCallback';
export type IRoomSummary = Awaited<ReturnType<MatrixClient['getRoomSummary']>>;
type RoomSummaryLoaderProps = {
roomIdOrAlias: string;
children: (roomSummary?: IRoomSummary) => ReactNode;
};
export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoaderProps) {
const mx = useMatrixClient();
const fetchSummary = useCallback(() => mx.getRoomSummary(roomIdOrAlias), [mx, roomIdOrAlias]);
const { data } = useQuery({
queryKey: [roomIdOrAlias, `summary`],
queryFn: fetchSummary,
});
return children(data);
}
export function LocalRoomSummaryLoader({
room,
children,
}: {
room: Room;
children: (roomSummary: LocalRoomSummary) => ReactNode;
}) {
const summary = useLocalRoomSummary(room);
return children(summary);
}
export function HierarchyRoomSummaryLoader({
roomId,
children,
}: {
roomId: string;
children: (state: AsyncState<IHierarchyRoom, Error>) => ReactNode;
}) {
const mx = useMatrixClient();
const fetchSummary = useCallback(() => mx.getRoomHierarchy(roomId, 1, 1), [mx, roomId]);
const [errorMemo, setError] = useState<Error>();
const { data, error } = useQuery({
queryKey: [roomId, `hierarchy`],
queryFn: fetchSummary,
retryOnMount: false,
refetchOnWindowFocus: false,
retry: (failureCount, err) => {
setError(err);
if (failureCount > 3) return false;
return true;
},
});
let state: AsyncState<IHierarchyRoom, Error> = {
status: AsyncStatus.Loading,
};
if (error) {
state = {
status: AsyncStatus.Error,
error,
};
}
if (errorMemo) {
state = {
status: AsyncStatus.Error,
error: errorMemo,
};
}
const summary = data?.rooms[0] ?? undefined;
if (summary) {
state = {
status: AsyncStatus.Success,
data: summary,
};
}
return children(state);
}

View File

@@ -0,0 +1,24 @@
import { ReactElement } from 'react';
import { Unread } from '../../types/matrix/room';
import { useRoomUnread, useRoomsUnread } from '../state/hooks/unread';
import { roomToUnreadAtom } from '../state/room/roomToUnread';
type RoomUnreadProviderProps = {
roomId: string;
children: (unread?: Unread) => ReactElement;
};
export function RoomUnreadProvider({ roomId, children }: RoomUnreadProviderProps) {
const unread = useRoomUnread(roomId, roomToUnreadAtom);
return children(unread);
}
type RoomsUnreadProviderProps = {
rooms: string[];
children: (unread?: Unread) => ReactElement;
};
export function RoomsUnreadProvider({ rooms, children }: RoomsUnreadProviderProps) {
const unread = useRoomsUnread(rooms, roomToUnreadAtom);
return children(unread);
}

View File

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

View File

@@ -0,0 +1,28 @@
import { ReactNode } from 'react';
import { RoomToParents } from '../../types/matrix/room';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { allRoomsAtom } from '../state/room-list/roomList';
import { useChildDirectScopeFactory, useSpaceChildren } from '../state/hooks/roomList';
type SpaceChildDirectsProviderProps = {
spaceId: string;
mDirects: Set<string>;
roomToParents: RoomToParents;
children: (rooms: string[]) => ReactNode;
};
export function SpaceChildDirectsProvider({
spaceId,
roomToParents,
mDirects,
children,
}: SpaceChildDirectsProviderProps) {
const mx = useMatrixClient();
const childDirects = useSpaceChildren(
allRoomsAtom,
spaceId,
useChildDirectScopeFactory(mx, mDirects, roomToParents)
);
return children(childDirects);
}

View File

@@ -0,0 +1,28 @@
import { ReactNode } from 'react';
import { RoomToParents } from '../../types/matrix/room';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { allRoomsAtom } from '../state/room-list/roomList';
import { useChildRoomScopeFactory, useSpaceChildren } from '../state/hooks/roomList';
type SpaceChildRoomsProviderProps = {
spaceId: string;
mDirects: Set<string>;
roomToParents: RoomToParents;
children: (rooms: string[]) => ReactNode;
};
export function SpaceChildRoomsProvider({
spaceId,
roomToParents,
mDirects,
children,
}: SpaceChildRoomsProviderProps) {
const mx = useMatrixClient();
const childRooms = useSpaceChildren(
allRoomsAtom,
spaceId,
useChildRoomScopeFactory(mx, mDirects, roomToParents)
);
return children(childRooms);
}

View File

@@ -0,0 +1,43 @@
import { ReactNode, useCallback, useEffect, useState } from 'react';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { SpecVersions, specVersions } from '../cs-api';
type SpecVersionsLoaderProps = {
baseUrl: string;
fallback?: () => ReactNode;
error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode;
children: (versions: SpecVersions) => ReactNode;
};
export function SpecVersionsLoader({
baseUrl,
fallback,
error,
children,
}: SpecVersionsLoaderProps) {
const [state, load] = useAsyncCallback(
useCallback(() => specVersions(fetch, baseUrl), [baseUrl])
);
const [ignoreError, setIgnoreError] = useState(false);
const ignoreCallback = useCallback(() => setIgnoreError(true), []);
useEffect(() => {
load();
}, [load]);
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
return fallback?.();
}
if (!ignoreError && state.status === AsyncStatus.Error) {
return error?.(state.error, load, ignoreCallback);
}
return children(
state.status === AsyncStatus.Success
? state.data
: {
versions: [],
}
);
}

View File

@@ -0,0 +1,17 @@
import { ReactNode } from 'react';
import { UIAFlow } from 'matrix-js-sdk';
import { useSupportedUIAFlows } from '../hooks/useUIAFlows';
export function SupportedUIAFlowsLoader({
flows,
supportedStages,
children,
}: {
supportedStages: string[];
flows: UIAFlow[];
children: (supportedFlows: UIAFlow[]) => ReactNode;
}) {
const supportedFlows = useSupportedUIAFlows(flows, supportedStages);
return children(supportedFlows);
}

View File

@@ -0,0 +1,72 @@
import React, { ReactNode } from 'react';
import {
Overlay,
OverlayBackdrop,
Box,
config,
Text,
TooltipProvider,
Tooltip,
Icons,
Icon,
Chip,
IconButton,
} from 'folds';
import FocusTrap from 'focus-trap-react';
export type UIAFlowOverlayProps = {
currentStep: number;
stepCount: number;
children: ReactNode;
onCancel: () => void;
};
export function UIAFlowOverlay({
currentStep,
stepCount,
children,
onCancel,
}: UIAFlowOverlayProps) {
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<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}
</Box>
<Box
style={{ padding: config.space.S200 }}
shrink="No"
justifyContent="Center"
alignItems="Center"
gap="200"
>
<Chip as="div" radii="Pill" outlined>
<Text as="span" size="T300">{`Step ${currentStep}/${stepCount}`}</Text>
</Chip>
<TooltipProvider
tooltip={
<Tooltip variant="Critical">
<Text>Exit</Text>
</Tooltip>
}
position="Top"
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant="Critical"
size="300"
onClick={onCancel}
radii="Pill"
outlined
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
</Box>
</FocusTrap>
</Overlay>
);
}

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

@@ -14,6 +14,7 @@ import {
import { CustomEditor, useEditor } from './Editor'; import { CustomEditor, useEditor } from './Editor';
import { Toolbar } from './Toolbar'; import { Toolbar } from './Toolbar';
import { stopPropagation } from '../../utils/keyboard';
export function EditorPreview() { export function EditorPreview() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -32,6 +33,7 @@ export function EditorPreview() {
initialFocus: false, initialFocus: false,
onDeactivate: () => setOpen(false), onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}} }}
> >
<Modal size="500"> <Modal size="500">

View File

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

View File

@@ -10,13 +10,14 @@ import {
Line, Line,
Menu, Menu,
PopOut, PopOut,
RectCords,
Scroll, Scroll,
Text, Text,
Tooltip, Tooltip,
TooltipProvider, TooltipProvider,
toRem, toRem,
} from 'folds'; } from 'folds';
import React, { ReactNode, useState } from 'react'; import React, { MouseEventHandler, ReactNode, useState } from 'react';
import { ReactEditor, useSlate } from 'slate-react'; import { ReactEditor, useSlate } from 'slate-react';
import { import {
headingLevel, headingLevel,
@@ -34,6 +35,7 @@ import { isMacOS } from '../../utils/user-agent';
import { KeySymbol } from '../../utils/key-symbol'; import { KeySymbol } from '../../utils/key-symbol';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { stopPropagation } from '../../utils/keyboard';
function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) { function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
return ( return (
@@ -119,30 +121,38 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
export function HeadingBlockButton() { export function HeadingBlockButton() {
const editor = useSlate(); const editor = useSlate();
const level = headingLevel(editor); const level = headingLevel(editor);
const [open, setOpen] = useState(false); const [anchor, setAnchor] = useState<RectCords>();
const isActive = isBlockActive(editor, BlockType.Heading); const isActive = isBlockActive(editor, BlockType.Heading);
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl'; const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
const handleMenuSelect = (selectedLevel: HeadingLevel) => { const handleMenuSelect = (selectedLevel: HeadingLevel) => {
setOpen(false); setAnchor(undefined);
toggleBlock(editor, BlockType.Heading, { level: selectedLevel }); toggleBlock(editor, BlockType.Heading, { level: selectedLevel });
ReactEditor.focus(editor); ReactEditor.focus(editor);
}; };
const handleMenuOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
if (isActive) {
toggleBlock(editor, BlockType.Heading);
return;
}
setAnchor(evt.currentTarget.getBoundingClientRect());
};
return ( return (
<PopOut <PopOut
open={open} anchor={anchor}
offset={5} offset={5}
position="Top" position="Top"
content={ content={
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
onDeactivate: () => setOpen(false), onDeactivate: () => setAnchor(undefined),
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight', evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}} }}
> >
<Menu style={{ padding: config.space.S100 }}> <Menu style={{ padding: config.space.S100 }}>
@@ -197,20 +207,17 @@ export function HeadingBlockButton() {
</FocusTrap> </FocusTrap>
} }
> >
{(ref) => ( <IconButton
<IconButton style={{ width: 'unset' }}
style={{ width: 'unset' }} variant="SurfaceVariant"
ref={ref} onClick={handleMenuOpen}
variant="SurfaceVariant" aria-pressed={isActive}
onClick={() => (isActive ? toggleBlock(editor, BlockType.Heading) : setOpen(!open))} size="400"
aria-pressed={isActive} radii="300"
size="400" >
radii="300" <Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
> <Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
<Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} /> </IconButton>
<Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
</IconButton>
)}
</PopOut> </PopOut>
); );
} }
@@ -250,7 +257,9 @@ export function Toolbar() {
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl'; const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
const disableInline = isBlockActive(editor, BlockType.CodeBlock); const disableInline = isBlockActive(editor, BlockType.CodeBlock);
const canEscape = isAnyMarkActive(editor) || !isBlockActive(editor, BlockType.Paragraph); const canEscape = isBlockActive(editor, BlockType.Paragraph)
? isAnyMarkActive(editor)
: ReactEditor.isFocused(editor);
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
return ( return (

View File

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

View File

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

View File

@@ -1,12 +1,11 @@
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react'; import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect } from 'react';
import { Editor } from 'slate'; import { Editor } from 'slate';
import { Avatar, AvatarFallback, AvatarImage, Icon, Icons, MenuItem, Text, color } from 'folds'; import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { MatrixClient } from 'matrix-js-sdk'; import { JoinRule, MatrixClient } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils'; import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { getRoomAvatarUrl, joinRuleToIconSrc } from '../../../utils/room'; import { getDirectRoomAvatarUrl } from '../../../utils/room';
import { roomIdByActivity } from '../../../../util/sort';
import initMatrix from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AutocompleteQuery } from './autocompleteQuery'; import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu'; import { AutocompleteMenu } from './AutocompleteMenu';
@@ -14,6 +13,11 @@ import { getMxIdServer, validMxId } from '../../../utils/matrix';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch'; import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard'; import { onTabPress } from '../../../utils/keyboard';
import { useKeyDown } from '../../../hooks/useKeyDown'; import { useKeyDown } from '../../../hooks/useKeyDown';
import { mDirectAtom } from '../../../state/mDirectList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { factoryRoomIdByActivity } from '../../../utils/sort';
import { RoomAvatar, RoomIcon } from '../../room-avatar';
import { getViaServers } from '../../../plugins/via-servers';
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void; type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
@@ -61,7 +65,6 @@ type RoomMentionAutocompleteProps = {
}; };
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 20,
matchOptions: { matchOptions: {
contain: true, contain: true,
}, },
@@ -74,15 +77,12 @@ export function RoomMentionAutocomplete({
requestClose, requestClose,
}: RoomMentionAutocompleteProps) { }: RoomMentionAutocompleteProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const dms: Set<string> = initMatrix.roomList?.directs ?? new Set(); const mDirects = useAtomValue(mDirectAtom);
const allRoomId: string[] = useMemo(() => { const allRooms = useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx));
const { spaces = [], rooms = [], directs = [] } = initMatrix.roomList ?? {};
return [...spaces, ...rooms, ...directs].sort(roomIdByActivity);
}, []);
const [result, search, resetSearch] = useAsyncSearch( const [result, search, resetSearch] = useAsyncSearch(
allRoomId, allRooms,
useCallback( useCallback(
(rId) => { (rId) => {
const r = mx.getRoom(rId); const r = mx.getRoom(rId);
@@ -96,7 +96,7 @@ export function RoomMentionAutocomplete({
SEARCH_OPTIONS SEARCH_OPTIONS
); );
const autoCompleteRoomIds = result ? result.items : allRoomId.slice(0, 20); const autoCompleteRoomIds = result ? result.items.slice(0, 20) : allRooms.slice(0, 20);
useEffect(() => { useEffect(() => {
if (query.text) search(query.text); if (query.text) search(query.text);
@@ -104,10 +104,14 @@ export function RoomMentionAutocomplete({
}, [query.text, search, resetSearch]); }, [query.text, search, resetSearch]);
const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => { const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => {
const mentionRoom = mx.getRoom(roomAliasOrId);
const viaServers = mentionRoom ? getViaServers(mentionRoom) : undefined;
const mentionEl = createMentionElement( const mentionEl = createMentionElement(
roomAliasOrId, roomAliasOrId,
name.startsWith('#') ? name : `#${name}`, name.startsWith('#') ? name : `#${name}`,
roomId === roomAliasOrId || mx.getRoom(roomId)?.getCanonicalAlias() === roomAliasOrId roomId === roomAliasOrId || mx.getRoom(roomId)?.getCanonicalAlias() === roomAliasOrId,
undefined,
viaServers
); );
replaceWithElement(editor, query.range, mentionEl); replaceWithElement(editor, query.range, mentionEl);
moveCursor(editor, true); moveCursor(editor, true);
@@ -136,9 +140,7 @@ export function RoomMentionAutocomplete({
autoCompleteRoomIds.map((rId) => { autoCompleteRoomIds.map((rId) => {
const room = mx.getRoom(rId); const room = mx.getRoom(rId);
if (!room) return null; if (!room) return null;
const dm = dms.has(room.roomId); const dm = mDirects.has(room.roomId);
const avatarUrl = getRoomAvatarUrl(mx, room);
const iconSrc = !dm && joinRuleToIconSrc(Icons, room.getJoinRule(), room.isSpaceRoom());
const handleSelect = () => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name); const handleSelect = () => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name);
@@ -158,17 +160,21 @@ export function RoomMentionAutocomplete({
} }
before={ before={
<Avatar size="200"> <Avatar size="200">
{iconSrc && <Icon src={iconSrc} size="100" />} {dm ? (
{avatarUrl && !iconSrc && <AvatarImage src={avatarUrl} alt={room.name} />} <RoomAvatar
{!avatarUrl && !iconSrc && ( roomId={room.roomId}
<AvatarFallback src={getDirectRoomAvatarUrl(mx, room)}
style={{ alt={room.name}
backgroundColor: color.Secondary.Container, renderFallback={() => (
color: color.Secondary.OnContainer, <RoomIcon
}} size="50"
> joinRule={room.getJoinRule() ?? JoinRule.Restricted}
<Text size="H6">{room.name[0]}</Text> filled
</AvatarFallback> />
)}
/>
) : (
<RoomIcon size="100" joinRule={room.getJoinRule()} space={room.isSpaceRoom()} />
)} )}
</Avatar> </Avatar>
} }

View File

@@ -1,6 +1,6 @@
import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react'; import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react';
import { Editor } from 'slate'; import { Editor } from 'slate';
import { Avatar, AvatarFallback, AvatarImage, MenuItem, Text, color } from 'folds'; import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk'; import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { AutocompleteQuery } from './autocompleteQuery'; import { AutocompleteQuery } from './autocompleteQuery';
@@ -17,6 +17,9 @@ import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { useKeyDown } from '../../../hooks/useKeyDown'; import { useKeyDown } from '../../../hooks/useKeyDown';
import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix'; import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix';
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room'; 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; type MentionAutoCompleteHandler = (userId: string, name: string) => void;
@@ -26,12 +29,10 @@ const userIdFromQueryText = (mx: MatrixClient, text: string) =>
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
function UnknownMentionItem({ function UnknownMentionItem({
query,
userId, userId,
name, name,
handleAutocomplete, handleAutocomplete,
}: { }: {
query: AutocompleteQuery<string>;
userId: string; userId: string;
name: string; name: string;
handleAutocomplete: MentionAutoCompleteHandler; handleAutocomplete: MentionAutoCompleteHandler;
@@ -46,14 +47,10 @@ function UnknownMentionItem({
onClick={() => handleAutocomplete(userId, name)} onClick={() => handleAutocomplete(userId, name)}
before={ before={
<Avatar size="200"> <Avatar size="200">
<AvatarFallback <UserAvatar
style={{ userId={userId}
backgroundColor: color.Secondary.Container, renderFallback={() => <Icon size="50" src={Icons.User} filled />}
color: color.Secondary.OnContainer, />
}}
>
<Text size="H6">{query.text[0]}</Text>
</AvatarFallback>
</Avatar> </Avatar>
} }
> >
@@ -71,8 +68,13 @@ type UserMentionAutocompleteProps = {
requestClose: () => void; requestClose: () => void;
}; };
const withAllowedMembership = (member: RoomMember): boolean =>
member.membership === Membership.Join ||
member.membership === Membership.Invite ||
member.membership === Membership.Knock;
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 20, limit: 1000,
matchOptions: { matchOptions: {
contain: true, contain: true,
}, },
@@ -89,12 +91,15 @@ export function UserMentionAutocomplete({
requestClose, requestClose,
}: UserMentionAutocompleteProps) { }: UserMentionAutocompleteProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const roomId: string = room.roomId!; const roomId: string = room.roomId!;
const roomAliasOrId = room.getCanonicalAlias() || roomId; const roomAliasOrId = room.getCanonicalAlias() || roomId;
const members = useRoomMembers(mx, roomId); const members = useRoomMembers(mx, roomId);
const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS); const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS);
const autoCompleteMembers = result ? result.items : members.slice(0, 20); const autoCompleteMembers = (result ? result.items.slice(0, 20) : members.slice(0, 20)).filter(
withAllowedMembership
);
useEffect(() => { useEffect(() => {
if (query.text) search(query.text); if (query.text) search(query.text);
@@ -135,7 +140,6 @@ export function UserMentionAutocomplete({
<AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}> <AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
{query.text === 'room' && ( {query.text === 'room' && (
<UnknownMentionItem <UnknownMentionItem
query={query}
userId={roomAliasOrId} userId={roomAliasOrId}
name="@room" name="@room"
handleAutocomplete={handleAutocomplete} handleAutocomplete={handleAutocomplete}
@@ -143,14 +147,16 @@ export function UserMentionAutocomplete({
)} )}
{autoCompleteMembers.length === 0 ? ( {autoCompleteMembers.length === 0 ? (
<UnknownMentionItem <UnknownMentionItem
query={query}
userId={userIdFromQueryText(mx, query.text)} userId={userIdFromQueryText(mx, query.text)}
name={userIdFromQueryText(mx, query.text)} name={userIdFromQueryText(mx, query.text)}
handleAutocomplete={handleAutocomplete} handleAutocomplete={handleAutocomplete}
/> />
) : ( ) : (
autoCompleteMembers.map((roomMember) => { autoCompleteMembers.map((roomMember) => {
const avatarUrl = roomMember.getAvatarUrl(mx.baseUrl, 32, 32, 'crop', undefined, false); const avatarMxcUrl = roomMember.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication)
: undefined;
return ( return (
<MenuItem <MenuItem
key={roomMember.userId} key={roomMember.userId}
@@ -167,18 +173,12 @@ export function UserMentionAutocomplete({
} }
before={ before={
<Avatar size="200"> <Avatar size="200">
{avatarUrl ? ( <UserAvatar
<AvatarImage src={avatarUrl} alt={getName(roomMember)} /> userId={roomMember.userId}
) : ( src={avatarUrl ?? undefined}
<AvatarFallback alt={getName(roomMember)}
style={{ renderFallback={() => <Icon size="50" src={Icons.User} filled />}
backgroundColor: color.Secondary.Container, />
color: color.Secondary.OnContainer,
}}
>
<Text size="H6">{getName(roomMember)[0]}</Text>
</AvatarFallback>
)}
</Avatar> </Avatar>
} }
> >

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,6 @@ import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
Avatar, Avatar,
AvatarFallback,
AvatarImage,
Box, Box,
Header, Header,
Icon, Icon,
@@ -21,8 +19,9 @@ import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix'; import { getMxIdLocalPart } from '../../utils/matrix';
import * as css from './EventReaders.css'; import * as css from './EventReaders.css';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import colorMXID from '../../../util/colorMXID';
import { openProfileViewer } from '../../../client/action/navigation'; import { openProfileViewer } from '../../../client/action/navigation';
import { UserAvatar } from '../user-avatar';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
export type EventReadersProps = { export type EventReadersProps = {
room: Room; room: Room;
@@ -32,6 +31,7 @@ export type EventReadersProps = {
export const EventReaders = as<'div', EventReadersProps>( export const EventReaders = as<'div', EventReadersProps>(
({ className, room, eventId, requestClose, ...props }, ref) => { ({ className, room, eventId, requestClose, ...props }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const latestEventReaders = useRoomEventReaders(room, eventId); const latestEventReaders = useRoomEventReaders(room, eventId);
const getName = (userId: string) => const getName = (userId: string) =>
@@ -57,9 +57,10 @@ export const EventReaders = as<'div', EventReadersProps>(
<Box className={css.Content} direction="Column"> <Box className={css.Content} direction="Column">
{latestEventReaders.map((readerId) => { {latestEventReaders.map((readerId) => {
const name = getName(readerId); const name = getName(readerId);
const avatarUrl = room const avatarMxcUrl = room
.getMember(readerId) .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 ( return (
<MenuItem <MenuItem
@@ -72,18 +73,12 @@ export const EventReaders = as<'div', EventReadersProps>(
}} }}
before={ before={
<Avatar size="200"> <Avatar size="200">
{avatarUrl ? ( <UserAvatar
<AvatarImage src={avatarUrl} /> userId={readerId}
) : ( src={avatarUrl ?? undefined}
<AvatarFallback alt={name}
style={{ renderFallback={() => <Icon size="50" src={Icons.User} filled />}
background: colorMXID(readerId), />
color: 'white',
}}
>
<Text size="H6">{name[0]}</Text>
</AvatarFallback>
)}
</Avatar> </Avatar>
} }
> >

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

@@ -32,8 +32,10 @@ export const ImageViewerImg = style([
DefaultReset, DefaultReset,
{ {
objectFit: 'contain', objectFit: 'contain',
width: '100%', width: 'auto',
height: '100%', height: 'auto',
maxWidth: '100%',
maxHeight: '100%',
backgroundColor: color.Surface.Container, backgroundColor: color.Surface.Container,
transition: 'transform 100ms linear', transition: 'transform 100ms linear',
}, },

View File

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

View File

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

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