Compare commits

...

42 Commits

Author SHA1 Message Date
Ajay Bura
a7f2d1684f improve thread reply layout 2025-08-04 20:31:38 +05:30
Ajay Bura
ccf10fc20c Revert "Fix menus congestion and improve thread reply layout (#2402)" (#2409)
This reverts commit d8d4714370.
2025-08-04 20:29:12 +05:30
Ajay Bura
31942b1114 Add code block language header and improve styles (#2403)
* add code block language header and improve styles

* improve codeblock fallback text

* move floating expand button to code block header

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

* improve threaded reply layout

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

* added hour24Clock to TimeProps

* Add incomplete dateFormatString setting

* Move 24-hour  toggle to Appearance

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

* Add setting for date formatting

* Fix minor formatting and naming issues

* Document functions

* adress most comments

* add hint for date formatting

* add support for 24hr time to TimePicker

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

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

* force build

* remove useless actions

* add actions back

* change icon to ThreadPlus

* add button to context menu

* fix capital T

---------

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

* add functionality

* Document functions

* Improve accessibility

* Remove pointless DefaultReset

* implement some requested changes

* fix content shift when expanding or collapsing

---------

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

* add time utils

* add jump to time in room timeline

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

* deep-link cross-signing reset button with oidc

* deep-link manage devices and delete device with oidc

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-05 21:51:29 +10:00
dependabot[bot]
3fd8a18157 Bump dawidd6/action-download-artifact from 9 to 11 (#2364)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 9 to 11.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](07ab29fd4a...ac66b43f0e)

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

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

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

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

* Implemented MR #2317

* Fix crash if canMessage is false

* Prepare for PR #2335

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-19 08:52:03 +10:00
dependabot[bot]
87ce209050 Bump actions/setup-node from 4.3.0 to 4.4.0 (#2307)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.3.0 to 4.4.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4.3.0...v4.4.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-19 08:29:55 +10:00
Ajay Bura
3ed8260877 Release v4.8.1 (#2360) 2025-06-10 23:48:55 +10:00
Ajay Bura
44347db6e4 Add allow from currently selected space if no m.space.parent found (#2359) 2025-06-10 23:47:46 +10:00
Ajay Bura
91632aa193 Fix space navigation & view space timeline dev-option (#2358)
* fix inaccessible space on alias change

* fix new room in space open in home

* allow opening space timeline

* hide event timeline feature behind dev tool

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

* add comment

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

* filter invites and add more options

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

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

* Add command for deleting multiple messages

* remove console logs and improve ban command description

* improve commands description

* add server acl command

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

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

* fix knock restricted icon and label
2025-05-13 14:18:52 +05:30
98 changed files with 4081 additions and 1034 deletions

View File

@@ -14,7 +14,7 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@v4.2.0
- name: Setup node - name: Setup node
uses: actions/setup-node@v4.3.0 uses: actions/setup-node@v4.4.0
with: with:
node-version: 20.12.2 node-version: 20.12.2
cache: 'npm' cache: 'npm'

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@07ab29fd4a977ae4d2b275087cf67563dfdf0295 uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
with: with:
workflow: ${{ github.event.workflow.id }} workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }} run_id: ${{ github.event.workflow_run.id }}
@@ -24,7 +24,7 @@ jobs:
id: pr id: pr
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
- name: Download artifact - name: Download artifact
uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295 uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
with: with:
workflow: ${{ github.event.workflow.id }} workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }} run_id: ${{ github.event.workflow_run.id }}

View File

@@ -13,7 +13,7 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@v4.2.0
- name: Build Docker image - name: Build Docker image
uses: docker/build-push-action@v6.15.0 uses: docker/build-push-action@v6.18.0
with: with:
context: . context: .
push: false push: false

View File

@@ -13,7 +13,7 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@v4.2.0
- name: Setup node - name: Setup node
uses: actions/setup-node@v4.3.0 uses: actions/setup-node@v4.4.0
with: with:
node-version: 20.12.2 node-version: 20.12.2
cache: 'npm' cache: 'npm'

View File

@@ -12,7 +12,7 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.2.0 uses: actions/checkout@v4.2.0
- name: Setup node - name: Setup node
uses: actions/setup-node@v4.3.0 uses: actions/setup-node@v4.4.0
with: with:
node-version: 20.12.2 node-version: 20.12.2
cache: 'npm' cache: 'npm'
@@ -52,7 +52,7 @@ jobs:
gpg --export | xxd -p gpg --export | xxd -p
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
- name: Upload tagged release - name: Upload tagged release
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8
with: with:
files: | files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz cinny-${{ steps.vars.outputs.tag }}.tar.gz
@@ -90,7 +90,7 @@ jobs:
${{ secrets.DOCKER_USERNAME }}/cinny ${{ secrets.DOCKER_USERNAME }}/cinny
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6.15.0 uses: docker/build-push-action@v6.18.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View File

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

114
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "cinny", "name": "cinny",
"version": "4.6.0", "version": "4.8.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cinny", "name": "cinny",
"version": "4.6.0", "version": "4.8.1",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
@@ -21,6 +21,7 @@
"@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", "chroma-js": "3.1.2",
@@ -33,7 +34,7 @@
"file-saver": "2.0.5", "file-saver": "2.0.5",
"flux": "4.0.3", "flux": "4.0.3",
"focus-trap-react": "10.0.2", "focus-trap-react": "10.0.2",
"folds": "2.1.0", "folds": "2.2.0",
"formik": "2.4.6", "formik": "2.4.6",
"html-dom-parser": "4.0.0", "html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0", "html-react-parser": "4.2.0",
@@ -45,7 +46,7 @@
"jotai": "2.6.0", "jotai": "2.6.0",
"linkify-react": "4.1.3", "linkify-react": "4.1.3",
"linkifyjs": "4.1.3", "linkifyjs": "4.1.3",
"matrix-js-sdk": "35.0.0", "matrix-js-sdk": "37.5.0",
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "4.2.67", "pdfjs-dist": "4.2.67",
"prismjs": "1.30.0", "prismjs": "1.30.0",
@@ -98,7 +99,7 @@
"prettier": "2.8.1", "prettier": "2.8.1",
"sass": "1.56.2", "sass": "1.56.2",
"typescript": "4.9.4", "typescript": "4.9.4",
"vite": "5.4.15", "vite": "5.4.19",
"vite-plugin-pwa": "0.20.5", "vite-plugin-pwa": "0.20.5",
"vite-plugin-static-copy": "1.0.4", "vite-plugin-static-copy": "1.0.4",
"vite-plugin-top-level-await": "1.4.4" "vite-plugin-top-level-await": "1.4.4"
@@ -2263,17 +2264,19 @@
} }
}, },
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": { "node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
"version": "11.1.0", "version": "14.1.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-11.1.0.tgz", "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-14.1.0.tgz",
"integrity": "sha512-JPuO9RCVDklDjbFzMvZfQb7PuiFkLY72bniRSu81lRzkkrcbZtmKqBFMm9H4f2FSz+tHVkDnmsvn12I4sdJJ5A==", "integrity": "sha512-vcSxHJIr6lP0Fgo8jl0sTHg+OZxZn+skGjiyB62erfgw/R2QqJl0ZVSY8SRcbk9LtHo/ZGld1tnaOyjL2e3cLQ==",
"license": "Apache-2.0",
"engines": { "engines": {
"node": ">= 10" "node": ">= 18"
} }
}, },
"node_modules/@matrix-org/olm": { "node_modules/@matrix-org/olm": {
"version": "3.2.15", "version": "3.2.15",
"resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz", "resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz",
"integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==" "integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==",
"license": "Apache-2.0"
}, },
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
@@ -4589,7 +4592,8 @@
"node_modules/@types/events": { "node_modules/@types/events": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==" "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
"license": "MIT"
}, },
"node_modules/@types/file-saver": { "node_modules/@types/file-saver": {
"version": "2.0.5", "version": "2.0.5",
@@ -4678,7 +4682,8 @@
"node_modules/@types/retry": { "node_modules/@types/retry": {
"version": "0.12.0", "version": "0.12.0",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
"license": "MIT"
}, },
"node_modules/@types/sanitize-html": { "node_modules/@types/sanitize-html": {
"version": "2.9.0", "version": "2.9.0",
@@ -5088,7 +5093,8 @@
"node_modules/another-json": { "node_modules/another-json": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz", "resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz",
"integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==" "integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==",
"license": "Apache-2.0"
}, },
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
@@ -5431,6 +5437,12 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
} }
}, },
"node_modules/badwords-list": {
"version": "2.0.1-4",
"resolved": "https://registry.npmjs.org/badwords-list/-/badwords-list-2.0.1-4.tgz",
"integrity": "sha512-FxfZUp7B9yCnesNtFQS9v6PvZdxTYa14Q60JR6vhjdQdWI4naTjJIyx22JzoER8ooeT8SAAKoHLjKfCV7XgYUQ==",
"license": "MIT"
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -5438,9 +5450,10 @@
"devOptional": true "devOptional": true
}, },
"node_modules/base-x": { "node_modules/base-x": {
"version": "5.0.0", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.0.tgz", "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
"integrity": "sha512-sMW3VGSX1QWVFA6l8U62MLKz29rRfpTlYdCqLdpLo1/Yd4zZwSbnUaDfciIAowAqvq7YFnWq9hrhdg1KYgc1lQ==" "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
"license": "MIT"
}, },
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
@@ -5546,6 +5559,7 @@
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
"license": "MIT",
"dependencies": { "dependencies": {
"base-x": "^5.0.0" "base-x": "^5.0.0"
} }
@@ -5848,6 +5862,7 @@
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -6999,6 +7014,7 @@
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": { "engines": {
"node": ">=0.8.x" "node": ">=0.8.x"
} }
@@ -7249,15 +7265,16 @@
} }
}, },
"node_modules/folds": { "node_modules/folds": {
"version": "2.1.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/folds/-/folds-2.1.0.tgz", "resolved": "https://registry.npmjs.org/folds/-/folds-2.2.0.tgz",
"integrity": "sha512-KwAG8bH3jsyZ9FKPMg+6ABV2YOcpp4nL0cCelsalnaPeRThkc5fgG1Xj5mhmdffYKjEXpEbERi5qmGbepgJryg==", "integrity": "sha512-uOfck5eWEIK11rhOAEdSoPIvMXwv+D1Go03pxSAKezWVb+uRoBdmE6LEqiLOF+ac4DGmZRMPvpdDsXCg7EVNIg==",
"license": "Apache-2.0",
"peerDependencies": { "peerDependencies": {
"@vanilla-extract/css": "^1.9.2", "@vanilla-extract/css": "1.9.2",
"@vanilla-extract/recipes": "^0.3.0", "@vanilla-extract/recipes": "0.3.0",
"classnames": "^2.3.2", "classnames": "2.3.2",
"react": "^17.0.0", "react": "17.0.0",
"react-dom": "^17.0.0" "react-dom": "17.0.0"
} }
}, },
"node_modules/for-each": { "node_modules/for-each": {
@@ -8557,6 +8574,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -8689,6 +8707,7 @@
"version": "1.9.2", "version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 0.6.0" "node": ">= 0.6.0"
}, },
@@ -8764,21 +8783,23 @@
"node_modules/matrix-events-sdk": { "node_modules/matrix-events-sdk": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz", "resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz",
"integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==" "integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==",
"license": "Apache-2.0"
}, },
"node_modules/matrix-js-sdk": { "node_modules/matrix-js-sdk": {
"version": "35.0.0", "version": "37.5.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-35.0.0.tgz", "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-37.5.0.tgz",
"integrity": "sha512-X8hIsd/8x1SC9vRr8DiNKQxmdrfRujtvEWPz8mY4FxVDJG8HEGDHvqUmaSy2jrtnOUn4oHzGQVLFO3DnhsSf8w==", "integrity": "sha512-5tyuAi5hnKud1UkVq8Z2/3c22hWGELBZzErJPZkE6Hju2uGUfGtrIx6uj6puv0ZjvsUU3X6Qgm8vdReKO1PGig==",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^11.0.0", "@matrix-org/matrix-sdk-crypto-wasm": "^14.0.1",
"@matrix-org/olm": "3.2.15", "@matrix-org/olm": "3.2.15",
"another-json": "^0.2.0", "another-json": "^0.2.0",
"bs58": "^6.0.0", "bs58": "^6.0.0",
"content-type": "^1.0.4", "content-type": "^1.0.4",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"loglevel": "^1.7.1", "loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1", "matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.10.0", "matrix-widget-api": "^1.10.0",
"oidc-client-ts": "^3.0.1", "oidc-client-ts": "^3.0.1",
@@ -8792,21 +8813,23 @@
} }
}, },
"node_modules/matrix-js-sdk/node_modules/uuid": { "node_modules/matrix-js-sdk/node_modules/uuid": {
"version": "11.0.5", "version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [ "funding": [
"https://github.com/sponsors/broofa", "https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan" "https://github.com/sponsors/ctavan"
], ],
"license": "MIT",
"bin": { "bin": {
"uuid": "dist/esm/bin/uuid" "uuid": "dist/esm/bin/uuid"
} }
}, },
"node_modules/matrix-widget-api": { "node_modules/matrix-widget-api": {
"version": "1.12.0", "version": "1.13.1",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.12.0.tgz", "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz",
"integrity": "sha512-6JRd9fJGGvuBRhcTg9wX+Skn/Q1wox3jdp5yYQKJ6pPw4urW9bkTR90APBKVDB1vorJKT44jml+lCzkDMRBjww==", "integrity": "sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"@types/events": "^3.0.0", "@types/events": "^3.0.0",
"events": "^3.2.0" "events": "^3.2.0"
@@ -9198,9 +9221,10 @@
} }
}, },
"node_modules/oidc-client-ts": { "node_modules/oidc-client-ts": {
"version": "3.1.0", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.1.0.tgz", "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.1.tgz",
"integrity": "sha512-IDopEXjiwjkmJLYZo6BTlvwOtnlSniWZkKZoXforC/oLZHC9wkIxd25Kwtmo5yKFMMVcsp3JY6bhcNJqdYk8+g==", "integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"jwt-decode": "^4.0.0" "jwt-decode": "^4.0.0"
}, },
@@ -9288,6 +9312,7 @@
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@types/retry": "0.12.0", "@types/retry": "0.12.0",
"retry": "^0.13.1" "retry": "^0.13.1"
@@ -10051,6 +10076,7 @@
"version": "0.13.1", "version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 4" "node": ">= 4"
} }
@@ -10264,6 +10290,7 @@
"version": "2.15.0", "version": "2.15.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
"license": "MIT",
"bin": { "bin": {
"sdp-verify": "checker.js" "sdp-verify": "checker.js"
} }
@@ -11172,7 +11199,8 @@
"node_modules/unhomoglyph": { "node_modules/unhomoglyph": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz", "resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
"integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==" "integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==",
"license": "MIT"
}, },
"node_modules/unicode-canonical-property-names-ecmascript": { "node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1", "version": "2.0.1",
@@ -11303,9 +11331,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.15", "version": "5.4.19",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.15.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
"integrity": "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==", "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",

View File

@@ -1,6 +1,6 @@
{ {
"name": "cinny", "name": "cinny",
"version": "4.6.0", "version": "4.8.1",
"description": "Yet another matrix client", "description": "Yet another matrix client",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -32,6 +32,7 @@
"@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", "chroma-js": "3.1.2",
@@ -44,7 +45,7 @@
"file-saver": "2.0.5", "file-saver": "2.0.5",
"flux": "4.0.3", "flux": "4.0.3",
"focus-trap-react": "10.0.2", "focus-trap-react": "10.0.2",
"folds": "2.1.0", "folds": "2.2.0",
"formik": "2.4.6", "formik": "2.4.6",
"html-dom-parser": "4.0.0", "html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0", "html-react-parser": "4.2.0",
@@ -56,7 +57,7 @@
"jotai": "2.6.0", "jotai": "2.6.0",
"linkify-react": "4.1.3", "linkify-react": "4.1.3",
"linkifyjs": "4.1.3", "linkifyjs": "4.1.3",
"matrix-js-sdk": "35.0.0", "matrix-js-sdk": "37.5.0",
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "4.2.67", "pdfjs-dist": "4.2.67",
"prismjs": "1.30.0", "prismjs": "1.30.0",
@@ -109,7 +110,7 @@
"prettier": "2.8.1", "prettier": "2.8.1",
"sass": "1.56.2", "sass": "1.56.2",
"typescript": "4.9.4", "typescript": "4.9.4",
"vite": "5.4.15", "vite": "5.4.19",
"vite-plugin-pwa": "0.20.5", "vite-plugin-pwa": "0.20.5",
"vite-plugin-static-copy": "1.0.4", "vite-plugin-static-copy": "1.0.4",
"vite-plugin-top-level-await": "1.4.4" "vite-plugin-top-level-await": "1.4.4"

View File

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

View File

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

@@ -17,12 +17,16 @@ import { JoinRule } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard'; import { stopPropagation } from '../utils/keyboard';
type JoinRuleIcons = Record<JoinRule, IconSrc>; export type ExtraJoinRules = 'knock_restricted';
export type ExtendedJoinRules = JoinRule | ExtraJoinRules;
type JoinRuleIcons = Record<ExtendedJoinRules, IconSrc>;
export const useRoomJoinRuleIcon = (): JoinRuleIcons => export const useRoomJoinRuleIcon = (): JoinRuleIcons =>
useMemo( useMemo(
() => ({ () => ({
[JoinRule.Invite]: Icons.HashLock, [JoinRule.Invite]: Icons.HashLock,
[JoinRule.Knock]: Icons.HashLock, [JoinRule.Knock]: Icons.HashLock,
knock_restricted: Icons.Hash,
[JoinRule.Restricted]: Icons.Hash, [JoinRule.Restricted]: Icons.Hash,
[JoinRule.Public]: Icons.HashGlobe, [JoinRule.Public]: Icons.HashGlobe,
[JoinRule.Private]: Icons.HashLock, [JoinRule.Private]: Icons.HashLock,
@@ -34,6 +38,7 @@ export const useSpaceJoinRuleIcon = (): JoinRuleIcons =>
() => ({ () => ({
[JoinRule.Invite]: Icons.SpaceLock, [JoinRule.Invite]: Icons.SpaceLock,
[JoinRule.Knock]: Icons.SpaceLock, [JoinRule.Knock]: Icons.SpaceLock,
knock_restricted: Icons.Space,
[JoinRule.Restricted]: Icons.Space, [JoinRule.Restricted]: Icons.Space,
[JoinRule.Public]: Icons.SpaceGlobe, [JoinRule.Public]: Icons.SpaceGlobe,
[JoinRule.Private]: Icons.SpaceLock, [JoinRule.Private]: Icons.SpaceLock,
@@ -41,12 +46,13 @@ export const useSpaceJoinRuleIcon = (): JoinRuleIcons =>
[] []
); );
type JoinRuleLabels = Record<JoinRule, string>; type JoinRuleLabels = Record<ExtendedJoinRules, string>;
export const useRoomJoinRuleLabel = (): JoinRuleLabels => export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
useMemo( useMemo(
() => ({ () => ({
[JoinRule.Invite]: 'Invite Only', [JoinRule.Invite]: 'Invite Only',
[JoinRule.Knock]: 'Knock & Invite', [JoinRule.Knock]: 'Knock & Invite',
knock_restricted: 'Space Members or Knock',
[JoinRule.Restricted]: 'Space Members', [JoinRule.Restricted]: 'Space Members',
[JoinRule.Public]: 'Public', [JoinRule.Public]: 'Public',
[JoinRule.Private]: 'Invite Only', [JoinRule.Private]: 'Invite Only',
@@ -54,7 +60,7 @@ export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
[] []
); );
type JoinRulesSwitcherProps<T extends JoinRule[]> = { type JoinRulesSwitcherProps<T extends ExtendedJoinRules[]> = {
icons: JoinRuleIcons; icons: JoinRuleIcons;
labels: JoinRuleLabels; labels: JoinRuleLabels;
rules: T; rules: T;
@@ -63,7 +69,7 @@ type JoinRulesSwitcherProps<T extends JoinRule[]> = {
disabled?: boolean; disabled?: boolean;
changing?: boolean; changing?: boolean;
}; };
export function JoinRulesSwitcher<T extends JoinRule[]>({ export function JoinRulesSwitcher<T extends ExtendedJoinRules[]>({
icons, icons,
labels, labels,
rules, rules,
@@ -79,7 +85,7 @@ export function JoinRulesSwitcher<T extends JoinRule[]>({
}; };
const handleChange = useCallback( const handleChange = useCallback(
(selectedRule: JoinRule) => { (selectedRule: ExtendedJoinRules) => {
setCords(undefined); setCords(undefined);
onChange(selectedRule); onChange(selectedRule);
}, },
@@ -131,7 +137,7 @@ export function JoinRulesSwitcher<T extends JoinRule[]>({
fill="Soft" fill="Soft"
radii="300" radii="300"
outlined outlined
before={<Icon size="100" src={icons[value]} />} before={<Icon size="100" src={icons[value] ?? icons[JoinRule.Restricted]} />}
after={ after={
changing ? ( changing ? (
<Spinner size="100" variant="Secondary" fill="Soft" /> <Spinner size="100" variant="Secondary" fill="Soft" />
@@ -142,7 +148,7 @@ export function JoinRulesSwitcher<T extends JoinRule[]>({
onClick={handleOpenMenu} onClick={handleOpenMenu}
disabled={disabled} disabled={disabled}
> >
<Text size="B300">{labels[value]}</Text> <Text size="B300">{labels[value] ?? 'Unsupported'}</Text>
</Button> </Button>
</PopOut> </PopOut>
); );

View File

@@ -1,7 +1,6 @@
import React, { FormEventHandler, useCallback } from 'react'; import React, { FormEventHandler, useCallback } from 'react';
import { Box, Text, Button, Spinner, color } from 'folds'; import { Box, Text, Button, Spinner, color } from 'folds';
import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto-api'; import { decodeRecoveryKey, deriveRecoveryKeyFromPassphrase } from 'matrix-js-sdk/lib/crypto-api';
import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase';
import { PasswordInput } from './password-input'; import { PasswordInput } from './password-input';
import { import {
SecretStorageKeyContent, SecretStorageKeyContent,
@@ -29,11 +28,16 @@ export function SecretStorageRecoveryPassphrase({
const [driveKeyState, submitPassphrase] = useAsyncCallback< const [driveKeyState, submitPassphrase] = useAsyncCallback<
Uint8Array, Uint8Array,
Error, Error,
Parameters<typeof deriveKey> Parameters<typeof deriveRecoveryKeyFromPassphrase>
>( >(
useCallback( useCallback(
async (passphrase, salt, iterations, bits) => { async (passphrase, salt, iterations, bits) => {
const decodedRecoveryKey = await deriveKey(passphrase, salt, iterations, bits); const decodedRecoveryKey = await deriveRecoveryKeyFromPassphrase(
passphrase,
salt,
iterations,
bits
);
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any); const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);

View File

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

View File

@@ -157,7 +157,7 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
<Text as="pre" className={css.CodeBlock} {...attributes}> <Text as="pre" className={css.CodeBlock} {...attributes}>
<Scroll <Scroll
direction="Horizontal" direction="Horizontal"
variant="Secondary" variant="SurfaceVariant"
size="300" size="300"
visibility="Hover" visibility="Hover"
hideTrack hideTrack

View File

@@ -9,7 +9,7 @@ import { getDirectRoomAvatarUrl } from '../../../utils/room';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AutocompleteQuery } from './autocompleteQuery'; import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu'; import { AutocompleteMenu } from './AutocompleteMenu';
import { getMxIdServer, validMxId } from '../../../utils/matrix'; import { getMxIdServer, isRoomAlias } from '../../../utils/matrix';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch'; import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard'; import { onTabPress } from '../../../utils/keyboard';
import { useKeyDown } from '../../../hooks/useKeyDown'; import { useKeyDown } from '../../../hooks/useKeyDown';
@@ -22,7 +22,7 @@ import { getViaServers } from '../../../plugins/via-servers';
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void; type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
const roomAliasFromQueryText = (mx: MatrixClient, text: string) => const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
validMxId(`#${text}`) isRoomAlias(`#${text}`)
? `#${text}` ? `#${text}`
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; : `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;

View File

@@ -15,7 +15,7 @@ import {
import { onTabPress } from '../../../utils/keyboard'; import { onTabPress } from '../../../utils/keyboard';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils'; import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { useKeyDown } from '../../../hooks/useKeyDown'; import { useKeyDown } from '../../../hooks/useKeyDown';
import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix'; import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room'; import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
import { UserAvatar } from '../../user-avatar'; import { UserAvatar } from '../../user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
@@ -24,7 +24,7 @@ import { Membership } from '../../../../types/matrix/room';
type MentionAutoCompleteHandler = (userId: string, name: string) => void; type MentionAutoCompleteHandler = (userId: string, name: string) => void;
const userIdFromQueryText = (mx: MatrixClient, text: string) => const userIdFromQueryText = (mx: MatrixClient, text: string) =>
validMxId(`@${text}`) isUserId(`@${text}`)
? `@${text}` ? `@${text}`
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;

View File

@@ -7,7 +7,6 @@ export const ReplyBend = style({
export const ThreadIndicator = style({ export const ThreadIndicator = style({
opacity: config.opacity.P300, opacity: config.opacity.P300,
gap: toRem(2),
selectors: { selectors: {
'button&': { 'button&': {
@@ -19,11 +18,6 @@ export const ThreadIndicator = style({
}, },
}); });
export const ThreadIndicatorIcon = style({
width: toRem(14),
height: toRem(14),
});
export const Reply = style({ export const Reply = style({
marginBottom: toRem(1), marginBottom: toRem(1),
minWidth: 0, minWidth: 0,

View File

@@ -38,9 +38,16 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
); );
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => ( export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
<Box className={css.ThreadIndicator} alignItems="Center" {...props} ref={ref}> <Box
<Icon className={css.ThreadIndicatorIcon} src={Icons.Message} /> shrink="No"
<Text size="T200">Threaded reply</Text> className={css.ThreadIndicator}
alignItems="Center"
gap="100"
{...props}
ref={ref}
>
<Icon size="50" src={Icons.Thread} />
<Text size="L400">Thread</Text>
</Box> </Box>
)); ));
@@ -97,7 +104,7 @@ export const Reply = as<'div', ReplyProps>(
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody; const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
return ( return (
<Box direction="Column" alignItems="Start" {...props} ref={ref}> <Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
{threadRootId && ( {threadRootId && (
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} /> <ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
)} )}

View File

@@ -5,19 +5,35 @@ import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/ti
export type TimeProps = { export type TimeProps = {
compact?: boolean; compact?: boolean;
ts: number; ts: number;
hour24Clock: boolean;
dateFormatString: string;
}; };
/**
* Renders a formatted timestamp, supporting compact and full display modes.
*
* Displays the time in hour:minute format if the message is from today, yesterday, or if `compact` is true.
* For older messages, it shows the date and time.
*
* @param {number} ts - The timestamp to display.
* @param {boolean} [compact=false] - If true, always show only the time.
* @param {boolean} hour24Clock - Whether to use 24-hour time format.
* @param {string} dateFormatString - Format string for the date part.
* @returns {React.ReactElement} A <Text as="time"> element with the formatted date/time.
*/
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>( export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
({ compact, ts, ...props }, ref) => { ({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => {
const formattedTime = timeHourMinute(ts, hour24Clock);
let time = ''; let time = '';
if (compact) { if (compact) {
time = timeHourMinute(ts); time = formattedTime;
} else if (today(ts)) { } else if (today(ts)) {
time = timeHourMinute(ts); time = formattedTime;
} else if (yesterday(ts)) { } else if (yesterday(ts)) {
time = `Yesterday ${timeHourMinute(ts)}`; time = `Yesterday ${formattedTime}`;
} else { } else {
time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`; time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`;
} }
return ( return (

View File

@@ -30,6 +30,7 @@ import { stopPropagation } from '../../../utils/keyboard';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix'; import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css'; import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash';
type RenderViewerProps = { type RenderViewerProps = {
src: string; src: string;
@@ -77,7 +78,7 @@ export const ImageContent = as<'div', ImageContentProps>(
) => { ) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const blurHash = info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]; const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const [load, setLoad] = useState(false); const [load, setLoad] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);

View File

@@ -31,6 +31,7 @@ import {
mxcUrlToHttp, mxcUrlToHttp,
} from '../../../utils/matrix'; } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash';
type RenderVideoProps = { type RenderVideoProps = {
title: string; title: string;
@@ -68,7 +69,7 @@ export const VideoContent = as<'div', VideoContentProps>(
) => { ) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const blurHash = info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]; const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const [load, setLoad] = useState(false); const [load, setLoad] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);

View File

@@ -16,6 +16,7 @@ export const AbsoluteContainer = style([
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
zIndex: 1,
width: '100%', width: '100%',
height: '100%', height: '100%',
}, },

View File

@@ -105,6 +105,20 @@ export const PageContent = as<'div'>(({ className, ...props }, ref) => (
<div className={classNames(css.PageContent, className)} {...props} ref={ref} /> <div className={classNames(css.PageContent, className)} {...props} ref={ref} />
)); ));
export function PageHeroEmpty({ children }: { children: ReactNode }) {
return (
<Box
className={classNames(ContainerColor({ variant: 'SurfaceVariant' }), css.PageHeroEmpty)}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="200"
>
{children}
</Box>
);
}
export const PageHeroSection = as<'div', ComponentProps<typeof Box>>( export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<Box <Box

View File

@@ -92,6 +92,15 @@ export const PageContent = style([
}, },
]); ]);
export const PageHeroEmpty = style([
DefaultReset,
{
padding: config.space.S400,
borderRadius: config.radii.R400,
minHeight: toRem(450),
},
]);
export const PageHeroSection = style([ export const PageHeroSection = style([
DefaultReset, DefaultReset,
{ {

View File

@@ -15,6 +15,8 @@ import { nameInitials } from '../../utils/common';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta'; import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
import { mDirectAtom } from '../../state/mDirectList'; import { mDirectAtom } from '../../state/mDirectList';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
export type RoomIntroProps = { export type RoomIntroProps = {
room: Room; room: Room;
@@ -43,6 +45,8 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]) useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
); );
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
return ( return (
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}> <Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
<Box> <Box>
@@ -67,7 +71,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
<Text size="T200" priority="300"> <Text size="T200" priority="300">
{'Created by '} {'Created by '}
<b>@{creatorName}</b> <b>@{creatorName}</b>
{` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts)}`} {` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`}
</Text> </Text>
)} )}
</Box> </Box>

View File

@@ -24,7 +24,7 @@ export const TextViewerContent = forwardRef<HTMLPreElement, TextViewerContentPro
> >
<ErrorBoundary fallback={<code>{text}</code>}> <ErrorBoundary fallback={<code>{text}</code>}>
<Suspense fallback={<code>{text}</code>}> <Suspense fallback={<code>{text}</code>}>
<ReactPrism>{(codeRef) => <code ref={codeRef}>{text}</code>}</ReactPrism> <ReactPrism key={text}>{(codeRef) => <code ref={codeRef}>{text}</code>}</ReactPrism>
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
</Text> </Text>

View File

@@ -0,0 +1,129 @@
import React, { forwardRef } from 'react';
import { Menu, Box, Text, Chip } from 'folds';
import dayjs from 'dayjs';
import * as css from './styles.css';
import { PickerColumn } from './PickerColumn';
import { dateFor, daysInMonth, daysToMs } from '../../utils/time';
type DatePickerProps = {
min: number;
max: number;
value: number;
onChange: (value: number) => void;
};
export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
({ min, max, value, onChange }, ref) => {
const selectedYear = dayjs(value).year();
const selectedMonth = dayjs(value).month() + 1;
const selectedDay = dayjs(value).date();
const handleSubmit = (newValue: number) => {
onChange(Math.min(Math.max(min, newValue), max));
};
const handleDay = (day: number) => {
const seconds = daysToMs(day);
const lastSeconds = daysToMs(selectedDay);
const newValue = value + (seconds - lastSeconds);
handleSubmit(newValue);
};
const handleMonthAndYear = (month: number, year: number) => {
const mDays = daysInMonth(month, year);
const currentDate = dateFor(selectedYear, selectedMonth, selectedDay);
const time = value - currentDate;
const newDate = dateFor(year, month, mDays < selectedDay ? mDays : selectedDay);
const newValue = newDate + time;
handleSubmit(newValue);
};
const handleMonth = (month: number) => {
handleMonthAndYear(month, selectedYear);
};
const handleYear = (year: number) => {
handleMonthAndYear(selectedMonth, year);
};
const minYear = dayjs(min).year();
const maxYear = dayjs(max).year();
const yearsRange = maxYear - minYear + 1;
const minMonth = dayjs(min).month() + 1;
const maxMonth = dayjs(max).month() + 1;
const minDay = dayjs(min).date();
const maxDay = dayjs(max).date();
return (
<Menu className={css.PickerMenu} ref={ref}>
<Box direction="Row" gap="200" className={css.PickerContainer}>
<PickerColumn title="Day">
{Array.from(Array(daysInMonth(selectedMonth, selectedYear)).keys())
.map((i) => i + 1)
.map((day) => (
<Chip
key={day}
size="500"
variant={selectedDay === day ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-selected={selectedDay === day}
onClick={() => handleDay(day)}
disabled={
(selectedYear === minYear && selectedMonth === minMonth && day < minDay) ||
(selectedYear === maxYear && selectedMonth === maxMonth && day > maxDay)
}
>
<Text size="T300">{day}</Text>
</Chip>
))}
</PickerColumn>
<PickerColumn title="Month">
{Array.from(Array(12).keys())
.map((i) => i + 1)
.map((month) => (
<Chip
key={month}
size="500"
variant={selectedMonth === month ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-selected={selectedMonth === month}
onClick={() => handleMonth(month)}
disabled={
(selectedYear === minYear && month < minMonth) ||
(selectedYear === maxYear && month > maxMonth)
}
>
<Text size="T300">
{dayjs()
.month(month - 1)
.format('MMM')}
</Text>
</Chip>
))}
</PickerColumn>
<PickerColumn title="Year">
{Array.from(Array(yearsRange).keys())
.map((i) => minYear + i)
.map((year) => (
<Chip
key={year}
size="500"
variant={selectedYear === year ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-selected={selectedYear === year}
onClick={() => handleYear(year)}
>
<Text size="T300">{year}</Text>
</Chip>
))}
</PickerColumn>
</Box>
</Menu>
);
}
);

View File

@@ -0,0 +1,23 @@
import React, { ReactNode } from 'react';
import { Box, Text, Scroll } from 'folds';
import { CutoutCard } from '../cutout-card';
import * as css from './styles.css';
export function PickerColumn({ title, children }: { title: string; children: ReactNode }) {
return (
<Box direction="Column" gap="100">
<Text className={css.PickerColumnLabel} size="L400">
{title}
</Text>
<Box grow="Yes">
<CutoutCard variant="Background">
<Scroll variant="Background" size="300" hideTrack>
<Box className={css.PickerColumnContent} direction="Column" gap="100">
{children}
</Box>
</Scroll>
</CutoutCard>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,153 @@
import React, { forwardRef } from 'react';
import { Menu, Box, Text, Chip } from 'folds';
import dayjs from 'dayjs';
import * as css from './styles.css';
import { PickerColumn } from './PickerColumn';
import { hour12to24, hour24to12, hoursToMs, inSameDay, minutesToMs } from '../../utils/time';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
type TimePickerProps = {
min: number;
max: number;
value: number;
onChange: (value: number) => void;
};
export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
({ min, max, value, onChange }, ref) => {
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const hour24 = dayjs(value).hour();
const selectedHour = hour24Clock ? hour24 : hour24to12(hour24);
const selectedMinute = dayjs(value).minute();
const selectedPM = hour24 >= 12;
const handleSubmit = (newValue: number) => {
onChange(Math.min(Math.max(min, newValue), max));
};
const handleHour = (hour: number) => {
const seconds = hoursToMs(hour24Clock ? hour : hour12to24(hour, selectedPM));
const lastSeconds = hoursToMs(hour24);
const newValue = value + (seconds - lastSeconds);
handleSubmit(newValue);
};
const handleMinute = (minute: number) => {
const seconds = minutesToMs(minute);
const lastSeconds = minutesToMs(selectedMinute);
const newValue = value + (seconds - lastSeconds);
handleSubmit(newValue);
};
const handlePeriod = (pm: boolean) => {
const seconds = hoursToMs(hour12to24(selectedHour, pm));
const lastSeconds = hoursToMs(hour24);
const newValue = value + (seconds - lastSeconds);
handleSubmit(newValue);
};
const minHour24 = dayjs(min).hour();
const maxHour24 = dayjs(max).hour();
const minMinute = dayjs(min).minute();
const maxMinute = dayjs(max).minute();
const minPM = minHour24 >= 12;
const maxPM = maxHour24 >= 12;
const minDay = inSameDay(min, value);
const maxDay = inSameDay(max, value);
return (
<Menu className={css.PickerMenu} ref={ref}>
<Box direction="Row" gap="200" className={css.PickerContainer}>
<PickerColumn title="Hour">
{hour24Clock
? Array.from(Array(24).keys()).map((hour) => (
<Chip
key={hour}
size="500"
variant={hour === selectedHour ? 'Primary' : 'Background'}
fill="None"
radii="300"
aria-selected={hour === selectedHour}
onClick={() => handleHour(hour)}
disabled={(minDay && hour < minHour24) || (maxDay && hour > maxHour24)}
>
<Text size="T300">{hour < 10 ? `0${hour}` : hour}</Text>
</Chip>
))
: Array.from(Array(12).keys())
.map((i) => {
if (i === 0) return 12;
return i;
})
.map((hour) => (
<Chip
key={hour}
size="500"
variant={hour === selectedHour ? 'Primary' : 'Background'}
fill="None"
radii="300"
aria-selected={hour === selectedHour}
onClick={() => handleHour(hour)}
disabled={
(minDay && hour12to24(hour, selectedPM) < minHour24) ||
(maxDay && hour12to24(hour, selectedPM) > maxHour24)
}
>
<Text size="T300">{hour < 10 ? `0${hour}` : hour}</Text>
</Chip>
))}
</PickerColumn>
<PickerColumn title="Minutes">
{Array.from(Array(60).keys()).map((minute) => (
<Chip
key={minute}
size="500"
variant={minute === selectedMinute ? 'Primary' : 'Background'}
fill="None"
radii="300"
aria-selected={minute === selectedMinute}
onClick={() => handleMinute(minute)}
disabled={
(minDay && hour24 === minHour24 && minute < minMinute) ||
(maxDay && hour24 === maxHour24 && minute > maxMinute)
}
>
<Text size="T300">{minute < 10 ? `0${minute}` : minute}</Text>
</Chip>
))}
</PickerColumn>
{!hour24Clock && (
<PickerColumn title="Period">
<Chip
size="500"
variant={!selectedPM ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-selected={!selectedPM}
onClick={() => handlePeriod(false)}
disabled={minDay && minPM}
>
<Text size="T300">AM</Text>
</Chip>
<Chip
size="500"
variant={selectedPM ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-selected={selectedPM}
onClick={() => handlePeriod(true)}
disabled={maxDay && !maxPM}
>
<Text size="T300">PM</Text>
</Chip>
</PickerColumn>
)}
</Box>
</Menu>
);
}
);

View File

@@ -0,0 +1,2 @@
export * from './TimePicker';
export * from './DatePicker';

View File

@@ -0,0 +1,16 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
export const PickerMenu = style({
padding: config.space.S200,
});
export const PickerContainer = style({
maxHeight: toRem(250),
});
export const PickerColumnLabel = style({
padding: config.space.S200,
});
export const PickerColumnContent = style({
padding: config.space.S200,
paddingRight: 0,
});

View File

@@ -2,8 +2,10 @@ import React, { useCallback, useMemo } from 'react';
import { color, Text } from 'folds'; import { color, Text } from 'folds';
import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk'; import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { useAtomValue } from 'jotai';
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels'; import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
import { import {
ExtendedJoinRules,
JoinRulesSwitcher, JoinRulesSwitcher,
useRoomJoinRuleIcon, useRoomJoinRuleIcon,
useRoomJoinRuleLabel, useRoomJoinRuleLabel,
@@ -19,6 +21,12 @@ import { useStateEvent } from '../../../hooks/useStateEvent';
import { useSpaceOptionally } from '../../../hooks/useSpace'; import { useSpaceOptionally } from '../../../hooks/useSpace';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { getStateEvents } from '../../../utils/room'; import { getStateEvents } from '../../../utils/room';
import {
useRecursiveChildSpaceScopeFactory,
useSpaceChildren,
} from '../../../state/hooks/roomList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
type RestrictedRoomAllowContent = { type RestrictedRoomAllowContent = {
room_id: string; room_id: string;
@@ -32,9 +40,14 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const room = useRoom(); const room = useRoom();
const roomVersion = parseInt(room.getVersion(), 10); const roomVersion = parseInt(room.getVersion(), 10);
const allowKnockRestricted = roomVersion >= 10;
const allowRestricted = roomVersion >= 8; const allowRestricted = roomVersion >= 8;
const allowKnock = roomVersion >= 7; const allowKnock = roomVersion >= 7;
const roomIdToParents = useAtomValue(roomToParentsAtom);
const space = useSpaceOptionally(); const space = useSpaceOptionally();
const subspacesScope = useRecursiveChildSpaceScopeFactory(mx, roomIdToParents);
const subspaces = useSpaceChildren(allRoomsAtom, space?.roomId ?? '', subspacesScope);
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId()); const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
const canEdit = powerLevelAPI.canSendStateEvent( const canEdit = powerLevelAPI.canSendStateEvent(
@@ -47,18 +60,21 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
const content = joinRuleEvent?.getContent<RoomJoinRulesEventContent>(); const content = joinRuleEvent?.getContent<RoomJoinRulesEventContent>();
const rule: JoinRule = content?.join_rule ?? JoinRule.Invite; const rule: JoinRule = content?.join_rule ?? JoinRule.Invite;
const joinRules: Array<JoinRule> = useMemo(() => { const joinRules: Array<ExtendedJoinRules> = useMemo(() => {
const r: JoinRule[] = [JoinRule.Invite]; const r: ExtendedJoinRules[] = [JoinRule.Invite];
if (allowKnock) { if (allowKnock) {
r.push(JoinRule.Knock); r.push(JoinRule.Knock);
} }
if (allowRestricted && space) { if (allowRestricted && space) {
r.push(JoinRule.Restricted); r.push(JoinRule.Restricted);
} }
if (allowKnockRestricted && space) {
r.push('knock_restricted');
}
r.push(JoinRule.Public); r.push(JoinRule.Public);
return r; return r;
}, [allowRestricted, allowKnock, space]); }, [allowKnockRestricted, allowRestricted, allowKnock, space]);
const icons = useRoomJoinRuleIcon(); const icons = useRoomJoinRuleIcon();
const spaceIcons = useSpaceJoinRuleIcon(); const spaceIcons = useSpaceJoinRuleIcon();
@@ -66,12 +82,25 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
const [submitState, submit] = useAsyncCallback( const [submitState, submit] = useAsyncCallback(
useCallback( useCallback(
async (joinRule: JoinRule) => { async (joinRule: ExtendedJoinRules) => {
const allow: RestrictedRoomAllowContent[] = []; const allow: RestrictedRoomAllowContent[] = [];
if (joinRule === JoinRule.Restricted) { if (joinRule === JoinRule.Restricted || joinRule === 'knock_restricted') {
const parents = getStateEvents(room, StateEvent.SpaceParent).map((event) => const roomParents = roomIdToParents.get(room.roomId);
event.getStateKey()
); const parents = getStateEvents(room, StateEvent.SpaceParent)
.map((event) => event.getStateKey())
.filter((parentId) => typeof parentId === 'string')
.filter((parentId) => roomParents?.has(parentId));
if (parents.length === 0 && space && roomParents) {
// if no m.space.parent found
// find parent in current space
const selectedParents = subspaces.filter((rId) => roomParents.has(rId));
if (roomParents.has(space.roomId)) {
selectedParents.push(space.roomId);
}
selectedParents.forEach((pId) => parents.push(pId));
}
parents.forEach((parentRoomId) => { parents.forEach((parentRoomId) => {
if (!parentRoomId) return; if (!parentRoomId) return;
allow.push({ allow.push({
@@ -82,12 +111,12 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
} }
const c: RoomJoinRulesEventContent = { const c: RoomJoinRulesEventContent = {
join_rule: joinRule, join_rule: joinRule as JoinRule,
}; };
if (allow.length > 0) c.allow = allow; if (allow.length > 0) c.allow = allow;
await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c); await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c);
}, },
[mx, room] [mx, room, space, subspaces, roomIdToParents]
) )
); );

View File

@@ -1,5 +1,5 @@
import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react'; import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds'; import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config } from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@@ -36,7 +36,7 @@ import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
import { useCategoryHandler } from '../../hooks/useCategoryHandler'; import { useCategoryHandler } from '../../hooks/useCategoryHandler';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList'; import { allRoomsAtom } from '../../state/room-list/roomList';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; import { getCanonicalAliasOrRoomId, rateLimitedActions } from '../../utils/matrix';
import { getSpaceRoomPath } from '../../pages/pathUtils'; import { getSpaceRoomPath } from '../../pages/pathUtils';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { CanDropCallback, useDnDMonitor } from './DnD'; import { CanDropCallback, useDnDMonitor } from './DnD';
@@ -53,6 +53,95 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
import { AccountDataEvent } from '../../../types/matrix/accountData'; import { AccountDataEvent } from '../../../types/matrix/accountData';
import { useRoomMembers } from '../../hooks/useRoomMembers'; import { useRoomMembers } from '../../hooks/useRoomMembers';
import { SpaceHierarchy } from './SpaceHierarchy'; import { SpaceHierarchy } from './SpaceHierarchy';
import { useGetRoom } from '../../hooks/useGetRoom';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
const useCanDropLobbyItem = (
space: Room,
roomsPowerLevels: Map<string, IPowerLevels>,
getRoom: (roomId: string) => Room | undefined,
canEditSpaceChild: (powerLevels: IPowerLevels) => boolean
): CanDropCallback => {
const mx = useMatrixClient();
const canDropSpace: CanDropCallback = useCallback(
(item, container) => {
if (!('space' in container.item)) {
// can not drop around rooms.
// space can only be drop around other spaces
return false;
}
const containerSpaceId = space.roomId;
if (
getRoom(containerSpaceId) === undefined ||
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
) {
return false;
}
return true;
},
[space, roomsPowerLevels, getRoom, canEditSpaceChild]
);
const canDropRoom: CanDropCallback = useCallback(
(item, container) => {
const containerSpaceId =
'space' in container.item ? container.item.roomId : container.item.parentId;
const draggingOutsideSpace = item.parentId !== containerSpaceId;
const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted;
// check and do not allow restricted room to be dragged outside
// current space if can't change `m.room.join_rules` `content.allow`
if (draggingOutsideSpace && restrictedItem) {
const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
const userPLInItem = powerLevelAPI.getPowerLevel(
itemPowerLevel,
mx.getUserId() ?? undefined
);
const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent(
itemPowerLevel,
StateEvent.RoomJoinRules,
userPLInItem
);
if (!canChangeJoinRuleAllow) {
return false;
}
}
if (
getRoom(containerSpaceId) === undefined ||
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
) {
return false;
}
return true;
},
[mx, getRoom, canEditSpaceChild, roomsPowerLevels]
);
const canDrop: CanDropCallback = useCallback(
(item, container): boolean => {
if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) {
// can not drop before or after itself
return false;
}
// if we are dragging a space
if ('space' in item) {
return canDropSpace(item, container);
}
return canDropRoom(item, container);
},
[canDropSpace, canDropRoom]
);
return canDrop;
};
export function Lobby() { export function Lobby() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -92,15 +181,7 @@ export function Lobby() {
useCallback((w, height) => setHeroSectionHeight(height), []) useCallback((w, height) => setHeroSectionHeight(height), [])
); );
const getRoom = useCallback( const getRoom = useGetRoom(allJoinedRooms);
(rId: string) => {
if (allJoinedRooms.has(rId)) {
return mx.getRoom(rId) ?? undefined;
}
return undefined;
},
[mx, allJoinedRooms]
);
const canEditSpaceChild = useCallback( const canEditSpaceChild = useCallback(
(powerLevels: IPowerLevels) => (powerLevels: IPowerLevels) =>
@@ -150,180 +231,155 @@ export function Lobby() {
) )
); );
const canDrop: CanDropCallback = useCallback( const canDrop: CanDropCallback = useCanDropLobbyItem(
(item, container): boolean => { space,
const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted; roomsPowerLevels,
if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) { getRoom,
// can not drop before or after itself canEditSpaceChild
return false;
}
if ('space' in item) {
if (!('space' in container.item)) return false;
const containerSpaceId = space.roomId;
if (
getRoom(containerSpaceId) === undefined ||
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
) {
return false;
}
return true;
}
const containerSpaceId =
'space' in container.item ? container.item.roomId : container.item.parentId;
const dropOutsideSpace = item.parentId !== containerSpaceId;
if (dropOutsideSpace && restrictedItem) {
// do not allow restricted room to drop outside
// current space if can't change join rule allow
const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
const userPLInItem = powerLevelAPI.getPowerLevel(
itemPowerLevel,
mx.getUserId() ?? undefined
);
const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent(
itemPowerLevel,
StateEvent.RoomJoinRules,
userPLInItem
);
if (!canChangeJoinRuleAllow) {
return false;
}
}
if (
getRoom(containerSpaceId) === undefined ||
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
) {
return false;
}
return true;
},
[getRoom, space.roomId, roomsPowerLevels, canEditSpaceChild, mx]
); );
const reorderSpace = useCallback( const [reorderSpaceState, reorderSpace] = useAsyncCallback(
(item: HierarchyItemSpace, containerItem: HierarchyItem) => { useCallback(
if (!item.parentId) return; async (item: HierarchyItemSpace, containerItem: HierarchyItem) => {
if (!item.parentId) return;
const itemSpaces: HierarchyItemSpace[] = hierarchy const itemSpaces: HierarchyItemSpace[] = hierarchy
.map((i) => i.space) .map((i) => i.space)
.filter((i) => i.roomId !== item.roomId); .filter((i) => i.roomId !== item.roomId);
const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId); const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId);
const insertIndex = beforeIndex + 1; const insertIndex = beforeIndex + 1;
itemSpaces.splice(insertIndex, 0, { itemSpaces.splice(insertIndex, 0, {
...item, ...item,
content: { ...item.content, order: undefined }, content: { ...item.content, order: undefined },
}); });
const currentOrders = itemSpaces.map((i) => { const currentOrders = itemSpaces.map((i) => {
if (typeof i.content.order === 'string' && lex.has(i.content.order)) { if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
return i.content.order; return i.content.order;
} }
return undefined; return undefined;
}); });
const newOrders = orderKeys(lex, currentOrders); const newOrders = orderKeys(lex, currentOrders);
newOrders?.forEach((orderKey, index) => { const reorders = newOrders
const itm = itemSpaces[index]; ?.map((orderKey, index) => ({
if (!itm || !itm.parentId) return; item: itemSpaces[index],
const parentPL = roomsPowerLevels.get(itm.parentId); orderKey,
const canEdit = parentPL && canEditSpaceChild(parentPL); }))
if (canEdit && orderKey !== currentOrders[index]) { .filter((reorder, index) => {
mx.sendStateEvent( if (!reorder.item.parentId) return false;
itm.parentId, const parentPL = roomsPowerLevels.get(reorder.item.parentId);
StateEvent.SpaceChild as any, const canEdit = parentPL && canEditSpaceChild(parentPL);
{ ...itm.content, order: orderKey }, return canEdit && reorder.orderKey !== currentOrders[index];
itm.roomId });
);
}
});
},
[mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
);
const reorderRoom = useCallback( if (reorders) {
(item: HierarchyItem, containerItem: HierarchyItem): void => { await rateLimitedActions(reorders, async (reorder) => {
const itemRoom = mx.getRoom(item.roomId); if (!reorder.item.parentId) return;
if (!item.parentId) { await mx.sendStateEvent(
return; reorder.item.parentId,
} StateEvent.SpaceChild as any,
const containerParentId: string = { ...reorder.item.content, order: reorder.orderKey },
'space' in containerItem ? containerItem.roomId : containerItem.parentId; reorder.item.roomId
const itemContent = item.content; );
if (item.parentId !== containerParentId) {
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
}
if (
itemRoom &&
itemRoom.getJoinRule() === JoinRule.Restricted &&
item.parentId !== containerParentId
) {
// change join rule allow parameter when dragging
// restricted room from one space to another
const joinRuleContent = getStateEvent(
itemRoom,
StateEvent.RoomJoinRules
)?.getContent<RoomJoinRulesEventContent>();
if (joinRuleContent) {
const allow =
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? [];
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
...joinRuleContent,
allow,
}); });
} }
} },
[mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
const itemSpaces = Array.from( )
hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? []
);
const beforeItem: HierarchyItem | undefined =
'space' in containerItem ? undefined : containerItem;
const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId);
const insertIndex = beforeIndex + 1;
itemSpaces.splice(insertIndex, 0, {
...item,
parentId: containerParentId,
content: { ...itemContent, order: undefined },
});
const currentOrders = itemSpaces.map((i) => {
if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
return i.content.order;
}
return undefined;
});
const newOrders = orderKeys(lex, currentOrders);
newOrders?.forEach((orderKey, index) => {
const itm = itemSpaces[index];
if (itm && orderKey !== currentOrders[index]) {
mx.sendStateEvent(
containerParentId,
StateEvent.SpaceChild as any,
{ ...itm.content, order: orderKey },
itm.roomId
);
}
});
},
[mx, hierarchy, lex]
); );
const reorderingSpace = reorderSpaceState.status === AsyncStatus.Loading;
const [reorderRoomState, reorderRoom] = useAsyncCallback(
useCallback(
async (item: HierarchyItem, containerItem: HierarchyItem) => {
const itemRoom = mx.getRoom(item.roomId);
if (!item.parentId) {
return;
}
const containerParentId: string =
'space' in containerItem ? containerItem.roomId : containerItem.parentId;
const itemContent = item.content;
// remove from current space
if (item.parentId !== containerParentId) {
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
}
if (
itemRoom &&
itemRoom.getJoinRule() === JoinRule.Restricted &&
item.parentId !== containerParentId
) {
// change join rule allow parameter when dragging
// restricted room from one space to another
const joinRuleContent = getStateEvent(
itemRoom,
StateEvent.RoomJoinRules
)?.getContent<RoomJoinRulesEventContent>();
if (joinRuleContent) {
const allow =
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ??
[];
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
...joinRuleContent,
allow,
});
}
}
const itemSpaces = Array.from(
hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? []
);
const beforeItem: HierarchyItem | undefined =
'space' in containerItem ? undefined : containerItem;
const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId);
const insertIndex = beforeIndex + 1;
itemSpaces.splice(insertIndex, 0, {
...item,
parentId: containerParentId,
content: { ...itemContent, order: undefined },
});
const currentOrders = itemSpaces.map((i) => {
if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
return i.content.order;
}
return undefined;
});
const newOrders = orderKeys(lex, currentOrders);
const reorders = newOrders
?.map((orderKey, index) => ({
item: itemSpaces[index],
orderKey,
}))
.filter((reorder, index) => reorder.item && reorder.orderKey !== currentOrders[index]);
if (reorders) {
await rateLimitedActions(reorders, async (reorder) => {
await mx.sendStateEvent(
containerParentId,
StateEvent.SpaceChild as any,
{ ...reorder.item.content, order: reorder.orderKey },
reorder.item.roomId
);
});
}
},
[mx, hierarchy, lex]
)
);
const reorderingRoom = reorderRoomState.status === AsyncStatus.Loading;
const reordering = reorderingRoom || reorderingSpace;
useDnDMonitor( useDnDMonitor(
scrollRef, scrollRef,
@@ -449,6 +505,7 @@ export function Lobby() {
draggingItem={draggingItem} draggingItem={draggingItem}
onDragging={setDraggingItem} onDragging={setDraggingItem}
canDrop={canDrop} canDrop={canDrop}
disabledReorder={reordering}
nextSpaceId={nextSpaceId} nextSpaceId={nextSpaceId}
getRoom={getRoom} getRoom={getRoom}
pinned={sidebarSpaces.has(item.space.roomId)} pinned={sidebarSpaces.has(item.space.roomId)}
@@ -460,6 +517,28 @@ export function Lobby() {
); );
})} })}
</div> </div>
{reordering && (
<Box
style={{
position: 'absolute',
bottom: config.space.S400,
left: 0,
right: 0,
zIndex: 2,
pointerEvents: 'none',
}}
justifyContent="Center"
>
<Chip
variant="Secondary"
outlined
radii="Pill"
before={<Spinner variant="Secondary" fill="Soft" size="100" />}
>
<Text size="L400">Reordering</Text>
</Chip>
</Box>
)}
</PageContentCenter> </PageContentCenter>
</PageContent> </PageContent>
</Scroll> </Scroll>

View File

@@ -31,6 +31,7 @@ type SpaceHierarchyProps = {
draggingItem?: HierarchyItem; draggingItem?: HierarchyItem;
onDragging: (item?: HierarchyItem) => void; onDragging: (item?: HierarchyItem) => void;
canDrop: CanDropCallback; canDrop: CanDropCallback;
disabledReorder?: boolean;
nextSpaceId?: string; nextSpaceId?: string;
getRoom: (roomId: string) => Room | undefined; getRoom: (roomId: string) => Room | undefined;
pinned: boolean; pinned: boolean;
@@ -54,6 +55,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
draggingItem, draggingItem,
onDragging, onDragging,
canDrop, canDrop,
disabledReorder,
nextSpaceId, nextSpaceId,
getRoom, getRoom,
pinned, pinned,
@@ -116,7 +118,9 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
handleClose={handleClose} handleClose={handleClose}
getRoom={getRoom} getRoom={getRoom}
canEditChild={canEditSpaceChild(spacePowerLevels)} canEditChild={canEditSpaceChild(spacePowerLevels)}
canReorder={parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false} canReorder={
parentPowerLevels && !disabledReorder ? canEditSpaceChild(parentPowerLevels) : false
}
options={ options={
parentId && parentId &&
parentPowerLevels && ( parentPowerLevels && (
@@ -174,7 +178,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
dm={mDirects.has(roomItem.roomId)} dm={mDirects.has(roomItem.roomId)}
onOpen={onOpenRoom} onOpen={onOpenRoom}
getRoom={getRoom} getRoom={getRoom}
canReorder={canEditSpaceChild(spacePowerLevels)} canReorder={canEditSpaceChild(spacePowerLevels) && !disabledReorder}
options={ options={
<HierarchyItemMenu <HierarchyItemMenu
item={roomItem} item={roomItem}

View File

@@ -5,7 +5,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { SearchOrderBy } from 'matrix-js-sdk'; import { SearchOrderBy } from 'matrix-js-sdk';
import { PageHero, PageHeroSection } from '../../components/page'; import { PageHero, PageHeroEmpty, PageHeroSection } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { _SearchPathSearchParams } from '../../pages/paths'; import { _SearchPathSearchParams } from '../../pages/paths';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
@@ -57,6 +57,9 @@ export function MessageSearch({
const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const scrollTopAnchorRef = useRef<HTMLDivElement>(null); const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@@ -222,18 +225,7 @@ export function MessageSearch({
</Box> </Box>
{!msgSearchParams.term && status === 'pending' && ( {!msgSearchParams.term && status === 'pending' && (
<Box <PageHeroEmpty>
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
padding: config.space.S400,
borderRadius: config.radii.R400,
minHeight: toRem(450),
}}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="200"
>
<PageHeroSection> <PageHeroSection>
<PageHero <PageHero
icon={<Icon size="600" src={Icons.Message} />} icon={<Icon size="600" src={Icons.Message} />}
@@ -241,7 +233,7 @@ export function MessageSearch({
subTitle="Find helpful messages in your community by searching with related keywords." subTitle="Find helpful messages in your community by searching with related keywords."
/> />
</PageHeroSection> </PageHeroSection>
</Box> </PageHeroEmpty>
)} )}
{msgSearchParams.term && groups.length === 0 && status === 'success' && ( {msgSearchParams.term && groups.length === 0 && status === 'success' && (
@@ -300,6 +292,8 @@ export function MessageSearch({
urlPreview={urlPreview} urlPreview={urlPreview}
onOpen={navigateRoom} onOpen={navigateRoom}
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)} legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/> />
</VirtualTile> </VirtualTile>
); );

View File

@@ -29,6 +29,7 @@ export function SearchInput({ active, loading, searchInputRef, onSearch, onReset
ref={searchInputRef} ref={searchInputRef}
style={{ paddingRight: config.space.S300 }} style={{ paddingRight: config.space.S300 }}
name="searchInput" name="searchInput"
autoFocus
size="500" size="500"
variant="Background" variant="Background"
placeholder="Search for keyword" placeholder="Search for keyword"

View File

@@ -57,6 +57,8 @@ type SearchResultGroupProps = {
urlPreview?: boolean; urlPreview?: boolean;
onOpen: (roomId: string, eventId: string) => void; onOpen: (roomId: string, eventId: string) => void;
legacyUsernameColor?: boolean; legacyUsernameColor?: boolean;
hour24Clock: boolean;
dateFormatString: string;
}; };
export function SearchResultGroup({ export function SearchResultGroup({
room, room,
@@ -66,6 +68,8 @@ export function SearchResultGroup({
urlPreview, urlPreview,
onOpen, onOpen,
legacyUsernameColor, legacyUsernameColor,
hour24Clock,
dateFormatString,
}: SearchResultGroupProps) { }: SearchResultGroupProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
@@ -275,7 +279,11 @@ export function SearchResultGroup({
</Username> </Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />} {tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box> </Box>
<Time ts={event.origin_server_ts} /> <Time
ts={event.origin_server_ts}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</Box> </Box>
<Box shrink="No" gap="200" alignItems="Center"> <Box shrink="No" gap="200" alignItems="Center">
<Chip <Chip

View File

@@ -1,6 +1,6 @@
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react'; import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
import { Editor } from 'slate'; import { Editor } from 'slate';
import { Box, MenuItem, Text } from 'folds'; import { Box, config, MenuItem, Text } from 'folds';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { Command, useCommands } from '../../hooks/useCommands'; import { Command, useCommands } from '../../hooks/useCommands';
import { import {
@@ -75,9 +75,6 @@ export function CommandAutocomplete({
headerContent={ headerContent={
<Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween"> <Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
<Text size="L400">Commands</Text> <Text size="L400">Commands</Text>
<Text size="T200" priority="300" truncate>
Begin your message with command
</Text>
</Box> </Box>
} }
requestClose={requestClose} requestClose={requestClose}
@@ -87,17 +84,22 @@ export function CommandAutocomplete({
key={commandName} key={commandName}
as="button" as="button"
radii="300" radii="300"
style={{ height: 'unset' }}
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) => onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, () => handleAutocomplete(commandName)) onTabPress(evt, () => handleAutocomplete(commandName))
} }
onClick={() => handleAutocomplete(commandName)} onClick={() => handleAutocomplete(commandName)}
> >
<Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween"> <Box
<Box shrink="No"> style={{ padding: `${config.space.S300} 0` }}
<Text style={{ flexGrow: 1 }} size="B400" truncate> grow="Yes"
{`/${commandName}`} direction="Column"
</Text> gap="100"
</Box> justifyContent="SpaceBetween"
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
{`/${commandName}`}
</Text>
<Text truncate priority="300" size="T200"> <Text truncate priority="300" size="T200">
{commands[commandName].description} {commands[commandName].description}
</Text> </Text>

View File

@@ -543,7 +543,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
> >
<Icon src={Icons.Cross} size="50" /> <Icon src={Icons.Cross} size="50" />
</IconButton> </IconButton>
<Box direction="Column"> <Box direction="Row" gap="200" alignItems="Center">
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />} {replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
<ReplyLayout <ReplyLayout
userColor={replyUsernameColor} userColor={replyUsernameColor}

View File

@@ -448,6 +448,10 @@ export function RoomTimeline({
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const ignoredUsersList = useIgnoredUsers(); const ignoredUsersList = useIgnoredUsers();
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]); const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
@@ -932,7 +936,7 @@ export function RoomTimeline({
); );
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback( const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => { (evt, startThread = false) => {
const replyId = evt.currentTarget.getAttribute('data-event-id'); const replyId = evt.currentTarget.getAttribute('data-event-id');
if (!replyId) { if (!replyId) {
console.warn('Button should have "data-event-id" attribute!'); console.warn('Button should have "data-event-id" attribute!');
@@ -943,7 +947,9 @@ export function RoomTimeline({
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
const { body, formatted_body: formattedBody } = content; const { body, formatted_body: formattedBody } = content;
const { 'm.relates_to': relation } = replyEvt.getWireContent(); const { 'm.relates_to': relation } = startThread
? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
: replyEvt.getWireContent();
const senderId = replyEvt.getSender(); const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') { if (senderId && typeof body === 'string') {
setReplyDraft({ setReplyDraft({
@@ -1065,9 +1071,12 @@ export function RoomTimeline({
) )
} }
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
powerLevelTag={getPowerLevelTag(senderPowerLevel)} powerLevelTag={getPowerLevelTag(senderPowerLevel)}
accessibleTagColors={accessibleTagColors} accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor || direct} legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
> >
{mEvent.isRedacted() ? ( {mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} /> <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@@ -1146,9 +1155,12 @@ export function RoomTimeline({
) )
} }
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
powerLevelTag={getPowerLevelTag(senderPowerLevel)} powerLevelTag={getPowerLevelTag(senderPowerLevel)}
accessibleTagColors={accessibleTagColors} accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor || direct} legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
> >
<EncryptedContent mEvent={mEvent}> <EncryptedContent mEvent={mEvent}>
{() => { {() => {
@@ -1247,9 +1259,12 @@ export function RoomTimeline({
) )
} }
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
powerLevelTag={getPowerLevelTag(senderPowerLevel)} powerLevelTag={getPowerLevelTag(senderPowerLevel)}
accessibleTagColors={accessibleTagColors} accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor || direct} legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
> >
{mEvent.isRedacted() ? ( {mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} /> <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@@ -1278,7 +1293,12 @@ export function RoomTimeline({
const parsed = parseMemberEvent(mEvent); const parsed = parseMemberEvent(mEvent);
const timeJSX = ( const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} /> <Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
); );
return ( return (
@@ -1292,6 +1312,7 @@ export function RoomTimeline({
messageSpacing={messageSpacing} messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
> >
<EventContent <EventContent
messageLayout={messageLayout} messageLayout={messageLayout}
@@ -1314,7 +1335,12 @@ export function RoomTimeline({
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = ( const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} /> <Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
); );
return ( return (
@@ -1328,6 +1354,7 @@ export function RoomTimeline({
messageSpacing={messageSpacing} messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
> >
<EventContent <EventContent
messageLayout={messageLayout} messageLayout={messageLayout}
@@ -1351,7 +1378,12 @@ export function RoomTimeline({
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = ( const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} /> <Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
); );
return ( return (
@@ -1365,6 +1397,7 @@ export function RoomTimeline({
messageSpacing={messageSpacing} messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
> >
<EventContent <EventContent
messageLayout={messageLayout} messageLayout={messageLayout}
@@ -1388,7 +1421,12 @@ export function RoomTimeline({
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = ( const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} /> <Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
); );
return ( return (
@@ -1402,6 +1440,7 @@ export function RoomTimeline({
messageSpacing={messageSpacing} messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
> >
<EventContent <EventContent
messageLayout={messageLayout} messageLayout={messageLayout}
@@ -1427,7 +1466,12 @@ export function RoomTimeline({
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = ( const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} /> <Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
); );
return ( return (
@@ -1441,6 +1485,7 @@ export function RoomTimeline({
messageSpacing={messageSpacing} messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
> >
<EventContent <EventContent
messageLayout={messageLayout} messageLayout={messageLayout}
@@ -1471,7 +1516,12 @@ export function RoomTimeline({
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = ( const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} /> <Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
); );
return ( return (
@@ -1485,6 +1535,7 @@ export function RoomTimeline({
messageSpacing={messageSpacing} messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
> >
<EventContent <EventContent
messageLayout={messageLayout} messageLayout={messageLayout}

View File

@@ -65,6 +65,8 @@ import {
getRoomNotificationModeIcon, getRoomNotificationModeIcon,
useRoomsNotificationPreferencesContext, useRoomsNotificationPreferencesContext,
} from '../../hooks/useRoomsNotificationPreferences'; } from '../../hooks/useRoomsNotificationPreferences';
import { JumpToTime } from './jump-to-time';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
type RoomMenuProps = { type RoomMenuProps = {
room: Room; room: Room;
@@ -79,6 +81,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationPreferences = useRoomsNotificationPreferencesContext();
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
const { navigateRoom } = useRoomNavigate();
const handleMarkAsRead = () => { const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity); markAsRead(mx, room.roomId, hideActivity);
@@ -175,6 +178,33 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
Room Settings Room Settings
</Text> </Text>
</MenuItem> </MenuItem>
<UseStateProvider initial={false}>
{(promptJump, setPromptJump) => (
<>
<MenuItem
onClick={() => setPromptJump(true)}
size="300"
after={<Icon size="100" src={Icons.RecentClock} />}
radii="300"
aria-pressed={promptJump}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Jump to Time
</Text>
</MenuItem>
{promptJump && (
<JumpToTime
onSubmit={(eventId) => {
setPromptJump(false);
navigateRoom(room.roomId, eventId);
requestClose();
}}
onCancel={() => setPromptJump(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box> </Box>
<Line variant="Surface" size="300" /> <Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>

View File

@@ -0,0 +1,260 @@
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Dialog,
Overlay,
OverlayCenter,
OverlayBackdrop,
Header,
config,
Box,
Text,
IconButton,
Icon,
Icons,
color,
Button,
Spinner,
Chip,
PopOut,
RectCords,
} from 'folds';
import { Direction, MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { stopPropagation } from '../../../utils/keyboard';
import { useAlive } from '../../../hooks/useAlive';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../../utils/time';
import { DatePicker, TimePicker } from '../../../components/time-date';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
type JumpToTimeProps = {
onCancel: () => void;
onSubmit: (eventId: string) => void;
};
export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
const mx = useMatrixClient();
const room = useRoom();
const alive = useAlive();
const createStateEvent = useStateEvent(room, StateEvent.RoomCreate);
const todayTs = getToday();
const yesterdayTs = getYesterday();
const createTs = useMemo(() => createStateEvent?.getTs() ?? 0, [createStateEvent]);
const [ts, setTs] = useState(() => Date.now());
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [timePickerCords, setTimePickerCords] = useState<RectCords>();
const [datePickerCords, setDatePickerCords] = useState<RectCords>();
const handleTimePicker: MouseEventHandler<HTMLButtonElement> = (evt) => {
setTimePickerCords(evt.currentTarget.getBoundingClientRect());
};
const handleDatePicker: MouseEventHandler<HTMLButtonElement> = (evt) => {
setDatePickerCords(evt.currentTarget.getBoundingClientRect());
};
const handleToday = () => {
setTs(todayTs < createTs ? createTs : todayTs);
};
const handleYesterday = () => {
setTs(yesterdayTs < createTs ? createTs : yesterdayTs);
};
const handleBeginning = () => setTs(createTs);
const [timestampState, timestampToEvent] = useAsyncCallback<string, MatrixError, [number]>(
useCallback(
async (newTs) => {
const result = await mx.timestampToEvent(room.roomId, newTs, Direction.Forward);
return result.event_id;
},
[mx, room]
)
);
const handleSubmit = () => {
timestampToEvent(ts).then((eventId) => {
if (alive()) {
onSubmit(eventId);
}
});
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onCancel,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Jump to Time</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="500">
<Box direction="Row" gap="300">
<Box direction="Column" gap="100">
<Text size="L400" priority="400">
Time
</Text>
<Box gap="100" alignItems="Center">
<Chip
size="500"
variant="Surface"
fill="None"
outlined
radii="300"
aria-pressed={!!timePickerCords}
after={<Icon size="50" src={Icons.ChevronBottom} />}
onClick={handleTimePicker}
>
<Text size="B300">{timeHourMinute(ts, hour24Clock)}</Text>
</Chip>
<PopOut
anchor={timePickerCords}
offset={5}
position="Bottom"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setTimePickerCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<TimePicker min={createTs} max={Date.now()} value={ts} onChange={setTs} />
</FocusTrap>
}
/>
</Box>
</Box>
<Box direction="Column" gap="100">
<Text size="L400" priority="400">
Date
</Text>
<Box gap="100" alignItems="Center">
<Chip
size="500"
variant="Surface"
fill="None"
outlined
radii="300"
aria-pressed={!!datePickerCords}
after={<Icon size="50" src={Icons.ChevronBottom} />}
onClick={handleDatePicker}
>
<Text size="B300">{timeDayMonthYear(ts)}</Text>
</Chip>
<PopOut
anchor={datePickerCords}
offset={5}
position="Bottom"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setDatePickerCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<DatePicker min={createTs} max={Date.now()} value={ts} onChange={setTs} />
</FocusTrap>
}
/>
</Box>
</Box>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Preset</Text>
<Box gap="200">
{createTs < todayTs && (
<Chip
variant={ts === todayTs ? 'Success' : 'SurfaceVariant'}
radii="Pill"
aria-pressed={ts === todayTs}
onClick={handleToday}
>
<Text size="B300">Today</Text>
</Chip>
)}
{createTs < yesterdayTs && (
<Chip
variant={ts === yesterdayTs ? 'Success' : 'SurfaceVariant'}
radii="Pill"
aria-pressed={ts === yesterdayTs}
onClick={handleYesterday}
>
<Text size="B300">Yesterday</Text>
</Chip>
)}
<Chip
variant={ts === createTs ? 'Success' : 'SurfaceVariant'}
radii="Pill"
aria-pressed={ts === createTs}
onClick={handleBeginning}
>
<Text size="B300">Beginning</Text>
</Chip>
</Box>
</Box>
{timestampState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
{timestampState.error.message}
</Text>
)}
<Button
type="submit"
variant="Primary"
before={
timestampState.status === AsyncStatus.Loading ? (
<Spinner fill="Solid" variant="Primary" size="200" />
) : undefined
}
aria-disabled={
timestampState.status === AsyncStatus.Loading ||
timestampState.status === AsyncStatus.Success
}
onClick={handleSubmit}
>
<Text size="B400">Open Timeline</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View File

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

View File

@@ -669,15 +669,21 @@ export type MessageProps = {
messageSpacing: MessageSpacing; messageSpacing: MessageSpacing;
onUserClick: MouseEventHandler<HTMLButtonElement>; onUserClick: MouseEventHandler<HTMLButtonElement>;
onUsernameClick: MouseEventHandler<HTMLButtonElement>; onUsernameClick: MouseEventHandler<HTMLButtonElement>;
onReplyClick: MouseEventHandler<HTMLButtonElement>; onReplyClick: (
ev: Parameters<MouseEventHandler<HTMLButtonElement>>[0],
startThread?: boolean
) => void;
onEditId?: (eventId?: string) => void; onEditId?: (eventId?: string) => void;
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
reply?: ReactNode; reply?: ReactNode;
reactions?: ReactNode; reactions?: ReactNode;
hideReadReceipts?: boolean; hideReadReceipts?: boolean;
showDeveloperTools?: boolean;
powerLevelTag?: PowerLevelTag; powerLevelTag?: PowerLevelTag;
accessibleTagColors?: Map<string, string>; accessibleTagColors?: Map<string, string>;
legacyUsernameColor?: boolean; legacyUsernameColor?: boolean;
hour24Clock: boolean;
dateFormatString: string;
}; };
export const Message = as<'div', MessageProps>( export const Message = as<'div', MessageProps>(
( (
@@ -703,9 +709,12 @@ export const Message = as<'div', MessageProps>(
reply, reply,
reactions, reactions,
hideReadReceipts, hideReadReceipts,
showDeveloperTools,
powerLevelTag, powerLevelTag,
accessibleTagColors, accessibleTagColors,
legacyUsernameColor, legacyUsernameColor,
hour24Clock,
dateFormatString,
children, children,
...props ...props
}, },
@@ -770,7 +779,12 @@ export const Message = as<'div', MessageProps>(
</Text> </Text>
</> </>
)} )}
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} /> <Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</Box> </Box>
</Box> </Box>
); );
@@ -857,6 +871,8 @@ export const Message = as<'div', MessageProps>(
}, 100); }, 100);
}; };
const isThreadedMessage = mEvent.threadRootId !== undefined;
return ( return (
<MessageBase <MessageBase
className={classNames(css.MessageBase, className)} className={classNames(css.MessageBase, className)}
@@ -919,6 +935,17 @@ export const Message = as<'div', MessageProps>(
> >
<Icon src={Icons.ReplyArrow} size="100" /> <Icon src={Icons.ReplyArrow} size="100" />
</IconButton> </IconButton>
{!isThreadedMessage && (
<IconButton
onClick={(ev) => onReplyClick(ev, true)}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.ThreadPlus} size="100" />
</IconButton>
)}
{canEditEvent(mx, mEvent) && onEditId && ( {canEditEvent(mx, mEvent) && onEditId && (
<IconButton <IconButton
onClick={() => onEditId(mEvent.getId())} onClick={() => onEditId(mEvent.getId())}
@@ -998,6 +1025,27 @@ export const Message = as<'div', MessageProps>(
Reply Reply
</Text> </Text>
</MenuItem> </MenuItem>
{!isThreadedMessage && (
<MenuItem
size="300"
after={<Icon src={Icons.ThreadPlus} size="100" />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: any) => {
onReplyClick(evt, true);
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
Reply in Thread
</Text>
</MenuItem>
)}
{canEditEvent(mx, mEvent) && onEditId && ( {canEditEvent(mx, mEvent) && onEditId && (
<MenuItem <MenuItem
size="300" size="300"
@@ -1026,7 +1074,13 @@ export const Message = as<'div', MessageProps>(
onClose={closeMenu} onClose={closeMenu}
/> />
)} )}
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} /> {showDeveloperTools && (
<MessageSourceCodeItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} /> <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && ( {canPinEvent && (
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} /> <MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
@@ -1101,6 +1155,7 @@ export type EventProps = {
canDelete?: boolean; canDelete?: boolean;
messageSpacing: MessageSpacing; messageSpacing: MessageSpacing;
hideReadReceipts?: boolean; hideReadReceipts?: boolean;
showDeveloperTools?: boolean;
}; };
export const Event = as<'div', EventProps>( export const Event = as<'div', EventProps>(
( (
@@ -1112,6 +1167,7 @@ export const Event = as<'div', EventProps>(
canDelete, canDelete,
messageSpacing, messageSpacing,
hideReadReceipts, hideReadReceipts,
showDeveloperTools,
children, children,
...props ...props
}, },
@@ -1188,7 +1244,13 @@ export const Event = as<'div', EventProps>(
onClose={closeMenu} onClose={closeMenu}
/> />
)} )}
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} /> {showDeveloperTools && (
<MessageSourceCodeItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} /> <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
</Box> </Box>
{((!mEvent.isRedacted() && canDelete && !stateEvent) || {((!mEvent.isRedacted() && canDelete && !stateEvent) ||

View File

@@ -102,6 +102,9 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
const theme = useTheme(); const theme = useTheme();
const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags); const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const [unpinState, unpin] = useAsyncCallback( const [unpinState, unpin] = useAsyncCallback(
useCallback(() => { useCallback(() => {
const pinEvent = getStateEvent(room, StateEvent.RoomPinnedEvents); const pinEvent = getStateEvent(room, StateEvent.RoomPinnedEvents);
@@ -205,7 +208,11 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
</Username> </Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />} {tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box> </Box>
<Time ts={pinnedEvent.getTs()} /> <Time
ts={pinnedEvent.getTs()}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</Box> </Box>
{renderOptions()} {renderOptions()}
</Box> </Box>

View File

@@ -71,7 +71,7 @@ const useSettingsMenuItems = (): SettingsMenuItem[] =>
{ {
page: SettingsPages.DevicesPage, page: SettingsPages.DevicesPage,
name: 'Devices', name: 'Devices',
icon: Icons.Category, icon: Icons.Monitor,
}, },
{ {
page: SettingsPages.EmojisStickersPage, page: SettingsPages.EmojisStickersPage,

View File

@@ -1,396 +1,10 @@
import React, { import React from 'react';
ChangeEventHandler, import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
FormEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import {
Box,
Text,
IconButton,
Icon,
Icons,
Scroll,
Input,
Avatar,
Button,
Chip,
Overlay,
OverlayBackdrop,
OverlayCenter,
Modal,
Dialog,
Header,
config,
Spinner,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page'; import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card'; import { MatrixId } from './MatrixId';
import { SequenceCardStyle } from '../styles.css'; import { Profile } from './Profile';
import { SettingTile } from '../../../components/setting-tile'; import { ContactInformation } from './ContactInfo';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { IgnoredUserList } from './IgnoredUserList';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { UserAvatar } from '../../../components/user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { nameInitials } from '../../../utils/common';
import { copyToClipboard } from '../../../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useObjectURL } from '../../../hooks/useObjectURL';
import { stopPropagation } from '../../../utils/keyboard';
import { ImageEditor } from '../../../components/image-editor';
import { ModalWide } from '../../../styles/Modal.css';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
function MatrixId() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
return (
<Box direction="Column" gap="100">
<Text size="L400">Matrix ID</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={userId}
after={
<Chip variant="Secondary" radii="Pill" onClick={() => copyToClipboard(userId)}>
<Text size="T200">Copy</Text>
</Chip>
}
/>
</SequenceCard>
</Box>
);
}
type ProfileProps = {
profile: UserProfile;
userId: string;
};
function ProfileAvatar({ profile, userId }: ProfileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const capabilities = useCapabilities();
const [alertRemove, setAlertRemove] = useState(false);
const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const [imageFile, setImageFile] = useState<File>();
const imageFileURL = useObjectURL(imageFile);
const uploadAtom = useMemo(() => {
if (imageFile) return createUploadAtom(imageFile);
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
}, []);
const handleUploaded = useCallback(
(upload: UploadSuccess) => {
const { mxc } = upload;
mx.setAvatarUrl(mxc);
handleRemoveUpload();
},
[mx, handleRemoveUpload]
);
const handleRemoveAvatar = () => {
mx.setAvatarUrl('');
setAlertRemove(false);
};
return (
<SettingTile
title={
<Text as="span" size="L400">
Avatar
</Text>
}
after={
<Avatar size="500" radii="300">
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
/>
</Avatar>
}
>
{uploadAtom ? (
<Box gap="200" direction="Column">
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded}
/>
</Box>
) : (
<Box gap="200">
<Button
onClick={() => pickFile('image/*')}
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={disableSetAvatar}
>
<Text size="B300">Upload</Text>
</Button>
{avatarUrl && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={disableSetAvatar}
onClick={() => setAlertRemove(true)}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
)}
{imageFileURL && (
<Overlay open={false} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleRemoveUpload,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal className={ModalWide} variant="Surface" size="500">
<ImageEditor
name={imageFile?.name ?? 'Unnamed'}
url={imageFileURL}
requestClose={handleRemoveUpload}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
<Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setAlertRemove(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Remove Avatar</Text>
</Box>
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text priority="400">Are you sure you want to remove profile avatar?</Text>
</Box>
<Button variant="Critical" onClick={handleRemoveAvatar}>
<Text size="B400">Remove</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
</SettingTile>
);
}
function ProfileDisplayName({ profile, userId }: ProfileProps) {
const mx = useMatrixClient();
const capabilities = useCapabilities();
const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
const [changeState, changeDisplayName] = useAsyncCallback(
useCallback((name: string) => mx.setDisplayName(name), [mx])
);
const changingDisplayName = changeState.status === AsyncStatus.Loading;
useEffect(() => {
setDisplayName(defaultDisplayName);
}, [defaultDisplayName]);
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const name = evt.currentTarget.value;
setDisplayName(name);
};
const handleReset = () => {
setDisplayName(defaultDisplayName);
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (changingDisplayName) return;
const target = evt.target as HTMLFormElement | undefined;
const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
const name = displayNameInput?.value;
if (!name) return;
changeDisplayName(name);
};
const hasChanges = displayName !== defaultDisplayName;
return (
<SettingTile
title={
<Text as="span" size="L400">
Display Name
</Text>
}
>
<Box direction="Column" grow="Yes" gap="100">
<Box
as="form"
onSubmit={handleSubmit}
gap="200"
aria-disabled={changingDisplayName || disableSetDisplayname}
>
<Box grow="Yes" direction="Column">
<Input
required
name="displayNameInput"
value={displayName}
onChange={handleChange}
variant="Secondary"
radii="300"
style={{ paddingRight: config.space.S200 }}
readOnly={changingDisplayName || disableSetDisplayname}
after={
hasChanges &&
!changingDisplayName && (
<IconButton
type="reset"
onClick={handleReset}
size="300"
radii="300"
variant="Secondary"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)
}
/>
</Box>
<Button
size="400"
variant={hasChanges ? 'Success' : 'Secondary'}
fill={hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges || changingDisplayName}
type="submit"
>
{changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
<Text size="B400">Save</Text>
</Button>
</Box>
</Box>
</SettingTile>
);
}
function Profile() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const profile = useUserProfile(userId);
return (
<Box direction="Column" gap="100">
<Text size="L400">Profile</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<ProfileAvatar userId={userId} profile={profile} />
<ProfileDisplayName userId={userId} profile={profile} />
</SequenceCard>
</Box>
);
}
function ContactInformation() {
const mx = useMatrixClient();
const [threePIdsState, loadThreePIds] = useAsyncCallback(
useCallback(() => mx.getThreePids(), [mx])
);
const threePIds =
threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined;
const emailIds = threePIds?.filter((id) => id.medium === 'email');
useEffect(() => {
loadThreePIds();
}, [loadThreePIds]);
return (
<Box direction="Column" gap="100">
<Text size="L400">Contact Information</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile title="Email Address" description="Email address attached to your account.">
<Box>
{emailIds?.map((email) => (
<Chip key={email.address} as="span" variant="Secondary" radii="Pill">
<Text size="T200">{email.address}</Text>
</Chip>
))}
</Box>
{/* <Input defaultValue="" variant="Secondary" radii="300" /> */}
</SettingTile>
</SequenceCard>
</Box>
);
}
type AccountProps = { type AccountProps = {
requestClose: () => void; requestClose: () => void;
@@ -419,6 +33,7 @@ export function Account({ requestClose }: AccountProps) {
<Profile /> <Profile />
<MatrixId /> <MatrixId />
<ContactInformation /> <ContactInformation />
<IgnoredUserList />
</Box> </Box>
</PageContent> </PageContent>
</Scroll> </Scroll>

View File

@@ -0,0 +1,45 @@
import React, { useCallback, useEffect } from 'react';
import { Box, Text, Chip } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
export function ContactInformation() {
const mx = useMatrixClient();
const [threePIdsState, loadThreePIds] = useAsyncCallback(
useCallback(() => mx.getThreePids(), [mx])
);
const threePIds =
threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined;
const emailIds = threePIds?.filter((id) => id.medium === 'email');
useEffect(() => {
loadThreePIds();
}, [loadThreePIds]);
return (
<Box direction="Column" gap="100">
<Text size="L400">Contact Information</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile title="Email Address" description="Email address attached to your account.">
<Box>
{emailIds?.map((email) => (
<Chip key={email.address} as="span" variant="Secondary" radii="Pill">
<Text size="T200">{email.address}</Text>
</Chip>
))}
</Box>
{/* <Input defaultValue="" variant="Secondary" radii="300" /> */}
</SettingTile>
</SequenceCard>
</Box>
);
}

View File

@@ -7,16 +7,17 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { isUserId } from '../../../utils/matrix'; import { isUserId } from '../../../utils/matrix';
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
import { useAlive } from '../../../hooks/useAlive';
function IgnoreUserInput({ userList }: { userList: string[] }) { function IgnoreUserInput({ userList }: { userList: string[] }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [userId, setUserId] = useState<string>(''); const [userId, setUserId] = useState<string>('');
const alive = useAlive();
const [ignoreState, ignore] = useAsyncCallback( const [ignoreState, ignore] = useAsyncCallback(
useCallback( useCallback(
async (uId: string) => { async (uId: string) => {
mx.setIgnoredUsers([...userList, uId]); await mx.setIgnoredUsers([...userList, uId]);
setUserId('');
}, },
[mx, userList] [mx, userList]
) )
@@ -43,7 +44,11 @@ function IgnoreUserInput({ userList }: { userList: string[] }) {
if (!isUserId(uId)) return; if (!isUserId(uId)) return;
ignore(uId); ignore(uId).then(() => {
if (alive()) {
setUserId('');
}
});
}; };
return ( return (
@@ -129,7 +134,7 @@ export function IgnoredUserList() {
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200"> <Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Block Messages</Text> <Text size="L400">Blocked Users</Text>
</Box> </Box>
<SequenceCard <SequenceCard
className={SequenceCardStyle} className={SequenceCardStyle}
@@ -139,13 +144,13 @@ export function IgnoredUserList() {
> >
<SettingTile <SettingTile
title="Select User" title="Select User"
description="Prevent receiving message by adding userId into blocklist." description="Prevent receiving messages or invites from user by adding their userId."
> >
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
<IgnoreUserInput userList={ignoredUsers} /> <IgnoreUserInput userList={ignoredUsers} />
{ignoredUsers.length > 0 && ( {ignoredUsers.length > 0 && (
<Box direction="Inherit" gap="100"> <Box direction="Inherit" gap="100">
<Text size="L400">Blocklist</Text> <Text size="L400">Users</Text>
<Box wrap="Wrap" gap="200"> <Box wrap="Wrap" gap="200">
{ignoredUsers.map((userId) => ( {ignoredUsers.map((userId) => (
<IgnoredUserChip key={userId} userId={userId} userList={ignoredUsers} /> <IgnoredUserChip key={userId} userId={userId} userList={ignoredUsers} />

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Box, Text, Chip } from 'folds';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { copyToClipboard } from '../../../../util/common';
export function MatrixId() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
return (
<Box direction="Column" gap="100">
<Text size="L400">Matrix ID</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={userId}
after={
<Chip variant="Secondary" radii="Pill" onClick={() => copyToClipboard(userId)}>
<Text size="T200">Copy</Text>
</Chip>
}
/>
</SequenceCard>
</Box>
);
}

View File

@@ -0,0 +1,325 @@
import React, {
ChangeEventHandler,
FormEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import {
Box,
Text,
IconButton,
Icon,
Icons,
Input,
Avatar,
Button,
Overlay,
OverlayBackdrop,
OverlayCenter,
Modal,
Dialog,
Header,
config,
Spinner,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { UserAvatar } from '../../../components/user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { nameInitials } from '../../../utils/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useObjectURL } from '../../../hooks/useObjectURL';
import { stopPropagation } from '../../../utils/keyboard';
import { ImageEditor } from '../../../components/image-editor';
import { ModalWide } from '../../../styles/Modal.css';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
type ProfileProps = {
profile: UserProfile;
userId: string;
};
function ProfileAvatar({ profile, userId }: ProfileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const capabilities = useCapabilities();
const [alertRemove, setAlertRemove] = useState(false);
const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const [imageFile, setImageFile] = useState<File>();
const imageFileURL = useObjectURL(imageFile);
const uploadAtom = useMemo(() => {
if (imageFile) return createUploadAtom(imageFile);
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
}, []);
const handleUploaded = useCallback(
(upload: UploadSuccess) => {
const { mxc } = upload;
mx.setAvatarUrl(mxc);
handleRemoveUpload();
},
[mx, handleRemoveUpload]
);
const handleRemoveAvatar = () => {
mx.setAvatarUrl('');
setAlertRemove(false);
};
return (
<SettingTile
title={
<Text as="span" size="L400">
Avatar
</Text>
}
after={
<Avatar size="500" radii="300">
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
/>
</Avatar>
}
>
{uploadAtom ? (
<Box gap="200" direction="Column">
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleRemoveUpload}
onComplete={handleUploaded}
/>
</Box>
) : (
<Box gap="200">
<Button
onClick={() => pickFile('image/*')}
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={disableSetAvatar}
>
<Text size="B300">Upload</Text>
</Button>
{avatarUrl && (
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={disableSetAvatar}
onClick={() => setAlertRemove(true)}
>
<Text size="B300">Remove</Text>
</Button>
)}
</Box>
)}
{imageFileURL && (
<Overlay open={false} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleRemoveUpload,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal className={ModalWide} variant="Surface" size="500">
<ImageEditor
name={imageFile?.name ?? 'Unnamed'}
url={imageFileURL}
requestClose={handleRemoveUpload}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
<Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setAlertRemove(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Remove Avatar</Text>
</Box>
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text priority="400">Are you sure you want to remove profile avatar?</Text>
</Box>
<Button variant="Critical" onClick={handleRemoveAvatar}>
<Text size="B400">Remove</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
</SettingTile>
);
}
function ProfileDisplayName({ profile, userId }: ProfileProps) {
const mx = useMatrixClient();
const capabilities = useCapabilities();
const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
const [changeState, changeDisplayName] = useAsyncCallback(
useCallback((name: string) => mx.setDisplayName(name), [mx])
);
const changingDisplayName = changeState.status === AsyncStatus.Loading;
useEffect(() => {
setDisplayName(defaultDisplayName);
}, [defaultDisplayName]);
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const name = evt.currentTarget.value;
setDisplayName(name);
};
const handleReset = () => {
setDisplayName(defaultDisplayName);
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
if (changingDisplayName) return;
const target = evt.target as HTMLFormElement | undefined;
const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
const name = displayNameInput?.value;
if (!name) return;
changeDisplayName(name);
};
const hasChanges = displayName !== defaultDisplayName;
return (
<SettingTile
title={
<Text as="span" size="L400">
Display Name
</Text>
}
>
<Box direction="Column" grow="Yes" gap="100">
<Box
as="form"
onSubmit={handleSubmit}
gap="200"
aria-disabled={changingDisplayName || disableSetDisplayname}
>
<Box grow="Yes" direction="Column">
<Input
required
name="displayNameInput"
value={displayName}
onChange={handleChange}
variant="Secondary"
radii="300"
style={{ paddingRight: config.space.S200 }}
readOnly={changingDisplayName || disableSetDisplayname}
after={
hasChanges &&
!changingDisplayName && (
<IconButton
type="reset"
onClick={handleReset}
size="300"
radii="300"
variant="Secondary"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)
}
/>
</Box>
<Button
size="400"
variant={hasChanges ? 'Success' : 'Secondary'}
fill={hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges || changingDisplayName}
type="submit"
>
{changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
<Text size="B400">Save</Text>
</Button>
</Box>
</Box>
</SettingTile>
);
}
export function Profile() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const profile = useUserProfile(userId);
return (
<Box direction="Column" gap="100">
<Text size="L400">Profile</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<ProfileAvatar userId={userId} profile={profile} />
<ProfileDisplayName userId={userId} profile={profile} />
</SequenceCard>
</Box>
);
}

View File

@@ -27,6 +27,8 @@ import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { LogoutDialog } from '../../../components/LogoutDialog'; import { LogoutDialog } from '../../../components/LogoutDialog';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
export function DeviceTilePlaceholder() { export function DeviceTilePlaceholder() {
return ( return (
@@ -41,6 +43,9 @@ export function DeviceTilePlaceholder() {
} }
function DeviceActiveTime({ ts }: { ts: number }) { function DeviceActiveTime({ ts }: { ts: number }) {
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
return ( return (
<Text className={BreakWord} size="T200"> <Text className={BreakWord} size="T200">
<Text size="Inherit" as="span" priority="300"> <Text size="Inherit" as="span" priority="300">
@@ -49,7 +54,8 @@ function DeviceActiveTime({ ts }: { ts: number }) {
<> <>
{today(ts) && 'Today'} {today(ts) && 'Today'}
{yesterday(ts) && 'Yesterday'} {yesterday(ts) && 'Yesterday'}
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts)} {timeHourMinute(ts)} {!today(ts) && !yesterday(ts) && timeDayMonYear(ts, dateFormatString)}{' '}
{timeHourMinute(ts, hour24Clock)}
</> </>
</Text> </Text>
); );

View File

@@ -11,6 +11,10 @@ import { useUIAMatrixError } from '../../../hooks/useUIAFlows';
import { DeviceVerificationStatus } from '../../../components/DeviceVerificationStatus'; import { DeviceVerificationStatus } from '../../../components/DeviceVerificationStatus';
import { VerifyOtherDeviceTile } from './Verification'; import { VerifyOtherDeviceTile } from './Verification';
import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus'; import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus';
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
import { withSearchParam } from '../../../pages/pathUtils';
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
import { SettingTile } from '../../../components/setting-tile';
type OtherDevicesProps = { type OtherDevicesProps = {
devices: IMyDevice[]; devices: IMyDevice[];
@@ -20,8 +24,39 @@ type OtherDevicesProps = {
export function OtherDevices({ devices, refreshDeviceList, showVerification }: OtherDevicesProps) { export function OtherDevices({ devices, refreshDeviceList, showVerification }: OtherDevicesProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const crypto = mx.getCrypto(); const crypto = mx.getCrypto();
const authMetadata = useAuthMetadata();
const accountManagementActions = useAccountManagementActions();
const [deleted, setDeleted] = useState<Set<string>>(new Set()); const [deleted, setDeleted] = useState<Set<string>>(new Set());
const handleDashboardOIDC = useCallback(() => {
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
if (!authUrl) return;
window.open(
withSearchParam(authUrl, {
action: accountManagementActions.sessionsList,
}),
'_blank'
);
}, [authMetadata, accountManagementActions]);
const handleDeleteOIDC = useCallback(
(deviceId: string) => {
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
if (!authUrl) return;
window.open(
withSearchParam(authUrl, {
action: accountManagementActions.sessionEnd,
device_id: deviceId,
}),
'_blank'
);
},
[authMetadata, accountManagementActions]
);
const handleToggleDelete = useCallback((deviceId: string) => { const handleToggleDelete = useCallback((deviceId: string) => {
setDeleted((deviceIds) => { setDeleted((deviceIds) => {
const newIds = new Set(deviceIds); const newIds = new Set(deviceIds);
@@ -70,6 +105,31 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
<> <>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Others</Text> <Text size="L400">Others</Text>
{authMetadata && (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Device Dashboard"
description="Manage your devices on OIDC dashboard."
after={
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
onClick={handleDashboardOIDC}
>
<Text size="B300">Open</Text>
</Button>
}
/>
</SequenceCard>
)}
{devices {devices
.sort((d1, d2) => { .sort((d1, d2) => {
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0; if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
@@ -89,12 +149,20 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
refreshDeviceList={refreshDeviceList} refreshDeviceList={refreshDeviceList}
disabled={deleting} disabled={deleting}
options={ options={
<DeviceDeleteBtn authMetadata ? (
deviceId={device.device_id} <DeviceDeleteBtn
deleted={deleted.has(device.device_id)} deviceId={device.device_id}
onDeleteToggle={handleToggleDelete} deleted={false}
disabled={deleting} onDeleteToggle={handleDeleteOIDC}
/> />
) : (
<DeviceDeleteBtn
deviceId={device.device_id}
deleted={deleted.has(device.device_id)}
onDeleteToggle={handleToggleDelete}
disabled={deleting}
/>
)
} }
/> />
{showVerification && crypto && ( {showVerification && crypto && (

View File

@@ -32,6 +32,9 @@ import {
DeviceVerificationSetup, DeviceVerificationSetup,
} from '../../../components/DeviceVerificationSetup'; } from '../../../components/DeviceVerificationSetup';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
import { withSearchParam } from '../../../pages/pathUtils';
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
type VerificationStatusBadgeProps = { type VerificationStatusBadgeProps = {
verificationStatus: VerificationStatus; verificationStatus: VerificationStatus;
@@ -252,6 +255,8 @@ export function EnableVerification({ visible }: EnableVerificationProps) {
export function DeviceVerificationOptions() { export function DeviceVerificationOptions() {
const [menuCords, setMenuCords] = useState<RectCords>(); const [menuCords, setMenuCords] = useState<RectCords>();
const authMetadata = useAuthMetadata();
const accountManagementActions = useAccountManagementActions();
const [reset, setReset] = useState(false); const [reset, setReset] = useState(false);
@@ -265,6 +270,18 @@ export function DeviceVerificationOptions() {
const handleReset = () => { const handleReset = () => {
setMenuCords(undefined); setMenuCords(undefined);
if (authMetadata) {
const authUrl = authMetadata.account_management_uri ?? authMetadata.issuer;
window.open(
withSearchParam(authUrl, {
action: accountManagementActions.crossSigningReset,
}),
'_blank'
);
return;
}
setReset(true); setReset(true);
}; };

View File

@@ -1,15 +1,19 @@
import React, { import React, {
ChangeEventHandler, ChangeEventHandler,
FormEventHandler,
KeyboardEventHandler, KeyboardEventHandler,
MouseEventHandler, MouseEventHandler,
useEffect,
useState, useState,
} from 'react'; } from 'react';
import dayjs from 'dayjs';
import { import {
as, as,
Box, Box,
Button, Button,
Chip, Chip,
config, config,
Header,
Icon, Icon,
IconButton, IconButton,
Icons, Icons,
@@ -28,7 +32,7 @@ import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page'; import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings'; import { DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { KeySymbol } from '../../../utils/key-symbol'; import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent'; import { isMacOS } from '../../../utils/user-agent';
@@ -44,6 +48,7 @@ import {
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout'; import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing'; import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
type ThemeSelectorProps = { type ThemeSelectorProps = {
@@ -341,6 +346,359 @@ function Appearance() {
); );
} }
type DateHintProps = {
hasChanges: boolean;
handleReset: () => void;
};
function DateHint({ hasChanges, handleReset }: DateHintProps) {
const [anchor, setAnchor] = useState<RectCords>();
const categoryPadding = { padding: config.space.S200, paddingTop: 0 };
const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
setAnchor(evt.currentTarget.getBoundingClientRect());
};
return (
<PopOut
anchor={anchor}
position="Top"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxHeight: '85vh', overflowY: 'auto' }}>
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
<Text size="L400">Formatting</Text>
</Header>
<Box direction="Column">
<Box style={categoryPadding} direction="Column">
<Header size="300">
<Text size="L400">Year</Text>
</Header>
<Box direction="Column" tabIndex={0} gap="100">
<Text size="T300">
YY
<Text as="span" size="Inherit" priority="300">
{': '}
Two-digit year
</Text>{' '}
</Text>
<Text size="T300">
YYYY
<Text as="span" size="Inherit" priority="300">
{': '}Four-digit year
</Text>
</Text>
</Box>
</Box>
<Box style={categoryPadding} direction="Column">
<Header size="300">
<Text size="L400">Month</Text>
</Header>
<Box direction="Column" tabIndex={0} gap="100">
<Text size="T300">
M
<Text as="span" size="Inherit" priority="300">
{': '}The month
</Text>
</Text>
<Text size="T300">
MM
<Text as="span" size="Inherit" priority="300">
{': '}Two-digit month
</Text>{' '}
</Text>
<Text size="T300">
MMM
<Text as="span" size="Inherit" priority="300">
{': '}Short month name
</Text>
</Text>
<Text size="T300">
MMMM
<Text as="span" size="Inherit" priority="300">
{': '}Full month name
</Text>
</Text>
</Box>
</Box>
<Box style={categoryPadding} direction="Column">
<Header size="300">
<Text size="L400">Day of the Month</Text>
</Header>
<Box direction="Column" tabIndex={0} gap="100">
<Text size="T300">
D
<Text as="span" size="Inherit" priority="300">
{': '}Day of the month
</Text>
</Text>
<Text size="T300">
DD
<Text as="span" size="Inherit" priority="300">
{': '}Two-digit day of the month
</Text>
</Text>
</Box>
</Box>
<Box style={categoryPadding} direction="Column">
<Header size="300">
<Text size="L400">Day of the Week</Text>
</Header>
<Box direction="Column" tabIndex={0} gap="100">
<Text size="T300">
d
<Text as="span" size="Inherit" priority="300">
{': '}Day of the week (Sunday = 0)
</Text>
</Text>
<Text size="T300">
dd
<Text as="span" size="Inherit" priority="300">
{': '}Two-letter day name
</Text>
</Text>
<Text size="T300">
ddd
<Text as="span" size="Inherit" priority="300">
{': '}Short day name
</Text>
</Text>
<Text size="T300">
dddd
<Text as="span" size="Inherit" priority="300">
{': '}Full day name
</Text>
</Text>
</Box>
</Box>
</Box>
</Menu>
</FocusTrap>
}
>
{hasChanges ? (
<IconButton
tabIndex={-1}
onClick={handleReset}
type="reset"
variant="Secondary"
size="300"
radii="300"
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
) : (
<IconButton
tabIndex={-1}
onClick={handleOpenMenu}
type="button"
variant="Secondary"
size="300"
radii="300"
aria-pressed={!!anchor}
>
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
</IconButton>
)}
</PopOut>
);
}
type CustomDateFormatProps = {
value: string;
onChange: (format: string) => void;
};
function CustomDateFormat({ value, onChange }: CustomDateFormatProps) {
const [dateFormatCustom, setDateFormatCustom] = useState(value);
useEffect(() => {
setDateFormatCustom(value);
}, [value]);
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const format = evt.currentTarget.value;
setDateFormatCustom(format);
};
const handleReset = () => {
setDateFormatCustom(value);
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const target = evt.target as HTMLFormElement | undefined;
const customDateFormatInput = target?.customDateFormatInput as HTMLInputElement | undefined;
const format = customDateFormatInput?.value;
if (!format) return;
onChange(format);
};
const hasChanges = dateFormatCustom !== value;
return (
<SettingTile>
<Box as="form" onSubmit={handleSubmit} gap="200">
<Box grow="Yes" direction="Column">
<Input
required
name="customDateFormatInput"
value={dateFormatCustom}
onChange={handleChange}
maxLength={16}
autoComplete="off"
variant="Secondary"
radii="300"
style={{ paddingRight: config.space.S200 }}
after={<DateHint hasChanges={hasChanges} handleReset={handleReset} />}
/>
</Box>
<Button
size="400"
variant={hasChanges ? 'Success' : 'Secondary'}
fill={hasChanges ? 'Solid' : 'Soft'}
outlined
radii="300"
disabled={!hasChanges}
type="submit"
>
<Text size="B400">Save</Text>
</Button>
</Box>
</SettingTile>
);
}
type PresetDateFormatProps = {
value: string;
onChange: (format: string) => void;
};
function PresetDateFormat({ value, onChange }: PresetDateFormatProps) {
const [menuCords, setMenuCords] = useState<RectCords>();
const dateFormatItems = useDateFormatItems();
const getDisplayDate = (format: string): string =>
format !== '' ? dayjs().format(format) : 'Custom';
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (format: DateFormat) => {
onChange(format);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text size="T300">
{getDisplayDate(dateFormatItems.find((i) => i.format === value)?.format ?? value)}
</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>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{dateFormatItems.map((item) => (
<MenuItem
key={item.format}
size="300"
variant={value === item.format ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(item.format)}
>
<Text size="T300">{getDisplayDate(item.format)}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
function SelectDateFormat() {
const [dateFormatString, setDateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const [selectedDateFormat, setSelectedDateFormat] = useState(dateFormatString);
const customDateFormat = selectedDateFormat === '';
const handlePresetChange = (format: string) => {
setSelectedDateFormat(format);
if (format !== '') {
setDateFormatString(format);
}
};
return (
<>
<SettingTile
title="Date Format"
description={customDateFormat ? dayjs().format(dateFormatString) : ''}
after={<PresetDateFormat value={selectedDateFormat} onChange={handlePresetChange} />}
/>
{customDateFormat && (
<CustomDateFormat value={dateFormatString} onChange={setDateFormatString} />
)}
</>
);
}
function DateAndTime() {
const [hour24Clock, setHour24Clock] = useSetting(settingsAtom, 'hour24Clock');
return (
<Box direction="Column" gap="100">
<Text size="L400">Date & Time</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="24-Hour Time Format"
after={<Switch variant="Primary" value={hour24Clock} onChange={setHour24Clock} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SelectDateFormat />
</SequenceCard>
</Box>
);
}
function Editor() { function Editor() {
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
@@ -637,6 +995,7 @@ export function General({ requestClose }: GeneralProps) {
<PageContent> <PageContent>
<Box direction="Column" gap="700"> <Box direction="Column" gap="700">
<Appearance /> <Appearance />
<DateAndTime />
<Editor /> <Editor />
<Messages /> <Messages />
</Box> </Box>

View File

@@ -5,7 +5,9 @@ import { SystemNotification } from './SystemNotification';
import { AllMessagesNotifications } from './AllMessages'; import { AllMessagesNotifications } from './AllMessages';
import { SpecialMessagesNotifications } from './SpecialMessages'; import { SpecialMessagesNotifications } from './SpecialMessages';
import { KeywordMessagesNotifications } from './KeywordMessages'; import { KeywordMessagesNotifications } from './KeywordMessages';
import { IgnoredUserList } from './IgnoredUserList'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
type NotificationsProps = { type NotificationsProps = {
requestClose: () => void; requestClose: () => void;
@@ -35,7 +37,19 @@ export function Notifications({ requestClose }: NotificationsProps) {
<AllMessagesNotifications /> <AllMessagesNotifications />
<SpecialMessagesNotifications /> <SpecialMessagesNotifications />
<KeywordMessagesNotifications /> <KeywordMessagesNotifications />
<IgnoredUserList /> <Box direction="Column" gap="100">
<Text size="L400">Block Messages</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
description={'This option has been moved to "Account > Block Users" section.'}
/>
</SequenceCard>
</Box>
</Box> </Box>
</PageContent> </PageContent>
</Scroll> </Scroll>

View File

@@ -0,0 +1,17 @@
import { useMemo } from 'react';
export const useAccountManagementActions = () => {
const actions = useMemo(
() => ({
profile: 'org.matrix.profile',
sessionsList: 'org.matrix.sessions_list',
sessionView: 'org.matrix.session_view',
sessionEnd: 'org.matrix.session_end',
accountDeactivate: 'org.matrix.account_deactivate',
crossSigningReset: 'org.matrix.cross_signing_reset',
}),
[]
);
return actions;
};

View File

@@ -0,0 +1,12 @@
import { ValidatedAuthMetadata } from 'matrix-js-sdk';
import { createContext, useContext } from 'react';
const AuthMetadataContext = createContext<ValidatedAuthMetadata | undefined>(undefined);
export const AuthMetadataProvider = AuthMetadataContext.Provider;
export const useAuthMetadata = (): ValidatedAuthMetadata | undefined => {
const metadata = useContext(AuthMetadataContext);
return metadata;
};

View File

@@ -1,34 +1,127 @@
import { MatrixClient, Room } from 'matrix-js-sdk'; import { Direction, IContextResponse, MatrixClient, Method, Room, RoomMember } from 'matrix-js-sdk';
import { RoomServerAclEventContent } from 'matrix-js-sdk/lib/types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { getDMRoomFor, isRoomAlias, isRoomId, isUserId } from '../utils/matrix'; import {
getDMRoomFor,
isRoomAlias,
isRoomId,
isServerName,
isUserId,
rateLimitedActions,
} from '../utils/matrix';
import { hasDevices } from '../../util/matrixUtil'; import { hasDevices } from '../../util/matrixUtil';
import * as roomActions from '../../client/action/room'; import * as roomActions from '../../client/action/room';
import { useRoomNavigate } from './useRoomNavigate'; import { useRoomNavigate } from './useRoomNavigate';
import { Membership, StateEvent } from '../../types/matrix/room';
import { getStateEvent } from '../utils/room';
import { splitWithSpace } from '../utils/common';
export const SHRUG = '¯\\_(ツ)_/¯'; export const SHRUG = '¯\\_(ツ)_/¯';
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻'; export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
export const UNFLIP = '┬─┬ノ( º_º)'; export const UNFLIP = '┬─┬ノ( º_º)';
export function parseUsersAndReason(payload: string): { const FLAG_PAT = '(?:^|\\s)-(\\w+)\\b';
users: string[]; const FLAG_REG = new RegExp(FLAG_PAT);
reason?: string; const FLAG_REG_G = new RegExp(FLAG_PAT, 'g');
} {
let reason: string | undefined;
let ids: string = payload;
const reasonMatch = payload.match(/\s-r\s/); export const splitPayloadContentAndFlags = (payload: string): [string, string | undefined] => {
if (reasonMatch) { const flagMatch = payload.match(FLAG_REG);
ids = payload.slice(0, reasonMatch.index);
reason = payload.slice((reasonMatch.index ?? 0) + reasonMatch[0].length); if (!flagMatch) {
if (reason.trim() === '') reason = undefined; return [payload, undefined];
} }
const rawIds = ids.split(' '); const content = payload.slice(0, flagMatch.index);
const users = rawIds.filter((id) => isUserId(id)); const flags = payload.slice(flagMatch.index);
return {
users, return [content, flags];
reason, };
};
} export const parseFlags = (flags: string | undefined): Record<string, string | undefined> => {
const result: Record<string, string> = {};
if (!flags) return result;
const matches: { key: string; index: number; match: string }[] = [];
for (let match = FLAG_REG_G.exec(flags); match !== null; match = FLAG_REG_G.exec(flags)) {
matches.push({ key: match[1], index: match.index, match: match[0] });
}
for (let i = 0; i < matches.length; i += 1) {
const { key, match } = matches[i];
const start = matches[i].index + match.length;
const end = i + 1 < matches.length ? matches[i + 1].index : flags.length;
const value = flags.slice(start, end).trim();
result[key] = value;
}
return result;
};
export const parseUsers = (payload: string): string[] => {
const users: string[] = [];
splitWithSpace(payload).forEach((item) => {
if (isUserId(item)) {
users.push(item);
}
});
return users;
};
export const parseServers = (payload: string): string[] => {
const servers: string[] = [];
splitWithSpace(payload).forEach((item) => {
if (isServerName(item)) {
servers.push(item);
}
});
return servers;
};
const getServerMembers = (room: Room, server: string): RoomMember[] => {
const members: RoomMember[] = room
.getMembers()
.filter((member) => member.userId.endsWith(`:${server}`));
return members;
};
export const parseTimestampFlag = (input: string): number | undefined => {
const match = input.match(/^(\d+(?:\.\d+)?)([dhms])$/); // supports floats like 1.5d
if (!match) {
return undefined;
}
const value = parseFloat(match[1]); // supports decimal values
const unit = match[2];
const now = Date.now(); // in milliseconds
let delta = 0;
switch (unit) {
case 'd':
delta = value * 24 * 60 * 60 * 1000;
break;
case 'h':
delta = value * 60 * 60 * 1000;
break;
case 'm':
delta = value * 60 * 1000;
break;
case 's':
delta = value * 1000;
break;
default:
return undefined;
}
const timestamp = now - delta;
return timestamp;
};
export type CommandExe = (payload: string) => Promise<void>; export type CommandExe = (payload: string) => Promise<void>;
@@ -52,6 +145,8 @@ export enum Command {
ConvertToRoom = 'converttoroom', ConvertToRoom = 'converttoroom',
TableFlip = 'tableflip', TableFlip = 'tableflip',
UnFlip = 'unflip', UnFlip = 'unflip',
Delete = 'delete',
Acl = 'acl',
} }
export type CommandContent = { export type CommandContent = {
@@ -96,7 +191,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
name: Command.StartDm, name: Command.StartDm,
description: 'Start direct message with user. Example: /startdm userId1', description: 'Start direct message with user. Example: /startdm userId1',
exe: async (payload) => { exe: async (payload) => {
const rawIds = payload.split(' '); const rawIds = splitWithSpace(payload);
const userIds = rawIds.filter((id) => isUserId(id) && id !== mx.getUserId()); const userIds = rawIds.filter((id) => isUserId(id) && id !== mx.getUserId());
if (userIds.length === 0) return; if (userIds.length === 0) return;
if (userIds.length === 1) { if (userIds.length === 1) {
@@ -106,7 +201,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
return; return;
} }
} }
const devices = await Promise.all(userIds.map(uid => hasDevices(mx, uid))); const devices = await Promise.all(userIds.map((uid) => hasDevices(mx, uid)));
const isEncrypt = devices.every((hasDevice) => hasDevice); const isEncrypt = devices.every((hasDevice) => hasDevice);
const result = await roomActions.createDM(mx, userIds, isEncrypt); const result = await roomActions.createDM(mx, userIds, isEncrypt);
navigateRoom(result.room_id); navigateRoom(result.room_id);
@@ -116,7 +211,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
name: Command.Join, name: Command.Join,
description: 'Join room with address. Example: /join address1 address2', description: 'Join room with address. Example: /join address1 address2',
exe: async (payload) => { exe: async (payload) => {
const rawIds = payload.split(' '); const rawIds = splitWithSpace(payload);
const roomIds = rawIds.filter( const roomIds = rawIds.filter(
(idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias)
); );
@@ -131,7 +226,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
mx.leave(room.roomId); mx.leave(room.roomId);
return; return;
} }
const rawIds = payload.split(' '); const rawIds = splitWithSpace(payload);
const roomIds = rawIds.filter((id) => isRoomId(id)); const roomIds = rawIds.filter((id) => isRoomId(id));
roomIds.map((id) => mx.leave(id)); roomIds.map((id) => mx.leave(id));
}, },
@@ -140,7 +235,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
name: Command.Invite, name: Command.Invite,
description: 'Invite user to room. Example: /invite userId1 userId2 [-r reason]', description: 'Invite user to room. Example: /invite userId1 userId2 [-r reason]',
exe: async (payload) => { exe: async (payload) => {
const { users, reason } = parseUsersAndReason(payload); const [content, flags] = splitPayloadContentAndFlags(payload);
const users = parseUsers(content);
const flagToContent = parseFlags(flags);
const reason = flagToContent.r;
users.map((id) => mx.invite(room.roomId, id, reason)); users.map((id) => mx.invite(room.roomId, id, reason));
}, },
}, },
@@ -148,31 +246,64 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
name: Command.DisInvite, name: Command.DisInvite,
description: 'Disinvite user to room. Example: /disinvite userId1 userId2 [-r reason]', description: 'Disinvite user to room. Example: /disinvite userId1 userId2 [-r reason]',
exe: async (payload) => { exe: async (payload) => {
const { users, reason } = parseUsersAndReason(payload); const [content, flags] = splitPayloadContentAndFlags(payload);
const users = parseUsers(content);
const flagToContent = parseFlags(flags);
const reason = flagToContent.r;
users.map((id) => mx.kick(room.roomId, id, reason)); users.map((id) => mx.kick(room.roomId, id, reason));
}, },
}, },
[Command.Kick]: { [Command.Kick]: {
name: Command.Kick, name: Command.Kick,
description: 'Kick user from room. Example: /kick userId1 userId2 [-r reason]', description: 'Kick user from room. Example: /kick userId1 userId2 servername [-r reason]',
exe: async (payload) => { exe: async (payload) => {
const { users, reason } = parseUsersAndReason(payload); const [content, flags] = splitPayloadContentAndFlags(payload);
users.map((id) => mx.kick(room.roomId, id, reason)); const users = parseUsers(content);
const servers = parseServers(content);
const flagToContent = parseFlags(flags);
const reason = flagToContent.r;
const serverMembers = servers?.flatMap((server) => getServerMembers(room, server));
const serverUsers = serverMembers
?.filter((m) => m.membership !== Membership.Ban)
.map((m) => m.userId);
if (Array.isArray(serverUsers)) {
serverUsers.forEach((user) => {
if (!users.includes(user)) users.push(user);
});
}
rateLimitedActions(users, (id) => mx.kick(room.roomId, id, reason));
}, },
}, },
[Command.Ban]: { [Command.Ban]: {
name: Command.Ban, name: Command.Ban,
description: 'Ban user from room. Example: /ban userId1 userId2 [-r reason]', description: 'Ban user from room. Example: /ban userId1 userId2 servername [-r reason]',
exe: async (payload) => { exe: async (payload) => {
const { users, reason } = parseUsersAndReason(payload); const [content, flags] = splitPayloadContentAndFlags(payload);
users.map((id) => mx.ban(room.roomId, id, reason)); const users = parseUsers(content);
const servers = parseServers(content);
const flagToContent = parseFlags(flags);
const reason = flagToContent.r;
const serverMembers = servers?.flatMap((server) => getServerMembers(room, server));
const serverUsers = serverMembers?.map((m) => m.userId);
if (Array.isArray(serverUsers)) {
serverUsers.forEach((user) => {
if (!users.includes(user)) users.push(user);
});
}
rateLimitedActions(users, (id) => mx.ban(room.roomId, id, reason));
}, },
}, },
[Command.UnBan]: { [Command.UnBan]: {
name: Command.UnBan, name: Command.UnBan,
description: 'Unban user from room. Example: /unban userId1 userId2', description: 'Unban user from room. Example: /unban userId1 userId2',
exe: async (payload) => { exe: async (payload) => {
const rawIds = payload.split(' '); const rawIds = splitWithSpace(payload);
const users = rawIds.filter((id) => isUserId(id)); const users = rawIds.filter((id) => isUserId(id));
users.map((id) => mx.unban(room.roomId, id)); users.map((id) => mx.unban(room.roomId, id));
}, },
@@ -181,7 +312,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
name: Command.Ignore, name: Command.Ignore,
description: 'Ignore user. Example: /ignore userId1 userId2', description: 'Ignore user. Example: /ignore userId1 userId2',
exe: async (payload) => { exe: async (payload) => {
const rawIds = payload.split(' '); const rawIds = splitWithSpace(payload);
const userIds = rawIds.filter((id) => isUserId(id)); const userIds = rawIds.filter((id) => isUserId(id));
if (userIds.length > 0) roomActions.ignore(mx, userIds); if (userIds.length > 0) roomActions.ignore(mx, userIds);
}, },
@@ -190,7 +321,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
name: Command.UnIgnore, name: Command.UnIgnore,
description: 'Unignore user. Example: /unignore userId1 userId2', description: 'Unignore user. Example: /unignore userId1 userId2',
exe: async (payload) => { exe: async (payload) => {
const rawIds = payload.split(' '); const rawIds = splitWithSpace(payload);
const userIds = rawIds.filter((id) => isUserId(id)); const userIds = rawIds.filter((id) => isUserId(id));
if (userIds.length > 0) roomActions.unignore(mx, userIds); if (userIds.length > 0) roomActions.unignore(mx, userIds);
}, },
@@ -227,6 +358,124 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
roomActions.convertToRoom(mx, room.roomId); roomActions.convertToRoom(mx, room.roomId);
}, },
}, },
[Command.Delete]: {
name: Command.Delete,
description:
'Delete messages from users. Example: /delete userId1 servername -past 1d|2h|5m|30s [-t m.room.message] [-r spam]',
exe: async (payload) => {
const [content, flags] = splitPayloadContentAndFlags(payload);
const users = parseUsers(content);
const servers = parseServers(content);
const flagToContent = parseFlags(flags);
const reason = flagToContent.r;
const pastContent = flagToContent.past ?? '';
const msgTypeContent = flagToContent.t;
const messageTypes: string[] = msgTypeContent ? splitWithSpace(msgTypeContent) : [];
const ts = parseTimestampFlag(pastContent);
if (!ts) return;
const serverMembers = servers?.flatMap((server) => getServerMembers(room, server));
const serverUsers = serverMembers?.map((m) => m.userId);
if (Array.isArray(serverUsers)) {
serverUsers.forEach((user) => {
if (!users.includes(user)) users.push(user);
});
}
const result = await mx.timestampToEvent(room.roomId, ts, Direction.Forward);
const startEventId = result.event_id;
const path = `/rooms/${encodeURIComponent(room.roomId)}/context/${encodeURIComponent(
startEventId
)}`;
const eventContext = await mx.http.authedRequest<IContextResponse>(Method.Get, path, {
limit: 0,
});
let token: string | undefined = eventContext.start;
while (token) {
// eslint-disable-next-line no-await-in-loop
const response = await mx.createMessagesRequest(
room.roomId,
token,
20,
Direction.Forward,
undefined
);
const { end, chunk } = response;
// remove until the latest event;
token = end;
const eventsToDelete = chunk.filter(
(roomEvent) =>
(messageTypes.length > 0 ? messageTypes.includes(roomEvent.type) : true) &&
users.includes(roomEvent.sender) &&
roomEvent.unsigned?.redacted_because === undefined
);
const eventIds = eventsToDelete.map((roomEvent) => roomEvent.event_id);
// eslint-disable-next-line no-await-in-loop
await rateLimitedActions(eventIds, (eventId) =>
mx.redactEvent(room.roomId, eventId, undefined, { reason })
);
}
},
},
[Command.Acl]: {
name: Command.Acl,
description:
'Manage server access control list. Example /acl [-a servername1] [-d servername2] [-ra servername1] [-rd servername2]',
exe: async (payload) => {
const [, flags] = splitPayloadContentAndFlags(payload);
const flagToContent = parseFlags(flags);
const allowFlag = flagToContent.a;
const denyFlag = flagToContent.d;
const removeAllowFlag = flagToContent.ra;
const removeDenyFlag = flagToContent.rd;
const allowList = allowFlag ? splitWithSpace(allowFlag) : [];
const denyList = denyFlag ? splitWithSpace(denyFlag) : [];
const removeAllowList = removeAllowFlag ? splitWithSpace(removeAllowFlag) : [];
const removeDenyList = removeDenyFlag ? splitWithSpace(removeDenyFlag) : [];
const serverAcl = getStateEvent(
room,
StateEvent.RoomServerAcl
)?.getContent<RoomServerAclEventContent>();
const aclContent: RoomServerAclEventContent = {
allow: serverAcl?.allow ? [...serverAcl.allow] : [],
allow_ip_literals: serverAcl?.allow_ip_literals,
deny: serverAcl?.deny ? [...serverAcl.deny] : [],
};
allowList.forEach((servername) => {
if (!Array.isArray(aclContent.allow) || aclContent.allow.includes(servername)) return;
aclContent.allow.push(servername);
});
denyList.forEach((servername) => {
if (!Array.isArray(aclContent.deny) || aclContent.deny.includes(servername)) return;
aclContent.deny.push(servername);
});
aclContent.allow = aclContent.allow?.filter(
(servername) => !removeAllowList.includes(servername)
);
aclContent.deny = aclContent.deny?.filter(
(servername) => !removeDenyList.includes(servername)
);
aclContent.allow?.sort();
aclContent.deny?.sort();
await mx.sendStateEvent(room.roomId, StateEvent.RoomServerAcl as any, aclContent);
},
},
}), }),
[mx, room, navigateRoom] [mx, room, navigateRoom]
); );

View File

@@ -0,0 +1,34 @@
import { useMemo } from 'react';
import { DateFormat } from '../state/settings';
export type DateFormatItem = {
name: string;
format: DateFormat;
};
export const useDateFormatItems = (): DateFormatItem[] =>
useMemo(
() => [
{
format: 'D MMM YYYY',
name: 'D MMM YYYY',
},
{
format: 'DD/MM/YYYY',
name: 'DD/MM/YYYY',
},
{
format: 'MM/DD/YYYY',
name: 'MM/DD/YYYY',
},
{
format: 'YYYY/MM/DD',
name: 'YYYY/MM/DD',
},
{
format: '',
name: 'Custom',
},
],
[]
);

View File

@@ -1,7 +1,7 @@
import { useEffect, useCallback, useMemo } from 'react'; import { useEffect, useCallback, useMemo } from 'react';
import { IMyDevice } from 'matrix-js-sdk'; import { IMyDevice } from 'matrix-js-sdk';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { CryptoEvent, CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto'; import { CryptoEvent, CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto-api';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
export const useDeviceListChange = ( export const useDeviceListChange = (

View File

@@ -0,0 +1,10 @@
import { useSpecVersions } from './useSpecVersions';
export const useReportRoomSupported = (): boolean => {
const { versions, unstable_features: unstableFeatures } = useSpecVersions();
// report room is introduced in spec version 1.13
const supported = unstableFeatures?.['org.matrix.msc4151'] || versions.includes('v1.13');
return supported;
};

View File

@@ -9,10 +9,12 @@ import {
getSpaceRoomPath, getSpaceRoomPath,
} from '../pages/pathUtils'; } from '../pages/pathUtils';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { getOrphanParents } from '../utils/room'; import { getOrphanParents, guessPerfectParent } from '../utils/room';
import { roomToParentsAtom } from '../state/room/roomToParents'; import { roomToParentsAtom } from '../state/room/roomToParents';
import { mDirectAtom } from '../state/mDirectList'; import { mDirectAtom } from '../state/mDirectList';
import { useSelectedSpace } from './router/useSelectedSpace'; import { useSelectedSpace } from './router/useSelectedSpace';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
export const useRoomNavigate = () => { export const useRoomNavigate = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -20,6 +22,7 @@ export const useRoomNavigate = () => {
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const spaceSelectedId = useSelectedSpace(); const spaceSelectedId = useSelectedSpace();
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const navigateSpace = useCallback( const navigateSpace = useCallback(
(roomId: string) => { (roomId: string) => {
@@ -32,16 +35,23 @@ export const useRoomNavigate = () => {
const navigateRoom = useCallback( const navigateRoom = useCallback(
(roomId: string, eventId?: string, opts?: NavigateOptions) => { (roomId: string, eventId?: string, opts?: NavigateOptions) => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
const orphanParents = getOrphanParents(roomToParents, roomId); const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
if (orphanParents.length > 0) { if (orphanParents.length > 0) {
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId( let parentSpace: string;
mx, if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
spaceSelectedId && orphanParents.includes(spaceSelectedId) parentSpace = spaceSelectedId;
? spaceSelectedId } else {
: orphanParents[0] parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
}
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace);
navigate(
getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId),
opts
); );
navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts);
return; return;
} }
@@ -52,7 +62,7 @@ export const useRoomNavigate = () => {
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts); navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
}, },
[mx, navigate, spaceSelectedId, roomToParents, mDirects] [mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools]
); );
return { return {

View File

@@ -0,0 +1,37 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Temporarily sets a boolean state.
*
* @param duration - Duration in milliseconds before resetting (default: 1500)
* @param initial - Initial value (default: false)
*/
export function useTimeoutToggle(duration = 1500, initial = false): [boolean, () => void] {
const [active, setActive] = useState(initial);
const timeoutRef = useRef<number | null>(null);
const clear = () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
const trigger = useCallback(() => {
setActive(!initial);
clear();
timeoutRef.current = window.setTimeout(() => {
setActive(initial);
timeoutRef.current = null;
}, duration);
}, [duration, initial]);
useEffect(
() => () => {
clear();
},
[]
);
return [active, trigger];
}

View File

@@ -5,7 +5,7 @@ import './SpaceAddExisting.scss';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation'; import navigation from '../../../client/state/navigation';
import { joinRuleToIconSrc, getIdServer } from '../../../util/matrixUtil'; import { joinRuleToIconSrc } from '../../../util/matrixUtil';
import { Debounce } from '../../../util/common'; import { Debounce } from '../../../util/common';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
@@ -21,16 +21,17 @@ import Dialog from '../dialog/Dialog';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg'; import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
import SearchIC from '../../../../public/res/ic/outlined/search.svg'; import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import { useStore } from '../../hooks/useStore';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList'; import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList';
import { allRoomsAtom } from '../../state/room-list/roomList'; import { allRoomsAtom } from '../../state/room-list/roomList';
import { mDirectAtom } from '../../state/mDirectList'; import { mDirectAtom } from '../../state/mDirectList';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
import { rateLimitedActions } from '../../utils/matrix';
import { useAlive } from '../../hooks/useAlive';
function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) { function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
const mountStore = useStore(roomId); const alive = useAlive();
const [debounce] = useState(new Debounce()); const [debounce] = useState(new Debounce());
const [process, setProcess] = useState(null); const [process, setProcess] = useState(null);
const [allRoomIds, setAllRoomIds] = useState([]); const [allRoomIds, setAllRoomIds] = useState([]);
@@ -68,14 +69,11 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
const handleAdd = async () => { const handleAdd = async () => {
setProcess(`Adding ${selected.length} items...`); setProcess(`Adding ${selected.length} items...`);
const promises = selected.map((rId) => { await rateLimitedActions(selected, async (rId) => {
const room = mx.getRoom(rId); const room = mx.getRoom(rId);
const via = getViaServers(room); const via = getViaServers(room);
if (via.length === 0) {
via.push(getIdServer(rId));
}
return mx.sendStateEvent( await mx.sendStateEvent(
roomId, roomId,
'm.space.child', 'm.space.child',
{ {
@@ -87,9 +85,7 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
); );
}); });
mountStore.setItem(true); if (!alive()) return;
await Promise.allSettled(promises);
if (mountStore.getItem() !== true) return;
const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs]; const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
const allIds = roomIds.filter( const allIds = roomIds.filter(

View File

@@ -273,7 +273,7 @@ function InviteUser({ isOpen, roomId, searchTerm, onRequestClose }) {
searchUser(usernameRef.current.value); searchUser(usernameRef.current.value);
}} }}
> >
<Input value={searchTerm} forwardRef={usernameRef} label="Name or userId" /> <Input value={searchTerm} forwardRef={usernameRef} label="Name or userId" autoFocus />
<Button disabled={isSearching} iconSrc={UserIC} variant="primary" type="submit"> <Button disabled={isSearching} iconSrc={UserIC} variant="primary" type="submit">
Search Search
</Button> </Button>

View File

@@ -75,7 +75,7 @@ function JoinAliasContent({ term, requestClose }) {
return ( return (
<form className="join-alias" onSubmit={handleSubmit}> <form className="join-alias" onSubmit={handleSubmit}>
<Input label="Address" value={term} name="alias" required /> <Input label="Address" value={term} name="alias" required autoFocus />
{error && ( {error && (
<Text className="join-alias__error" variant="b3"> <Text className="join-alias__error" variant="b3">
{error} {error}

View File

@@ -15,7 +15,7 @@ export function AuthFooter() {
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
v4.6.0 v4.8.1
</Text> </Text>
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer"> <Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
Twitter Twitter

View File

@@ -1,19 +1,21 @@
import { Avatar, AvatarImage, Box, Button, Text } from 'folds'; import { Avatar, AvatarImage, Box, Button, Text } from 'folds';
import { IIdentityProvider, createClient } from 'matrix-js-sdk'; import { IIdentityProvider, SSOAction, createClient } from 'matrix-js-sdk';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
type SSOLoginProps = { type SSOLoginProps = {
providers?: IIdentityProvider[]; providers?: IIdentityProvider[];
redirectUrl: string; redirectUrl: string;
action?: SSOAction;
saveScreenSpace?: boolean; saveScreenSpace?: boolean;
}; };
export function SSOLogin({ providers, redirectUrl, saveScreenSpace }: SSOLoginProps) { export function SSOLogin({ providers, redirectUrl, action, saveScreenSpace }: SSOLoginProps) {
const discovery = useAutoDiscoveryInfo(); const discovery = useAutoDiscoveryInfo();
const baseUrl = discovery['m.homeserver'].base_url; const baseUrl = discovery['m.homeserver'].base_url;
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]); const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
const getSSOIdUrl = (ssoId?: string): string => mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId); const getSSOIdUrl = (ssoId?: string): string =>
mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId, action);
const withoutIcon = providers const withoutIcon = providers
? providers.find( ? providers.find(

View File

@@ -1,6 +1,7 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Box, Text, color } from 'folds'; import { Box, Text, color } from 'folds';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { SSOAction } from 'matrix-js-sdk';
import { useAuthFlows } from '../../../hooks/useAuthFlows'; import { useAuthFlows } from '../../../hooks/useAuthFlows';
import { useAuthServer } from '../../../hooks/useAuthServer'; import { useAuthServer } from '../../../hooks/useAuthServer';
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows'; import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
@@ -76,6 +77,7 @@ export function Login() {
<SSOLogin <SSOLogin
providers={parsedFlows.sso.identity_providers} providers={parsedFlows.sso.identity_providers}
redirectUrl={ssoRedirectUrl} redirectUrl={ssoRedirectUrl}
action={SSOAction.LOGIN}
saveScreenSpace={parsedFlows.password !== undefined} saveScreenSpace={parsedFlows.password !== undefined}
/> />
<span data-spacing-node /> <span data-spacing-node />

View File

@@ -73,7 +73,7 @@ export const login = async (
} }
const mx = createClient({ baseUrl: url }); const mx = createClient({ baseUrl: url });
const [err, res] = await to<LoginResponse, MatrixError>(mx.login(data.type, data)); const [err, res] = await to<LoginResponse, MatrixError>(mx.loginRequest(data));
if (err) { if (err) {
if (err.httpStatus === 400) { if (err.httpStatus === 400) {

View File

@@ -1,6 +1,7 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Box, Text, color } from 'folds'; import { Box, Text, color } from 'folds';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { SSOAction } from 'matrix-js-sdk';
import { useAuthServer } from '../../../hooks/useAuthServer'; import { useAuthServer } from '../../../hooks/useAuthServer';
import { RegisterFlowStatus, useAuthFlows } from '../../../hooks/useAuthFlows'; import { RegisterFlowStatus, useAuthFlows } from '../../../hooks/useAuthFlows';
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows'; import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
@@ -83,6 +84,7 @@ export function Register() {
<SSOLogin <SSOLogin
providers={sso.identity_providers} providers={sso.identity_providers}
redirectUrl={ssoRedirectUrl} redirectUrl={ssoRedirectUrl}
action={SSOAction.REGISTER}
saveScreenSpace={registerFlows.status === RegisterFlowStatus.FlowRequired} saveScreenSpace={registerFlows.status === RegisterFlowStatus.FlowRequired}
/> />
<span data-spacing-node /> <span data-spacing-node />

View File

@@ -25,7 +25,7 @@ import {
} from '../../../client/initMatrix'; } from '../../../client/initMatrix';
import { getSecret } from '../../../client/state/auth'; import { getSecret } from '../../../client/state/auth';
import { SplashScreen } from '../../components/splash-screen'; import { SplashScreen } from '../../components/splash-screen';
import { CapabilitiesAndMediaConfigLoader } from '../../components/CapabilitiesAndMediaConfigLoader'; import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
import { CapabilitiesProvider } from '../../hooks/useCapabilities'; import { CapabilitiesProvider } from '../../hooks/useCapabilities';
import { MediaConfigProvider } from '../../hooks/useMediaConfig'; import { MediaConfigProvider } from '../../hooks/useMediaConfig';
import { MatrixClientProvider } from '../../hooks/useMatrixClient'; import { MatrixClientProvider } from '../../hooks/useMatrixClient';
@@ -37,6 +37,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useSyncState } from '../../hooks/useSyncState'; import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus'; import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
function ClientRootLoading() { function ClientRootLoading() {
return ( return (
@@ -207,18 +208,20 @@ export function ClientRoot({ children }: ClientRootProps) {
<ClientRootLoading /> <ClientRootLoading />
) : ( ) : (
<MatrixClientProvider value={mx}> <MatrixClientProvider value={mx}>
<CapabilitiesAndMediaConfigLoader> <ServerConfigsLoader>
{(capabilities, mediaConfig) => ( {(serverConfigs) => (
<CapabilitiesProvider value={capabilities ?? {}}> <CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
<MediaConfigProvider value={mediaConfig ?? {}}> <MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
{children} <AuthMetadataProvider value={serverConfigs.authMetadata}>
<Windows /> {children}
<Dialogs /> <Windows />
<ReusableContextMenu /> <Dialogs />
<ReusableContextMenu />
</AuthMetadataProvider>
</MediaConfigProvider> </MediaConfigProvider>
</CapabilitiesProvider> </CapabilitiesProvider>
)} )}
</CapabilitiesAndMediaConfigLoader> </ServerConfigsLoader>
</MatrixClientProvider> </MatrixClientProvider>
)} )}
</SpecVersions> </SpecVersions>

View File

@@ -24,7 +24,7 @@ export function WelcomePage() {
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
v4.6.0 v4.8.1
</a> </a>
</span> </span>
} }

View File

@@ -209,7 +209,7 @@ export function Explore() {
<Box as="span" grow="Yes" alignItems="Center" gap="200"> <Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400"> <Avatar size="200" radii="400">
<Icon <Icon
src={Icons.Category} src={Icons.Server}
size="100" size="100"
filled={selectedServer === userServer} filled={selectedServer === userServer}
/> />
@@ -243,11 +243,7 @@ export function Explore() {
<NavItemContent> <NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200"> <Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400"> <Avatar size="200" radii="400">
<Icon <Icon src={Icons.Server} size="100" filled={server === selectedServer} />
src={Icons.Category}
size="100"
filled={server === selectedServer}
/>
</Avatar> </Avatar>
<Box as="span" grow="Yes"> <Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate> <Text as="span" size="Inherit" truncate>

View File

@@ -507,7 +507,7 @@ export function PublicRooms() {
)} )}
</Box> </Box>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200"> <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Category} />} {screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Server} />}
<Text size="H3" truncate> <Text size="H3" truncate>
{server} {server}
</Text> </Text>

View File

@@ -32,7 +32,7 @@ function InvitesNavItem() {
</Avatar> </Avatar>
<Box as="span" grow="Yes"> <Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate> <Text as="span" size="Inherit" truncate>
Invitations Invites
</Text> </Text>
</Box> </Box>
{inviteCount > 0 && <UnreadBadge highlight count={inviteCount} />} {inviteCount > 0 && <UnreadBadge highlight count={inviteCount} />}

View File

@@ -1,8 +1,10 @@
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import { import {
Avatar, Avatar,
Badge,
Box, Box,
Button, Button,
Chip,
Icon, Icon,
IconButton, IconButton,
Icons, Icons,
@@ -16,56 +18,140 @@ import {
config, config,
} from 'folds'; } from 'folds';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { RoomTopicEventContent } from 'matrix-js-sdk/lib/types';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { MatrixError, Room } from 'matrix-js-sdk'; import { MatrixClient, MatrixError, Room } from 'matrix-js-sdk';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page'; import {
import { useDirectInvites, useRoomInvites, useSpaceInvites } from '../../../state/hooks/inviteList'; Page,
PageContent,
PageContentCenter,
PageHeader,
PageHero,
PageHeroEmpty,
PageHeroSection,
} from '../../../components/page';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { allInvitesAtom } from '../../../state/room-list/inviteList'; import { allInvitesAtom } from '../../../state/room-list/inviteList';
import { mDirectAtom } from '../../../state/mDirectList';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { import {
bannedInRooms,
getCommonRooms,
getDirectRoomAvatarUrl, getDirectRoomAvatarUrl,
getMemberDisplayName, getMemberDisplayName,
getRoomAvatarUrl, getRoomAvatarUrl,
getStateEvent,
isDirectInvite, isDirectInvite,
isSpace,
} from '../../../utils/room'; } from '../../../utils/room';
import { nameInitials } from '../../../utils/common'; import { nameInitials } from '../../../utils/common';
import { RoomAvatar } from '../../../components/room-avatar'; import { RoomAvatar } from '../../../components/room-avatar';
import { addRoomIdToMDirect, getMxIdLocalPart, guessDmRoomUserId } from '../../../utils/matrix'; import {
addRoomIdToMDirect,
getMxIdLocalPart,
guessDmRoomUserId,
rateLimitedActions,
} from '../../../utils/matrix';
import { Time } from '../../../components/message'; import { Time } from '../../../components/message';
import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver'; import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver';
import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard'; import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard';
import { RoomTopicViewer } from '../../../components/room-topic-viewer'; import { RoomTopicViewer } from '../../../components/room-topic-viewer';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { useRoomTopic } from '../../../hooks/useRoomMeta';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler'; import { BackRouteHandler } from '../../../components/BackRouteHandler';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { StateEvent } from '../../../../types/matrix/room';
import { testBadWords } from '../../../plugins/bad-words';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
import { useReportRoomSupported } from '../../../hooks/useReportRoomSupported';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
const COMPACT_CARD_WIDTH = 548; const COMPACT_CARD_WIDTH = 548;
type InviteCardProps = { type InviteData = {
room: Room; room: Room;
userId: string; roomId: string;
direct?: boolean; roomName: string;
compact?: boolean; roomAvatar?: string;
onNavigate: (roomId: string) => void; roomTopic?: string;
roomAlias?: string;
senderId: string;
senderName: string;
inviteTs?: number;
isSpace: boolean;
isDirect: boolean;
isEncrypted: boolean;
}; };
function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardProps) {
const mx = useMatrixClient(); const makeInviteData = (mx: MatrixClient, room: Room, useAuthentication: boolean): InviteData => {
const useAuthentication = useMediaAuthentication(); const userId = mx.getSafeUserId();
const direct = isDirectInvite(room, userId);
const roomAvatar = direct
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
const roomName = room.name || room.getCanonicalAlias() || room.roomId; const roomName = room.name || room.getCanonicalAlias() || room.roomId;
const roomTopic =
getStateEvent(room, StateEvent.RoomTopic)?.getContent<RoomTopicEventContent>()?.topic ??
undefined;
const member = room.getMember(userId); const member = room.getMember(userId);
const memberEvent = member?.events.member; const memberEvent = member?.events.member;
const memberTs = memberEvent?.getTs() ?? 0;
const senderId = memberEvent?.getSender(); const senderId = memberEvent?.getSender();
const senderName = senderId const senderName = senderId
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId ? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
: undefined; : undefined;
const inviteTs = memberEvent?.getTs() ?? 0;
const topic = useRoomTopic(room); return {
room,
roomId: room.roomId,
roomAvatar,
roomName,
roomTopic,
roomAlias: room.getCanonicalAlias() ?? undefined,
senderId: senderId ?? 'Unknown',
senderName: senderName ?? 'Unknown',
inviteTs,
isSpace: isSpace(room),
isDirect: direct,
isEncrypted: !!getStateEvent(room, StateEvent.RoomEncryption),
};
};
const hasBadWords = (invite: InviteData): boolean =>
testBadWords(invite.roomName) ||
testBadWords(invite.roomTopic ?? '') ||
testBadWords(invite.senderName) ||
testBadWords(invite.senderId);
type NavigateHandler = (roomId: string, space: boolean) => void;
type InviteCardProps = {
invite: InviteData;
compact?: boolean;
hour24Clock: boolean;
dateFormatString: string;
onNavigate: NavigateHandler;
hideAvatar: boolean;
};
function InviteCard({
invite,
compact,
hour24Clock,
dateFormatString,
onNavigate,
hideAvatar,
}: InviteCardProps) {
const mx = useMatrixClient();
const userId = mx.getSafeUserId();
const [viewTopic, setViewTopic] = useState(false); const [viewTopic, setViewTopic] = useState(false);
const closeTopic = () => setViewTopic(false); const closeTopic = () => setViewTopic(false);
@@ -73,17 +159,19 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
const [joinState, join] = useAsyncCallback<void, MatrixError, []>( const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
useCallback(async () => { useCallback(async () => {
const dmUserId = isDirectInvite(room, userId) ? guessDmRoomUserId(room, userId) : undefined; const dmUserId = isDirectInvite(invite.room, userId)
? guessDmRoomUserId(invite.room, userId)
: undefined;
await mx.joinRoom(room.roomId); await mx.joinRoom(invite.roomId);
if (dmUserId) { if (dmUserId) {
await addRoomIdToMDirect(mx, room.roomId, dmUserId); await addRoomIdToMDirect(mx, invite.roomId, dmUserId);
} }
onNavigate(room.roomId); onNavigate(invite.roomId, invite.isSpace);
}, [mx, room, userId, onNavigate]) }, [mx, invite, userId, onNavigate])
); );
const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>( const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
useCallback(() => mx.leave(room.roomId), [mx, room]) useCallback(() => mx.leave(invite.roomId), [mx, invite])
); );
const joining = const joining =
@@ -95,28 +183,43 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
<SequenceCard <SequenceCard
variant="SurfaceVariant" variant="SurfaceVariant"
direction="Column" direction="Column"
gap="200" gap="300"
style={{ padding: config.space.S400, paddingTop: config.space.S200 }} style={{ padding: `${config.space.S400} ${config.space.S400} ${config.space.S200}` }}
> >
<Box gap="200" alignItems="Baseline"> {(invite.isEncrypted || invite.isDirect || invite.isSpace) && (
<Box grow="Yes"> <Box gap="200" alignItems="Center">
<Text size="T200" priority="300" truncate> {invite.isEncrypted && (
Invited by <b>{senderName}</b> <Box shrink="No" alignItems="Center" justifyContent="Center">
</Text> <Badge variant="Success" fill="Solid" size="400" radii="300">
<Text size="L400">Encrypted</Text>
</Badge>
</Box>
)}
{invite.isDirect && (
<Box shrink="No" alignItems="Center" justifyContent="Center">
<Badge variant="Primary" fill="Solid" size="400" radii="300">
<Text size="L400">Direct Message</Text>
</Badge>
</Box>
)}
{invite.isSpace && (
<Box shrink="No" alignItems="Center" justifyContent="Center">
<Badge variant="Secondary" fill="Soft" size="400" radii="300">
<Text size="L400">Space</Text>
</Badge>
</Box>
)}
</Box> </Box>
<Box shrink="No"> )}
<Time size="T200" ts={memberTs} priority="300" />
</Box>
</Box>
<Box gap="300"> <Box gap="300">
<Avatar size="300"> <Avatar size="300">
<RoomAvatar <RoomAvatar
roomId={room.roomId} roomId={invite.roomId}
src={direct ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication)} src={hideAvatar ? undefined : invite.roomAvatar}
alt={roomName} alt={invite.roomName}
renderFallback={() => ( renderFallback={() => (
<Text as="span" size="H6"> <Text as="span" size="H6">
{nameInitials(roomName)} {nameInitials(hideAvatar && invite.roomAvatar ? undefined : invite.roomName)}
</Text> </Text>
)} )}
/> />
@@ -125,9 +228,9 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
<Box grow="Yes" direction="Column" gap="200"> <Box grow="Yes" direction="Column" gap="200">
<Box direction="Column"> <Box direction="Column">
<Text size="T300" truncate> <Text size="T300" truncate>
<b>{roomName}</b> <b>{invite.roomName}</b>
</Text> </Text>
{topic && ( {invite.roomTopic && (
<Text <Text
size="T200" size="T200"
onClick={openTopic} onClick={openTopic}
@@ -135,7 +238,7 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
tabIndex={0} tabIndex={0}
truncate truncate
> >
{topic} {invite.roomTopic}
</Text> </Text>
)} )}
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}> <Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
@@ -149,8 +252,8 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
}} }}
> >
<RoomTopicViewer <RoomTopicViewer
name={roomName} name={invite.roomName}
topic={topic ?? ''} topic={invite.roomTopic ?? ''}
requestClose={closeTopic} requestClose={closeTopic}
/> />
</FocusTrap> </FocusTrap>
@@ -173,6 +276,7 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
onClick={leave} onClick={leave}
size="300" size="300"
variant="Secondary" variant="Secondary"
radii="300"
fill="Soft" fill="Soft"
disabled={joining || leaving} disabled={joining || leaving}
before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined} before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined}
@@ -182,28 +286,430 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
<Button <Button
onClick={join} onClick={join}
size="300" size="300"
variant="Primary" variant="Success"
fill="Soft" fill="Soft"
radii="300"
outlined outlined
disabled={joining || leaving} disabled={joining || leaving}
before={joining ? <Spinner variant="Primary" fill="Soft" size="100" /> : undefined} before={joining ? <Spinner variant="Success" fill="Soft" size="100" /> : undefined}
> >
<Text size="B300">Accept</Text> <Text size="B300">Accept</Text>
</Button> </Button>
</Box> </Box>
</Box> </Box>
</Box> </Box>
<Box gap="200" alignItems="Baseline">
<Box grow="Yes">
<Text size="T200" priority="300">
From: <b>{invite.senderId}</b>
</Text>
</Box>
{invite.inviteTs && (
<Box shrink="No">
<Time
size="T200"
ts={invite.inviteTs}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
priority="300"
/>
</Box>
)}
</Box>
</SequenceCard> </SequenceCard>
); );
} }
enum InviteFilter {
Known,
Unknown,
Spam,
}
type InviteFiltersProps = {
filter: InviteFilter;
onFilter: (filter: InviteFilter) => void;
knownInvites: InviteData[];
unknownInvites: InviteData[];
spamInvites: InviteData[];
};
function InviteFilters({
filter,
onFilter,
knownInvites,
unknownInvites,
spamInvites,
}: InviteFiltersProps) {
const isKnown = filter === InviteFilter.Known;
const isUnknown = filter === InviteFilter.Unknown;
const isSpam = filter === InviteFilter.Spam;
return (
<Box gap="200">
<Chip
variant={isKnown ? 'Success' : 'Surface'}
aria-selected={isKnown}
outlined={!isKnown}
onClick={() => onFilter(InviteFilter.Known)}
before={isKnown && <Icon size="100" src={Icons.Check} />}
after={
knownInvites.length > 0 && (
<Badge variant={isKnown ? 'Success' : 'Secondary'} fill="Solid" radii="Pill">
<Text size="L400">{knownInvites.length}</Text>
</Badge>
)
}
>
<Text size="T200">Primary</Text>
</Chip>
<Chip
variant={isUnknown ? 'Warning' : 'Surface'}
aria-selected={isUnknown}
outlined={!isUnknown}
onClick={() => onFilter(InviteFilter.Unknown)}
before={isUnknown && <Icon size="100" src={Icons.Check} />}
after={
unknownInvites.length > 0 && (
<Badge variant={isUnknown ? 'Warning' : 'Secondary'} fill="Solid" radii="Pill">
<Text size="L400">{unknownInvites.length}</Text>
</Badge>
)
}
>
<Text size="T200">Public</Text>
</Chip>
<Chip
variant={isSpam ? 'Critical' : 'Surface'}
aria-selected={isSpam}
outlined={!isSpam}
onClick={() => onFilter(InviteFilter.Spam)}
before={isSpam && <Icon size="100" src={Icons.Check} />}
after={
spamInvites.length > 0 && (
<Badge variant={isSpam ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
<Text size="L400">{spamInvites.length}</Text>
</Badge>
)
}
>
<Text size="T200">Spam</Text>
</Chip>
</Box>
);
}
type KnownInvitesProps = {
invites: InviteData[];
handleNavigate: NavigateHandler;
compact: boolean;
hour24Clock: boolean;
dateFormatString: string;
};
function KnownInvites({
invites,
handleNavigate,
compact,
hour24Clock,
dateFormatString,
}: KnownInvitesProps) {
return (
<Box direction="Column" gap="200">
<Text size="H4">Primary</Text>
{invites.length > 0 ? (
<Box direction="Column" gap="100">
{invites.map((invite) => (
<InviteCard
key={invite.roomId}
invite={invite}
compact={compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
onNavigate={handleNavigate}
hideAvatar={false}
/>
))}
</Box>
) : (
<PageHeroEmpty>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Mail} />}
title="No Invites"
subTitle="When someone you share a room with sends you an invite, itll show up here."
/>
</PageHeroSection>
</PageHeroEmpty>
)}
</Box>
);
}
type UnknownInvitesProps = {
invites: InviteData[];
handleNavigate: NavigateHandler;
compact: boolean;
hour24Clock: boolean;
dateFormatString: string;
};
function UnknownInvites({
invites,
handleNavigate,
compact,
hour24Clock,
dateFormatString,
}: UnknownInvitesProps) {
const mx = useMatrixClient();
const [declineAllStatus, declineAll] = useAsyncCallback(
useCallback(async () => {
const roomIds = invites.map((invite) => invite.roomId);
await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId));
}, [mx, invites])
);
const declining = declineAllStatus.status === AsyncStatus.Loading;
return (
<Box direction="Column" gap="200">
<Box gap="200" justifyContent="SpaceBetween" alignItems="Center">
<Text size="H4">Public</Text>
<Box>
{invites.length > 0 && (
<Chip
variant="SurfaceVariant"
onClick={declineAll}
before={declining && <Spinner size="50" variant="Secondary" fill="Soft" />}
disabled={declining}
radii="Pill"
>
<Text size="T200">Decline All</Text>
</Chip>
)}
</Box>
</Box>
{invites.length > 0 ? (
<Box direction="Column" gap="100">
{invites.map((invite) => (
<InviteCard
key={invite.roomId}
invite={invite}
compact={compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
onNavigate={handleNavigate}
hideAvatar
/>
))}
</Box>
) : (
<PageHeroEmpty>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Info} />}
title="No Invites"
subTitle="Invites from people outside your rooms will appear here."
/>
</PageHeroSection>
</PageHeroEmpty>
)}
</Box>
);
}
type SpamInvitesProps = {
invites: InviteData[];
handleNavigate: NavigateHandler;
compact: boolean;
hour24Clock: boolean;
dateFormatString: string;
};
function SpamInvites({
invites,
handleNavigate,
compact,
hour24Clock,
dateFormatString,
}: SpamInvitesProps) {
const mx = useMatrixClient();
const [showInvites, setShowInvites] = useState(false);
const reportRoomSupported = useReportRoomSupported();
const [declineAllStatus, declineAll] = useAsyncCallback(
useCallback(async () => {
const roomIds = invites.map((invite) => invite.roomId);
await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId));
}, [mx, invites])
);
const [reportAllStatus, reportAll] = useAsyncCallback(
useCallback(async () => {
const roomIds = invites.map((invite) => invite.roomId);
await rateLimitedActions(roomIds, (roomId) => mx.reportRoom(roomId, 'Spam Invite'));
}, [mx, invites])
);
const ignoredUsers = useIgnoredUsers();
const unignoredUsers = Array.from(new Set(invites.map((invite) => invite.senderId))).filter(
(user) => !ignoredUsers.includes(user)
);
const [blockAllStatus, blockAll] = useAsyncCallback(
useCallback(
() => mx.setIgnoredUsers([...ignoredUsers, ...unignoredUsers]),
[mx, ignoredUsers, unignoredUsers]
)
);
const declining = declineAllStatus.status === AsyncStatus.Loading;
const reporting = reportAllStatus.status === AsyncStatus.Loading;
const blocking = blockAllStatus.status === AsyncStatus.Loading;
const loading = blocking || reporting || declining;
return (
<Box direction="Column" gap="200">
<Text size="H4">Spam</Text>
{invites.length > 0 ? (
<Box direction="Column" gap="100">
<SequenceCard
variant="SurfaceVariant"
direction="Column"
gap="300"
style={{ padding: `${config.space.S400} ${config.space.S400} 0` }}
>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Warning} />}
title={`${invites.length} Spam Invites`}
subTitle="Some of the following invites may contain harmful content or have been sent by banned users."
>
<Box direction="Row" gap="200" justifyContent="Center" wrap="Wrap">
<Button
size="300"
variant="Critical"
fill="Solid"
radii="300"
onClick={declineAll}
before={declining && <Spinner size="100" variant="Critical" fill="Solid" />}
disabled={loading}
>
<Text size="B300" truncate>
Decline All
</Text>
</Button>
{reportRoomSupported && reportAllStatus.status !== AsyncStatus.Success && (
<Button
size="300"
variant="Secondary"
fill="Solid"
radii="300"
onClick={reportAll}
before={reporting && <Spinner size="100" variant="Secondary" fill="Solid" />}
disabled={loading}
>
<Text size="B300" truncate>
Report All
</Text>
</Button>
)}
{unignoredUsers.length > 0 && (
<Button
size="300"
variant="Secondary"
fill="Solid"
radii="300"
disabled={loading}
onClick={blockAll}
before={blocking && <Spinner size="100" variant="Secondary" fill="Solid" />}
>
<Text size="B300" truncate>
Block All
</Text>
</Button>
)}
</Box>
<span data-spacing-node />
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="Pill"
before={
<Icon size="100" src={showInvites ? Icons.ChevronTop : Icons.ChevronBottom} />
}
onClick={() => setShowInvites(!showInvites)}
>
<Text size="B300">{showInvites ? 'Hide All' : 'View All'}</Text>
</Button>
</PageHero>
</PageHeroSection>
</SequenceCard>
{showInvites &&
invites.map((invite) => (
<InviteCard
key={invite.roomId}
invite={invite}
compact={compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
onNavigate={handleNavigate}
hideAvatar
/>
))}
</Box>
) : (
<PageHeroEmpty>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Warning} />}
title="No Spam Invites"
subTitle="Invites detected as spam appear here."
/>
</PageHeroSection>
</PageHeroEmpty>
)}
</Box>
);
}
export function Invites() { export function Invites() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const userId = mx.getUserId()!; const useAuthentication = useMediaAuthentication();
const mDirects = useAtomValue(mDirectAtom); const { navigateRoom, navigateSpace } = useRoomNavigate();
const directInvites = useDirectInvites(mx, allInvitesAtom, mDirects); const allRooms = useAtomValue(allRoomsAtom);
const spaceInvites = useSpaceInvites(mx, allInvitesAtom); const allInviteIds = useAtomValue(allInvitesAtom);
const roomInvites = useRoomInvites(mx, allInvitesAtom, mDirects);
const [filter, setFilter] = useState(InviteFilter.Known);
const invitesData = allInviteIds
.map((inviteId) => mx.getRoom(inviteId))
.filter((inviteRoom) => !!inviteRoom)
.map((inviteRoom) => makeInviteData(mx, inviteRoom, useAuthentication));
const [knownInvites, unknownInvites, spamInvites] = useMemo(() => {
const known: InviteData[] = [];
const unknown: InviteData[] = [];
const spam: InviteData[] = [];
invitesData.forEach((invite) => {
if (hasBadWords(invite) || bannedInRooms(mx, allRooms, invite.senderId)) {
spam.push(invite);
return;
}
if (getCommonRooms(mx, allRooms, invite.senderId).length === 0) {
unknown.push(invite);
return;
}
known.push(invite);
});
return [known, unknown, spam];
}, [mx, allRooms, invitesData]);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH); const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH);
useElementSizeObserver( useElementSizeObserver(
@@ -212,21 +718,15 @@ export function Invites() {
); );
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const { navigateRoom, navigateSpace } = useRoomNavigate(); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const renderInvite = (roomId: string, direct: boolean, handleNavigate: (rId: string) => void) => { const handleNavigate = (roomId: string, space: boolean) => {
const room = mx.getRoom(roomId); if (space) {
if (!room) return null; navigateSpace(roomId);
return ( return;
<InviteCard }
key={roomId} navigateRoom(roomId);
room={room}
userId={userId}
compact={compact}
direct={direct}
onNavigate={handleNavigate}
/>
);
}; };
return ( return (
@@ -247,7 +747,7 @@ export function Invites() {
<Box alignItems="Center" gap="200"> <Box alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Mail} />} {screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Mail} />}
<Text size="H3" truncate> <Text size="H3" truncate>
Invitations Invites
</Text> </Text>
</Box> </Box>
<Box grow="Yes" basis="No" /> <Box grow="Yes" basis="No" />
@@ -258,47 +758,46 @@ export function Invites() {
<PageContent> <PageContent>
<PageContentCenter> <PageContentCenter>
<Box ref={containerRef} direction="Column" gap="600"> <Box ref={containerRef} direction="Column" gap="600">
{directInvites.length > 0 && ( <Box direction="Column" gap="100">
<Box direction="Column" gap="200"> <span data-spacing-node />
<Text size="H4">Direct Messages</Text> <Text size="L400">Filter</Text>
<Box direction="Column" gap="100"> <InviteFilters
{directInvites.map((roomId) => renderInvite(roomId, true, navigateRoom))} filter={filter}
</Box> onFilter={setFilter}
</Box> knownInvites={knownInvites}
unknownInvites={unknownInvites}
spamInvites={spamInvites}
/>
</Box>
{filter === InviteFilter.Known && (
<KnownInvites
invites={knownInvites}
compact={compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
handleNavigate={handleNavigate}
/>
)} )}
{spaceInvites.length > 0 && (
<Box direction="Column" gap="200"> {filter === InviteFilter.Unknown && (
<Text size="H4">Spaces</Text> <UnknownInvites
<Box direction="Column" gap="100"> invites={unknownInvites}
{spaceInvites.map((roomId) => renderInvite(roomId, false, navigateSpace))} compact={compact}
</Box> hour24Clock={hour24Clock}
</Box> dateFormatString={dateFormatString}
handleNavigate={handleNavigate}
/>
)} )}
{roomInvites.length > 0 && (
<Box direction="Column" gap="200"> {filter === InviteFilter.Spam && (
<Text size="H4">Rooms</Text> <SpamInvites
<Box direction="Column" gap="100"> invites={spamInvites}
{roomInvites.map((roomId) => renderInvite(roomId, false, navigateRoom))} compact={compact}
</Box> hour24Clock={hour24Clock}
</Box> dateFormatString={dateFormatString}
handleNavigate={handleNavigate}
/>
)} )}
{directInvites.length === 0 &&
spaceInvites.length === 0 &&
roomInvites.length === 0 && (
<div>
<SequenceCard
variant="SurfaceVariant"
style={{ padding: config.space.S400 }}
direction="Column"
gap="200"
>
<Text>No Pending Invitations</Text>
<Text size="T200">
You don&apos;t have any new pending invitations to display yet.
</Text>
</SequenceCard>
</div>
)}
</Box> </Box>
</PageContentCenter> </PageContentCenter>
</PageContent> </PageContent>

View File

@@ -205,6 +205,8 @@ type RoomNotificationsGroupProps = {
hideActivity: boolean; hideActivity: boolean;
onOpen: (roomId: string, eventId: string) => void; onOpen: (roomId: string, eventId: string) => void;
legacyUsernameColor?: boolean; legacyUsernameColor?: boolean;
hour24Clock: boolean;
dateFormatString: string;
}; };
function RoomNotificationsGroupComp({ function RoomNotificationsGroupComp({
room, room,
@@ -214,6 +216,8 @@ function RoomNotificationsGroupComp({
hideActivity, hideActivity,
onOpen, onOpen,
legacyUsernameColor, legacyUsernameColor,
hour24Clock,
dateFormatString,
}: RoomNotificationsGroupProps) { }: RoomNotificationsGroupProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
@@ -496,7 +500,11 @@ function RoomNotificationsGroupComp({
</Username> </Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />} {tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box> </Box>
<Time ts={event.origin_server_ts} /> <Time
ts={event.origin_server_ts}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</Box> </Box>
<Box shrink="No" gap="200" alignItems="Center"> <Box shrink="No" gap="200" alignItems="Center">
<Chip <Chip
@@ -549,6 +557,8 @@ export function Notifications() {
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
@@ -713,6 +723,8 @@ export function Notifications() {
legacyUsernameColor={ legacyUsernameColor={
legacyUsernameColor || mDirects.has(groupRoom.roomId) legacyUsernameColor || mDirects.has(groupRoom.roomId)
} }
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/> />
</VirtualTile> </VirtualTile>
); );

View File

@@ -744,13 +744,14 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
const targetSpaceId = target.getAttribute('data-id'); const targetSpaceId = target.getAttribute('data-id');
if (!targetSpaceId) return; if (!targetSpaceId) return;
const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId));
if (screenSize === ScreenSize.Mobile) { if (screenSize === ScreenSize.Mobile) {
navigate(getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId))); navigate(spacePath);
return; return;
} }
const activePath = navToActivePath.get(targetSpaceId); const activePath = navToActivePath.get(targetSpaceId);
if (activePath) { if (activePath && activePath.pathname.startsWith(spacePath)) {
navigate(joinPathComponent(activePath)); navigate(joinPathComponent(activePath));
return; return;
} }

View File

@@ -1,21 +1,24 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom'; import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useSpace } from '../../../hooks/useSpace'; import { useSpace } from '../../../hooks/useSpace';
import { getAllParents } from '../../../utils/room'; import { getAllParents, getSpaceChildren } from '../../../utils/room';
import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList'; import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
import { mDirectAtom } from '../../../state/mDirectList'; import { mDirectAtom } from '../../../state/mDirectList';
import { settingsAtom } from '../../../state/settings';
import { useSetting } from '../../../state/hooks/settings';
export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const space = useSpace(); const space = useSpace();
const roomToParents = useAtomValue(roomToParentsAtom); const [developerTools] = useSetting(settingsAtom, 'developerTools');
const [roomToParents, setRoomToParents] = useAtom(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const allRooms = useAtomValue(allRoomsAtom); const allRooms = useAtomValue(allRoomsAtom);
@@ -24,12 +27,36 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
const roomId = useSelectedRoom(); const roomId = useSelectedRoom();
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
if ( if (!room || !allRooms.includes(room.roomId)) {
!room || // room is not joined
room.isSpaceRoom() || return (
!allRooms.includes(room.roomId) || <JoinBeforeNavigate
!getAllParents(roomToParents, room.roomId).has(space.roomId) roomIdOrAlias={roomIdOrAlias!}
) { eventId={eventId}
viaServers={viaServers}
/>
);
}
if (developerTools && room.isSpaceRoom() && room.roomId === space.roomId) {
// allow to view space timeline
return (
<RoomProvider key={room.roomId} value={room}>
<IsDirectRoomProvider value={mDirects.has(room.roomId)}>{children}</IsDirectRoomProvider>
</RoomProvider>
);
}
if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) {
if (getSpaceChildren(space).includes(room.roomId)) {
// fill missing roomToParent mapping
setRoomToParents({
type: 'PUT',
parent: space.roomId,
children: [room.roomId],
});
}
return ( return (
<JoinBeforeNavigate <JoinBeforeNavigate
roomIdOrAlias={roomIdOrAlias!} roomIdOrAlias={roomIdOrAlias!}

View File

@@ -75,6 +75,7 @@ import {
useRoomsNotificationPreferencesContext, useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences'; } from '../../../hooks/useRoomsNotificationPreferences';
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
type SpaceMenuProps = { type SpaceMenuProps = {
room: Room; room: Room;
@@ -83,11 +84,13 @@ type SpaceMenuProps = {
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => { const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
const openSpaceSettings = useOpenSpaceSettings(); const openSpaceSettings = useOpenSpaceSettings();
const { navigateRoom } = useRoomNavigate();
const allChild = useSpaceChildren( const allChild = useSpaceChildren(
allRoomsAtom, allRoomsAtom,
@@ -118,6 +121,11 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
requestClose(); requestClose();
}; };
const handleOpenTimeline = () => {
navigateRoom(room.roomId);
requestClose();
};
return ( return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}> <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
@@ -168,6 +176,18 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
Space Settings Space Settings
</Text> </Text>
</MenuItem> </MenuItem>
{developerTools && (
<MenuItem
onClick={handleOpenTimeline}
size="300"
after={<Icon size="100" src={Icons.Terminal} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Event Timeline
</Text>
</MenuItem>
)}
</Box> </Box>
<Line variant="Surface" size="300" /> <Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>

View File

@@ -0,0 +1,15 @@
import * as badWords from 'badwords-list';
import { sanitizeForRegex } from '../utils/regex';
const additionalBadWords: string[] = ['torture', 't0rture'];
const fullBadWordList = additionalBadWords.concat(
badWords.array.filter((word) => !additionalBadWords.includes(word))
);
export const BAD_WORDS_REGEX = new RegExp(
`(\\b|_)(${fullBadWordList.map((word) => sanitizeForRegex(word)).join('|')})(\\b|_)`,
'g'
);
export const testBadWords = (str: string): boolean => !!str.toLowerCase().match(BAD_WORDS_REGEX);

View File

@@ -1,5 +1,12 @@
/* eslint-disable jsx-a11y/alt-text */ /* eslint-disable jsx-a11y/alt-text */
import React, { ComponentPropsWithoutRef, ReactEventHandler, Suspense, lazy } from 'react'; import React, {
ComponentPropsWithoutRef,
ReactEventHandler,
Suspense,
lazy,
useMemo,
useState,
} from 'react';
import { import {
Element, Element,
Text as DOMText, Text as DOMText,
@@ -9,10 +16,11 @@ import {
} from 'html-react-parser'; } from 'html-react-parser';
import { MatrixClient } from 'matrix-js-sdk'; import { MatrixClient } from 'matrix-js-sdk';
import classNames from 'classnames'; import classNames from 'classnames';
import { Scroll, Text } from 'folds'; import { Box, Chip, config, Header, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds';
import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs'; import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs';
import Linkify from 'linkify-react'; import Linkify from 'linkify-react';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import { ChildNode } from 'domhandler';
import * as css from '../styles/CustomHtml.css'; import * as css from '../styles/CustomHtml.css';
import { import {
getMxIdLocalPart, getMxIdLocalPart,
@@ -31,7 +39,8 @@ import {
testMatrixTo, testMatrixTo,
} from './matrix-to'; } from './matrix-to';
import { onEnterOrSpace } from '../utils/keyboard'; import { onEnterOrSpace } from '../utils/keyboard';
import { tryDecodeURIComponent } from '../utils/dom'; import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
const ReactPrism = lazy(() => import('./react-prism/ReactPrism')); const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
@@ -195,6 +204,111 @@ export const highlightText = (
); );
}); });
/**
* Recursively extracts and concatenates all text content from an array of ChildNode objects.
*
* @param {ChildNode[]} nodes - An array of ChildNode objects to extract text from.
* @returns {string} The concatenated plain text content of all descendant text nodes.
*/
const extractTextFromChildren = (nodes: ChildNode[]): string => {
let text = '';
nodes.forEach((node) => {
if (node.type === 'text') {
text += node.data;
} else if (node instanceof Element && node.children) {
text += extractTextFromChildren(node.children);
}
});
return text;
};
export function CodeBlock({
children,
opts,
}: {
children: ChildNode[];
opts: HTMLReactParserOptions;
}) {
const code = children[0];
const languageClass =
code instanceof Element && code.name === 'code' ? code.attribs.class : undefined;
const language =
languageClass && languageClass.startsWith('language-')
? languageClass.replace('language-', '')
: languageClass;
const LINE_LIMIT = 14;
const largeCodeBlock = useMemo(
() => extractTextFromChildren(children).split('\n').length > LINE_LIMIT,
[children]
);
const [expanded, setExpand] = useState(false);
const [copied, setCopied] = useTimeoutToggle();
const handleCopy = () => {
copyToClipboard(extractTextFromChildren(children));
setCopied();
};
const toggleExpand = () => {
setExpand(!expanded);
};
return (
<Text size="T300" as="pre" className={css.CodeBlock}>
<Header variant="Surface" size="400" className={css.CodeBlockHeader}>
<Box grow="Yes">
<Text size="L400" truncate>
{language ?? 'Code'}
</Text>
</Box>
<Box shrink="No" gap="200">
<Chip
variant={copied ? 'Success' : 'Surface'}
fill="None"
radii="Pill"
onClick={handleCopy}
before={copied && <Icon size="50" src={Icons.Check} />}
>
<Text size="B300">{copied ? 'Copied' : 'Copy'}</Text>
</Chip>
{largeCodeBlock && (
<IconButton
size="300"
variant="SurfaceVariant"
outlined
radii="300"
onClick={toggleExpand}
aria-label={expanded ? 'Collapse' : 'Expand'}
>
<Icon size="50" src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} />
</IconButton>
)}
</Box>
</Header>
<Scroll
style={{
maxHeight: largeCodeBlock && !expanded ? toRem(300) : undefined,
paddingBottom: largeCodeBlock ? config.space.S400 : undefined,
}}
direction="Both"
variant="SurfaceVariant"
size="300"
visibility="Hover"
hideTrack
>
<div id="code-block-content" className={css.CodeBlockInternal}>
{domToReact(children, opts)}
</div>
</Scroll>
{largeCodeBlock && !expanded && <Box className={css.CodeBlockBottomShadow} />}
</Text>
);
}
export const getReactCustomHtmlParser = ( export const getReactCustomHtmlParser = (
mx: MatrixClient, mx: MatrixClient,
roomId: string | undefined, roomId: string | undefined,
@@ -269,19 +383,7 @@ export const getReactCustomHtmlParser = (
} }
if (name === 'pre') { if (name === 'pre') {
return ( return <CodeBlock opts={opts}>{children}</CodeBlock>;
<Text {...props} as="pre" className={css.CodeBlock}>
<Scroll
direction="Horizontal"
variant="Secondary"
size="300"
visibility="Hover"
hideTrack
>
<div className={css.CodeBlockInternal}>{domToReact(children, opts)}</div>
</Scroll>
</Text>
);
} }
if (name === 'blockquote') { if (name === 'blockquote') {
@@ -331,9 +433,9 @@ export const getReactCustomHtmlParser = (
} }
} else { } else {
return ( return (
<code className={css.Code} {...props}> <Text as="code" size="T300" className={css.Code} {...props}>
{domToReact(children, opts)} {domToReact(children, opts)}
</code> </Text>
); );
} }
} }

View File

@@ -2,18 +2,307 @@ import React, { MutableRefObject, ReactNode, useEffect, useRef } from 'react';
import Prism from 'prismjs'; import Prism from 'prismjs';
import 'prismjs/components/prism-json'; import 'prismjs/components/prism-abap.js';
import 'prismjs/components/prism-javascript'; import 'prismjs/components/prism-abnf.js';
import 'prismjs/components/prism-typescript'; import 'prismjs/components/prism-actionscript.js';
import 'prismjs/components/prism-css'; import 'prismjs/components/prism-ada.js';
import 'prismjs/components/prism-sass'; import 'prismjs/components/prism-agda.js';
import 'prismjs/components/prism-swift'; import 'prismjs/components/prism-al.js';
import 'prismjs/components/prism-rust'; import 'prismjs/components/prism-antlr4.js';
import 'prismjs/components/prism-go'; import 'prismjs/components/prism-apacheconf.js';
import 'prismjs/components/prism-c'; import 'prismjs/components/prism-apex.js';
import 'prismjs/components/prism-cpp'; import 'prismjs/components/prism-apl.js';
import 'prismjs/components/prism-java'; import 'prismjs/components/prism-applescript.js';
import 'prismjs/components/prism-python'; import 'prismjs/components/prism-aql.js';
import 'prismjs/components/prism-arff.js';
import 'prismjs/components/prism-armasm.js';
import 'prismjs/components/prism-arturo.js';
import 'prismjs/components/prism-asciidoc.js';
import 'prismjs/components/prism-asm6502.js';
import 'prismjs/components/prism-asmatmel.js';
import 'prismjs/components/prism-aspnet.js';
import 'prismjs/components/prism-autohotkey.js';
import 'prismjs/components/prism-autoit.js';
import 'prismjs/components/prism-avisynth.js';
import 'prismjs/components/prism-avro-idl.js';
import 'prismjs/components/prism-awk.js';
import 'prismjs/components/prism-bash.js';
import 'prismjs/components/prism-basic.js';
import 'prismjs/components/prism-batch.js';
import 'prismjs/components/prism-bbcode.js';
import 'prismjs/components/prism-bbj.js';
import 'prismjs/components/prism-bicep.js';
import 'prismjs/components/prism-birb.js';
import 'prismjs/components/prism-bnf.js';
import 'prismjs/components/prism-bqn.js';
import 'prismjs/components/prism-brainfuck.js';
import 'prismjs/components/prism-brightscript.js';
import 'prismjs/components/prism-bro.js';
import 'prismjs/components/prism-bsl.js';
import 'prismjs/components/prism-c.js';
import 'prismjs/components/prism-cfscript.js';
import 'prismjs/components/prism-cil.js';
import 'prismjs/components/prism-cilkc.js';
import 'prismjs/components/prism-cilkcpp.js';
import 'prismjs/components/prism-clike.js';
import 'prismjs/components/prism-clojure.js';
import 'prismjs/components/prism-cmake.js';
import 'prismjs/components/prism-cobol.js';
import 'prismjs/components/prism-coffeescript.js';
import 'prismjs/components/prism-concurnas.js';
import 'prismjs/components/prism-cooklang.js';
import 'prismjs/components/prism-coq.js';
import 'prismjs/components/prism-cpp.js';
import 'prismjs/components/prism-csharp.js';
import 'prismjs/components/prism-cshtml.js';
import 'prismjs/components/prism-csp.js';
import 'prismjs/components/prism-css-extras.js';
import 'prismjs/components/prism-css.js';
import 'prismjs/components/prism-csv.js';
import 'prismjs/components/prism-cue.js';
import 'prismjs/components/prism-cypher.js';
import 'prismjs/components/prism-d.js';
import 'prismjs/components/prism-dart.js';
import 'prismjs/components/prism-dataweave.js';
import 'prismjs/components/prism-dax.js';
import 'prismjs/components/prism-dhall.js';
import 'prismjs/components/prism-diff.js';
import 'prismjs/components/prism-dns-zone-file.js';
import 'prismjs/components/prism-docker.js';
import 'prismjs/components/prism-dot.js';
import 'prismjs/components/prism-ebnf.js';
import 'prismjs/components/prism-editorconfig.js';
import 'prismjs/components/prism-eiffel.js';
import 'prismjs/components/prism-ejs.js';
import 'prismjs/components/prism-elixir.js';
import 'prismjs/components/prism-elm.js';
import 'prismjs/components/prism-erb.js';
import 'prismjs/components/prism-erlang.js';
import 'prismjs/components/prism-etlua.js';
import 'prismjs/components/prism-excel-formula.js';
import 'prismjs/components/prism-factor.js';
import 'prismjs/components/prism-false.js';
import 'prismjs/components/prism-firestore-security-rules.js';
import 'prismjs/components/prism-flow.js';
import 'prismjs/components/prism-fortran.js';
import 'prismjs/components/prism-fsharp.js';
import 'prismjs/components/prism-ftl.js';
import 'prismjs/components/prism-gap.js';
import 'prismjs/components/prism-gcode.js';
import 'prismjs/components/prism-gdscript.js';
import 'prismjs/components/prism-gedcom.js';
import 'prismjs/components/prism-gettext.js';
import 'prismjs/components/prism-gherkin.js';
import 'prismjs/components/prism-git.js';
import 'prismjs/components/prism-glsl.js';
import 'prismjs/components/prism-gml.js';
import 'prismjs/components/prism-gn.js';
import 'prismjs/components/prism-go-module.js';
import 'prismjs/components/prism-go.js';
import 'prismjs/components/prism-gradle.js';
import 'prismjs/components/prism-graphql.js';
import 'prismjs/components/prism-groovy.js';
import 'prismjs/components/prism-haml.js';
import 'prismjs/components/prism-handlebars.js';
import 'prismjs/components/prism-haskell.js';
import 'prismjs/components/prism-haxe.js';
import 'prismjs/components/prism-hcl.js';
import 'prismjs/components/prism-hlsl.js';
import 'prismjs/components/prism-hoon.js';
import 'prismjs/components/prism-hpkp.js';
import 'prismjs/components/prism-hsts.js';
import 'prismjs/components/prism-http.js';
import 'prismjs/components/prism-ichigojam.js';
import 'prismjs/components/prism-icon.js';
import 'prismjs/components/prism-icu-message-format.js';
import 'prismjs/components/prism-idris.js';
import 'prismjs/components/prism-iecst.js';
import 'prismjs/components/prism-ignore.js';
import 'prismjs/components/prism-inform7.js';
import 'prismjs/components/prism-ini.js';
import 'prismjs/components/prism-io.js';
import 'prismjs/components/prism-j.js';
import 'prismjs/components/prism-java.js';
import 'prismjs/components/prism-javadoclike.js';
import 'prismjs/components/prism-javascript.js';
import 'prismjs/components/prism-javastacktrace.js';
import 'prismjs/components/prism-jexl.js';
import 'prismjs/components/prism-jolie.js';
import 'prismjs/components/prism-jq.js';
import 'prismjs/components/prism-js-extras.js';
import 'prismjs/components/prism-js-templates.js';
import 'prismjs/components/prism-json.js';
import 'prismjs/components/prism-json5.js';
import 'prismjs/components/prism-jsonp.js';
import 'prismjs/components/prism-jsstacktrace.js';
import 'prismjs/components/prism-jsx.js';
import 'prismjs/components/prism-julia.js';
import 'prismjs/components/prism-keepalived.js';
import 'prismjs/components/prism-keyman.js';
import 'prismjs/components/prism-kotlin.js';
import 'prismjs/components/prism-kumir.js';
import 'prismjs/components/prism-kusto.js';
import 'prismjs/components/prism-latex.js';
import 'prismjs/components/prism-latte.js';
import 'prismjs/components/prism-less.js';
import 'prismjs/components/prism-lilypond.js';
import 'prismjs/components/prism-linker-script.js';
import 'prismjs/components/prism-liquid.js';
import 'prismjs/components/prism-lisp.js';
import 'prismjs/components/prism-livescript.js';
import 'prismjs/components/prism-llvm.js';
import 'prismjs/components/prism-log.js';
import 'prismjs/components/prism-lolcode.js';
import 'prismjs/components/prism-lua.js';
import 'prismjs/components/prism-magma.js';
import 'prismjs/components/prism-makefile.js';
import 'prismjs/components/prism-markdown.js';
import 'prismjs/components/prism-markup-templating.js';
import 'prismjs/components/prism-markup.js';
import 'prismjs/components/prism-mata.js';
import 'prismjs/components/prism-matlab.js';
import 'prismjs/components/prism-maxscript.js';
import 'prismjs/components/prism-mel.js';
import 'prismjs/components/prism-mermaid.js';
import 'prismjs/components/prism-metafont.js';
import 'prismjs/components/prism-mizar.js';
import 'prismjs/components/prism-mongodb.js';
import 'prismjs/components/prism-monkey.js';
import 'prismjs/components/prism-moonscript.js';
import 'prismjs/components/prism-n1ql.js';
import 'prismjs/components/prism-n4js.js';
import 'prismjs/components/prism-nand2tetris-hdl.js';
import 'prismjs/components/prism-naniscript.js';
import 'prismjs/components/prism-nasm.js';
import 'prismjs/components/prism-neon.js';
import 'prismjs/components/prism-nevod.js';
import 'prismjs/components/prism-nginx.js';
import 'prismjs/components/prism-nim.js';
import 'prismjs/components/prism-nix.js';
import 'prismjs/components/prism-nsis.js';
import 'prismjs/components/prism-objectivec.js';
import 'prismjs/components/prism-ocaml.js';
import 'prismjs/components/prism-odin.js';
import 'prismjs/components/prism-opencl.js';
import 'prismjs/components/prism-openqasm.js';
import 'prismjs/components/prism-oz.js';
import 'prismjs/components/prism-parigp.js';
import 'prismjs/components/prism-parser.js';
import 'prismjs/components/prism-pascal.js';
import 'prismjs/components/prism-pascaligo.js';
import 'prismjs/components/prism-pcaxis.js';
import 'prismjs/components/prism-peoplecode.js';
import 'prismjs/components/prism-perl.js';
import 'prismjs/components/prism-php-extras.js';
import 'prismjs/components/prism-php.js';
import 'prismjs/components/prism-phpdoc.js';
import 'prismjs/components/prism-plant-uml.js';
import 'prismjs/components/prism-powerquery.js';
import 'prismjs/components/prism-powershell.js';
import 'prismjs/components/prism-processing.js';
import 'prismjs/components/prism-prolog.js';
import 'prismjs/components/prism-promql.js';
import 'prismjs/components/prism-properties.js';
import 'prismjs/components/prism-protobuf.js';
import 'prismjs/components/prism-psl.js';
import 'prismjs/components/prism-pug.js';
import 'prismjs/components/prism-puppet.js';
import 'prismjs/components/prism-pure.js';
import 'prismjs/components/prism-purebasic.js';
import 'prismjs/components/prism-purescript.js';
import 'prismjs/components/prism-python.js';
import 'prismjs/components/prism-q.js';
import 'prismjs/components/prism-qml.js';
import 'prismjs/components/prism-qore.js';
import 'prismjs/components/prism-qsharp.js';
import 'prismjs/components/prism-r.js';
import 'prismjs/components/prism-reason.js';
import 'prismjs/components/prism-regex.js';
import 'prismjs/components/prism-rego.js';
import 'prismjs/components/prism-renpy.js';
import 'prismjs/components/prism-rescript.js';
import 'prismjs/components/prism-rest.js';
import 'prismjs/components/prism-rip.js';
import 'prismjs/components/prism-roboconf.js';
import 'prismjs/components/prism-robotframework.js';
import 'prismjs/components/prism-ruby.js';
import 'prismjs/components/prism-rust.js';
import 'prismjs/components/prism-sas.js';
import 'prismjs/components/prism-sass.js';
import 'prismjs/components/prism-scala.js';
import 'prismjs/components/prism-scheme.js';
import 'prismjs/components/prism-scss.js';
import 'prismjs/components/prism-shell-session.js';
import 'prismjs/components/prism-smali.js';
import 'prismjs/components/prism-smalltalk.js';
import 'prismjs/components/prism-smarty.js';
import 'prismjs/components/prism-sml.js';
import 'prismjs/components/prism-solidity.js';
import 'prismjs/components/prism-solution-file.js';
import 'prismjs/components/prism-soy.js';
import 'prismjs/components/prism-splunk-spl.js';
import 'prismjs/components/prism-sqf.js';
import 'prismjs/components/prism-sql.js';
import 'prismjs/components/prism-squirrel.js';
import 'prismjs/components/prism-stan.js';
import 'prismjs/components/prism-stata.js';
import 'prismjs/components/prism-stylus.js';
import 'prismjs/components/prism-supercollider.js';
import 'prismjs/components/prism-swift.js';
import 'prismjs/components/prism-systemd.js';
import 'prismjs/components/prism-t4-templating.js';
import 'prismjs/components/prism-t4-vb.js';
import 'prismjs/components/prism-tap.js';
import 'prismjs/components/prism-tcl.js';
import 'prismjs/components/prism-textile.js';
import 'prismjs/components/prism-toml.js';
import 'prismjs/components/prism-tremor.js';
import 'prismjs/components/prism-tsx.js';
import 'prismjs/components/prism-tt2.js';
import 'prismjs/components/prism-turtle.js';
import 'prismjs/components/prism-twig.js';
import 'prismjs/components/prism-typescript.js';
import 'prismjs/components/prism-typoscript.js';
import 'prismjs/components/prism-unrealscript.js';
import 'prismjs/components/prism-uorazor.js';
import 'prismjs/components/prism-uri.js';
import 'prismjs/components/prism-v.js';
import 'prismjs/components/prism-vala.js';
import 'prismjs/components/prism-vbnet.js';
import 'prismjs/components/prism-velocity.js';
import 'prismjs/components/prism-verilog.js';
import 'prismjs/components/prism-vhdl.js';
import 'prismjs/components/prism-vim.js';
import 'prismjs/components/prism-visual-basic.js';
import 'prismjs/components/prism-warpscript.js';
import 'prismjs/components/prism-wasm.js';
import 'prismjs/components/prism-web-idl.js';
import 'prismjs/components/prism-wgsl.js';
import 'prismjs/components/prism-wiki.js';
import 'prismjs/components/prism-wolfram.js';
import 'prismjs/components/prism-wren.js';
import 'prismjs/components/prism-xeora.js';
import 'prismjs/components/prism-xml-doc.js';
import 'prismjs/components/prism-xojo.js';
import 'prismjs/components/prism-xquery.js';
import 'prismjs/components/prism-yaml.js';
import 'prismjs/components/prism-yang.js';
import 'prismjs/components/prism-zig.js';
import 'prismjs/components/prism-arduino.js';
// Broken:
//
// import 'prismjs/components/prism-bison.js';
// import 'prismjs/components/prism-chaiscript.js';
// import 'prismjs/components/prism-core.js';
// import 'prismjs/components/prism-crystal.js';
// import 'prismjs/components/prism-django.js';
// import 'prismjs/components/prism-javadoc.js';
// import 'prismjs/components/prism-jsdoc.js';
// import 'prismjs/components/prism-plsql.js';
// import 'prismjs/components/prism-racket.js';
// import 'prismjs/components/prism-sparql.js';
// import 'prismjs/components/prism-t4-cs.js';
import './ReactPrism.css'; import './ReactPrism.css';
// using classNames .prism-dark .prism-light from ReactPrism.css // using classNames .prism-dark .prism-light from ReactPrism.css

View File

@@ -1,5 +1,5 @@
import { atom } from 'jotai'; import { atom } from 'jotai';
import { ImportRoomKeyProgressData } from 'matrix-js-sdk/lib/crypto-api'; import { ImportRoomKeyProgressData, ImportRoomKeyStage } from 'matrix-js-sdk/lib/crypto-api';
export enum BackupProgressStatus { export enum BackupProgressStatus {
Idle, Idle,
@@ -39,22 +39,16 @@ export const backupRestoreProgressAtom = atom<
>( >(
(get) => get(baseBackupRestoreProgressAtom), (get) => get(baseBackupRestoreProgressAtom),
(get, set, progress) => { (get, set, progress) => {
if (progress.stage === 'fetch') { if (progress.stage === ImportRoomKeyStage.Fetch) {
set(baseBackupRestoreProgressAtom, { set(baseBackupRestoreProgressAtom, {
status: BackupProgressStatus.Fetching, status: BackupProgressStatus.Fetching,
}); });
return; return;
} }
if (progress.stage === 'load_keys') { if (progress.stage === ImportRoomKeyStage.LoadKeys) {
const { total, successes, failures } = progress; const { total, successes, failures } = progress;
if (total === undefined || successes === undefined || failures === undefined) {
// Setting to idle as https://github.com/matrix-org/matrix-js-sdk/issues/4703
set(baseBackupRestoreProgressAtom, {
status: BackupProgressStatus.Idle,
});
return;
}
const downloaded = successes + failures; const downloaded = successes + failures;
if (downloaded === total) { if (downloaded === total) {
set(baseBackupRestoreProgressAtom, { set(baseBackupRestoreProgressAtom, {

View File

@@ -9,6 +9,8 @@ import {
const NAV_TO_ACTIVE_PATH = 'navToActivePath'; const NAV_TO_ACTIVE_PATH = 'navToActivePath';
const getStoreKey = (userId: string): string => `${NAV_TO_ACTIVE_PATH}${userId}`;
type NavToActivePath = Map<string, Path>; type NavToActivePath = Map<string, Path>;
type NavToActivePathAction = type NavToActivePathAction =
@@ -25,7 +27,7 @@ type NavToActivePathAction =
export type NavToActivePathAtom = WritableAtom<NavToActivePath, [NavToActivePathAction], undefined>; export type NavToActivePathAtom = WritableAtom<NavToActivePath, [NavToActivePathAction], undefined>;
export const makeNavToActivePathAtom = (userId: string): NavToActivePathAtom => { export const makeNavToActivePathAtom = (userId: string): NavToActivePathAtom => {
const storeKey = `${NAV_TO_ACTIVE_PATH}${userId}`; const storeKey = getStoreKey(userId);
const baseNavToActivePathAtom = atomWithLocalStorage<NavToActivePath>( const baseNavToActivePathAtom = atomWithLocalStorage<NavToActivePath>(
storeKey, storeKey,
@@ -64,3 +66,7 @@ export const makeNavToActivePathAtom = (userId: string): NavToActivePathAtom =>
return navToActivePathAtom; return navToActivePathAtom;
}; };
export const clearNavToActivePathStore = (userId: string) => {
localStorage.removeItem(getStoreKey(userId));
};

View File

@@ -1,6 +1,7 @@
import { atom } from 'jotai'; import { atom } from 'jotai';
const STORAGE_KEY = 'settings'; const STORAGE_KEY = 'settings';
export type DateFormat = 'D MMM YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY/MM/DD' | '';
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500'; export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
export enum MessageLayout { export enum MessageLayout {
Modern = 0, Modern = 0,
@@ -35,6 +36,9 @@ export interface Settings {
showNotifications: boolean; showNotifications: boolean;
isNotificationSounds: boolean; isNotificationSounds: boolean;
hour24Clock: boolean;
dateFormatString: string;
developerTools: boolean; developerTools: boolean;
} }
@@ -65,6 +69,9 @@ const defaultSettings: Settings = {
showNotifications: true, showNotifications: true,
isNotificationSounds: true, isNotificationSounds: true,
hour24Clock: false,
dateFormatString: 'D MMM YYYY',
developerTools: false, developerTools: false,
}; };

View File

@@ -41,16 +41,19 @@ export const BlockQuote = style([
]); ]);
const BaseCode = style({ const BaseCode = style({
fontFamily: 'monospace', color: color.SurfaceVariant.OnContainer,
color: color.Secondary.OnContainer, background: color.SurfaceVariant.Container,
background: color.Secondary.Container, border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
border: `${config.borderWidth.B300} solid ${color.Secondary.ContainerLine}`,
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
}); });
const CodeFont = style({
fontFamily: 'monospace',
});
export const Code = style([ export const Code = style([
DefaultReset, DefaultReset,
BaseCode, BaseCode,
CodeFont,
{ {
padding: `0 ${config.space.S100}`, padding: `0 ${config.space.S100}`,
}, },
@@ -85,10 +88,32 @@ export const CodeBlock = style([
MarginSpaced, MarginSpaced,
{ {
fontStyle: 'normal', fontStyle: 'normal',
position: 'relative',
overflow: 'hidden',
}, },
]); ]);
export const CodeBlockInternal = style({ export const CodeBlockHeader = style({
padding: `${config.space.S200} ${config.space.S200} 0`, padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
gap: config.space.S200,
});
export const CodeBlockInternal = style([
CodeFont,
{
padding: `${config.space.S200} ${config.space.S200} 0`,
minWidth: toRem(200),
},
]);
export const CodeBlockBottomShadow = style({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
pointerEvents: 'none',
height: config.space.S400,
background: `linear-gradient(to top, #00000022, #00000000)`,
}); });
export const List = style([ export const List = style([

View File

@@ -1,4 +1,4 @@
import { encode } from 'blurhash'; import { encode, isBlurhashValid } from 'blurhash';
export const encodeBlurHash = ( export const encodeBlurHash = (
img: HTMLImageElement | HTMLVideoElement, img: HTMLImageElement | HTMLVideoElement,
@@ -17,3 +17,13 @@ export const encodeBlurHash = (
const data = context.getImageData(0, 0, canvas.width, canvas.height); const data = context.getImageData(0, 0, canvas.width, canvas.height);
return encode(data.data, data.width, data.height, 4, 4); return encode(data.data, data.width, data.height, 4, 4);
}; };
export const validBlurHash = (hash?: string): string | undefined => {
if (typeof hash === 'string') {
const validity = isBlurhashValid(hash);
return validity.result ? hash : undefined;
}
return undefined;
};

View File

@@ -125,3 +125,9 @@ export const suffixRename = (name: string, validator: (newName: string) => boole
}; };
export const replaceSpaceWithDash = (str: string): string => str.replace(/ /g, '-'); export const replaceSpaceWithDash = (str: string): string => str.replace(/ /g, '-');
export const splitWithSpace = (content: string): string[] => {
const trimmedContent = content.trim();
if (trimmedContent === '') return [];
return trimmedContent.split(' ');
};

View File

@@ -13,14 +13,19 @@ import {
UploadProgress, UploadProgress,
UploadResponse, UploadResponse,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import to from 'await-to-js';
import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
import { AccountDataEvent } from '../../types/matrix/accountData'; import { AccountDataEvent } from '../../types/matrix/accountData';
import { getStateEvent } from './room'; import { getStateEvent } from './room';
import { StateEvent } from '../../types/matrix/room'; import { StateEvent } from '../../types/matrix/room';
export const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@!$+#])(.+):(\S+)$/); const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/;
export const validMxId = (id: string): boolean => !!matchMxId(id); export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName);
const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@$+#])(.+):(\S+)$/);
const validMxId = (id: string): boolean => !!matchMxId(id);
export const getMxIdServer = (userId: string): string | undefined => matchMxId(userId)?.[3]; export const getMxIdServer = (userId: string): string | undefined => matchMxId(userId)?.[3];
@@ -28,7 +33,7 @@ export const getMxIdLocalPart = (userId: string): string | undefined => matchMxI
export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@'); export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@');
export const isRoomId = (id: string): boolean => validMxId(id) && id.startsWith('!'); export const isRoomId = (id: string): boolean => id.startsWith('!');
export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#'); export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#');
@@ -45,7 +50,11 @@ export const getCanonicalAliasOrRoomId = (mx: MatrixClient, roomId: string): str
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
if (!room) return roomId; if (!room) return roomId;
if (getStateEvent(room, StateEvent.RoomTombstone) !== undefined) return roomId; if (getStateEvent(room, StateEvent.RoomTombstone) !== undefined) return roomId;
return room.getCanonicalAlias() || roomId; const alias = room.getCanonicalAlias();
if (alias && getCanonicalAliasRoomId(mx, alias) === roomId) {
return alias;
}
return roomId;
}; };
export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => { export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => {
@@ -292,3 +301,46 @@ export const downloadEncryptedMedia = async (
return decryptedContent; return decryptedContent;
}; };
export const rateLimitedActions = async <T, R = void>(
data: T[],
callback: (item: T, index: number) => Promise<R>,
maxRetryCount?: number
) => {
let retryCount = 0;
let actionInterval = 0;
const sleepForMs = (ms: number) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
const performAction = async (dataItem: T, index: number) => {
const [err] = await to<R, MatrixError>(callback(dataItem, index));
if (err?.httpStatus === 429) {
if (retryCount === maxRetryCount) {
return;
}
const waitMS = err.getRetryAfterMs() ?? 3000;
actionInterval = waitMS * 1.5;
await sleepForMs(waitMS);
retryCount += 1;
await performAction(dataItem, index);
}
};
for (let i = 0; i < data.length; i += 1) {
const dataItem = data[i];
retryCount = 0;
// eslint-disable-next-line no-await-in-loop
await performAction(dataItem, i);
if (actionInterval > 0) {
// eslint-disable-next-line no-await-in-loop
await sleepForMs(actionInterval);
}
}
};

View File

@@ -5,6 +5,7 @@ import {
EventTimelineSet, EventTimelineSet,
EventType, EventType,
IMentions, IMentions,
IPowerLevelsContent,
IPushRule, IPushRule,
IPushRules, IPushRules,
JoinRule, JoinRule,
@@ -19,6 +20,7 @@ import {
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { AccountDataEvent } from '../../types/matrix/accountData'; import { AccountDataEvent } from '../../types/matrix/accountData';
import { import {
Membership,
MessageEvent, MessageEvent,
NotificationType, NotificationType,
RoomToParents, RoomToParents,
@@ -171,7 +173,7 @@ export const getNotificationType = (mx: MatrixClient, roomId: string): Notificat
} }
if (!roomPushRule) { if (!roomPushRule) {
const overrideRules = mx.getAccountData('m.push_rules')?.getContent<IPushRules>() const overrideRules = mx.getAccountData(EventType.PushRules)?.getContent<IPushRules>()
?.global?.override; ?.global?.override;
if (!overrideRules) return NotificationType.Default; if (!overrideRules) return NotificationType.Default;
@@ -292,9 +294,14 @@ export const getDirectRoomAvatarUrl = (
useAuthentication = false useAuthentication = false
): string | undefined => { ): string | undefined => {
const mxcUrl = room.getAvatarFallbackMember()?.getMxcAvatarUrl(); const mxcUrl = room.getAvatarFallbackMember()?.getMxcAvatarUrl();
return mxcUrl
? mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined if (!mxcUrl) {
: undefined; return getRoomAvatarUrl(mx, room, size, useAuthentication);
}
return (
mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined
);
}; };
export const trimReplyFromBody = (body: string): string => { export const trimReplyFromBody = (body: string): string => {
@@ -443,3 +450,72 @@ export const getMentionContent = (userIds: string[], room: boolean): IMentions =
return mMentions; return mMentions;
}; };
export const getCommonRooms = (
mx: MatrixClient,
rooms: string[],
otherUserId: string
): string[] => {
const commonRooms: string[] = [];
rooms.forEach((roomId) => {
const room = mx.getRoom(roomId);
if (!room || room.getMyMembership() !== Membership.Join) return;
const common = room.hasMembershipState(otherUserId, Membership.Join);
if (common) {
commonRooms.push(roomId);
}
});
return commonRooms;
};
export const bannedInRooms = (mx: MatrixClient, rooms: string[], otherUserId: string): boolean =>
rooms.some((roomId) => {
const room = mx.getRoom(roomId);
if (!room || room.getMyMembership() !== Membership.Join) return false;
const banned = room.hasMembershipState(otherUserId, Membership.Ban);
return banned;
});
export const guessPerfectParent = (
mx: MatrixClient,
roomId: string,
parents: string[]
): string | undefined => {
if (parents.length === 1) {
return parents[0];
}
const getSpecialUsers = (rId: string): string[] => {
const r = mx.getRoom(rId);
const powerLevels =
r && getStateEvent(r, StateEvent.RoomPowerLevels)?.getContent<IPowerLevelsContent>();
const { users_default: usersDefault, users } = powerLevels ?? {};
if (typeof users !== 'object') return [];
const defaultPower = typeof usersDefault === 'number' ? usersDefault : 0;
return Object.keys(users).filter((userId) => users[userId] > defaultPower);
};
let perfectParent: string | undefined;
let score = 0;
const roomSpecialUsers = getSpecialUsers(roomId);
parents.forEach((parentId) => {
const parentSpecialUsers = getSpecialUsers(parentId);
const matchedUsersCount = parentSpecialUsers.filter((userId) =>
roomSpecialUsers.includes(userId)
).length;
if (matchedUsersCount > score) {
score = matchedUsersCount;
perfectParent = parentId;
}
});
return perfectParent;
};

View File

@@ -9,12 +9,29 @@ export const today = (ts: number): boolean => dayjs(ts).isToday();
export const yesterday = (ts: number): boolean => dayjs(ts).isYesterday(); export const yesterday = (ts: number): boolean => dayjs(ts).isYesterday();
export const timeHourMinute = (ts: number): string => dayjs(ts).format('hh:mm A'); export const timeHour = (ts: number, hour24Clock: boolean): string =>
dayjs(ts).format(hour24Clock ? 'HH' : 'hh');
export const timeMinute = (ts: number): string => dayjs(ts).format('mm');
export const timeAmPm = (ts: number): string => dayjs(ts).format('A');
export const timeDay = (ts: number): string => dayjs(ts).format('D');
export const timeMon = (ts: number): string => dayjs(ts).format('MMM');
export const timeMonth = (ts: number): string => dayjs(ts).format('MMMM');
export const timeYear = (ts: number): string => dayjs(ts).format('YYYY');
export const timeDayMonYear = (ts: number): string => dayjs(ts).format('D MMM YYYY'); export const timeHourMinute = (ts: number, hour24Clock: boolean): string =>
dayjs(ts).format(hour24Clock ? 'HH:mm' : 'hh:mm A');
export const timeDayMonYear = (ts: number, dateFormatString: string): string =>
dayjs(ts).format(dateFormatString);
export const timeDayMonthYear = (ts: number): string => dayjs(ts).format('D MMMM YYYY'); export const timeDayMonthYear = (ts: number): string => dayjs(ts).format('D MMMM YYYY');
export const daysInMonth = (month: number, year: number): number =>
dayjs(`${year}-${month}-1`).daysInMonth();
export const dateFor = (year: number, month: number, day: number): number =>
dayjs(`${year}-${month}-${day}`).valueOf();
export const inSameDay = (ts1: number, ts2: number): boolean => { export const inSameDay = (ts1: number, ts2: number): boolean => {
const dt1 = new Date(ts1); const dt1 = new Date(ts1);
const dt2 = new Date(ts2); const dt2 = new Date(ts2);
@@ -33,3 +50,37 @@ export const minuteDifference = (ts1: number, ts2: number): number => {
diff /= 60; diff /= 60;
return Math.abs(Math.round(diff)); return Math.abs(Math.round(diff));
}; };
export const hour24to12 = (hour24: number): number => {
const h = hour24 % 12;
if (h === 0) return 12;
return h;
};
export const hour12to24 = (hour: number, pm: boolean): number => {
if (hour === 12) {
return pm ? 12 : 0;
}
return pm ? hour + 12 : hour;
};
export const secondsToMs = (seconds: number) => seconds * 1000;
export const minutesToMs = (minutes: number) => minutes * secondsToMs(60);
export const hoursToMs = (hour: number) => hour * minutesToMs(60);
export const daysToMs = (days: number) => days * hoursToMs(24);
export const getToday = () => {
const nowTs = Date.now();
const date = dayjs(nowTs);
return dateFor(date.year(), date.month() + 1, date.date());
};
export const getYesterday = () => {
const nowTs = Date.now() - daysToMs(1);
const date = dayjs(nowTs);
return dateFor(date.year(), date.month() + 1, date.date());
};

View File

@@ -12,7 +12,7 @@ function addRoomToMDirect(mx, roomId, userId) {
const mDirectsEvent = mx.getAccountData('m.direct'); const mDirectsEvent = mx.getAccountData('m.direct');
let userIdToRoomIds = {}; let userIdToRoomIds = {};
if (typeof mDirectsEvent !== 'undefined') userIdToRoomIds = mDirectsEvent.getContent(); if (typeof mDirectsEvent !== 'undefined') userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
// remove it from the lists of any others users // remove it from the lists of any others users
// (it can only be a DM room for one person) // (it can only be a DM room for one person)
@@ -93,11 +93,8 @@ function convertToRoom(mx, roomId) {
* @param {string[]} via * @param {string[]} via
*/ */
async function join(mx, roomIdOrAlias, isDM = false, via = undefined) { async function join(mx, roomIdOrAlias, isDM = false, via = undefined) {
const roomIdParts = roomIdOrAlias.split(':');
const viaServers = via || [roomIdParts[1]];
try { try {
const resultRoom = await mx.joinRoom(roomIdOrAlias, { viaServers }); const resultRoom = await mx.joinRoom(roomIdOrAlias, { viaServers: via });
if (isDM) { if (isDM) {
const targetUserId = guessDMRoomTargetId(mx.getRoom(resultRoom.roomId), mx.getUserId()); const targetUserId = guessDMRoomTargetId(mx.getRoom(resultRoom.roomId), mx.getUserId());

View File

@@ -1,11 +1,7 @@
import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from 'matrix-js-sdk'; import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from 'matrix-js-sdk';
import { logger } from 'matrix-js-sdk/lib/logger';
import { cryptoCallbacks } from './state/secretStorageKeys'; import { cryptoCallbacks } from './state/secretStorageKeys';
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
if (import.meta.env.PROD) {
logger.disableAll();
}
type Session = { type Session = {
baseUrl: string; baseUrl: string;
@@ -38,7 +34,6 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
await indexedDBStore.startup(); await indexedDBStore.startup();
await mx.initRustCrypto(); await mx.initRustCrypto();
mx.setGlobalErrorOnUnknownDevices(false);
mx.setMaxListeners(50); mx.setMaxListeners(50);
return mx; return mx;
@@ -52,6 +47,7 @@ export const startClient = async (mx: MatrixClient) => {
export const clearCacheAndReload = async (mx: MatrixClient) => { export const clearCacheAndReload = async (mx: MatrixClient) => {
mx.stopClient(); mx.stopClient();
clearNavToActivePathStore(mx.getSafeUserId());
await mx.store.deleteAllData(); await mx.store.deleteAllData();
window.location.reload(); window.location.reload();
}; };

View File

@@ -1,5 +1,5 @@
const cons = { const cons = {
version: '4.6.0', version: '4.8.1',
secretKey: { secretKey: {
ACCESS_TOKEN: 'cinny_access_token', ACCESS_TOKEN: 'cinny_access_token',
DEVICE_ID: 'cinny_device_id', DEVICE_ID: 'cinny_device_id',

View File

@@ -7,6 +7,8 @@ import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfil
import inject from '@rollup/plugin-inject'; import inject from '@rollup/plugin-inject';
import topLevelAwait from 'vite-plugin-top-level-await'; import topLevelAwait from 'vite-plugin-top-level-await';
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from 'vite-plugin-pwa';
import fs from 'fs';
import path from 'path';
import buildConfig from './build.config'; import buildConfig from './build.config';
const copyFiles = { const copyFiles = {
@@ -39,6 +41,32 @@ const copyFiles = {
], ],
}; };
function serverMatrixSdkCryptoWasm(wasmFilePath) {
return {
name: 'vite-plugin-serve-matrix-sdk-crypto-wasm',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url === wasmFilePath) {
const resolvedPath = path.join(path.resolve(), "/node_modules/@matrix-org/matrix-sdk-crypto-wasm/pkg/matrix_sdk_crypto_wasm_bg.wasm");
if (fs.existsSync(resolvedPath)) {
res.setHeader('Content-Type', 'application/wasm');
res.setHeader('Cache-Control', 'no-cache');
const fileStream = fs.createReadStream(resolvedPath);
fileStream.pipe(res);
} else {
res.writeHead(404);
res.end('File not found');
}
} else {
next();
}
});
},
};
}
export default defineConfig({ export default defineConfig({
appType: 'spa', appType: 'spa',
publicDir: false, publicDir: false,
@@ -46,8 +74,13 @@ export default defineConfig({
server: { server: {
port: 8080, port: 8080,
host: true, host: true,
fs: {
// Allow serving files from one level up to the project root
allow: ['..'],
},
}, },
plugins: [ plugins: [
serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'),
topLevelAwait({ topLevelAwait({
// The export name of top-level await promise for each chunk module // The export name of top-level await promise for each chunk module
promiseExportName: '__tla', promiseExportName: '__tla',