Compare commits

..

28 Commits

Author SHA1 Message Date
Ajay Bura
03c285b461 image not loading on mobile after lock/unlock 2026-02-15 13:27:57 +05:30
Andrew Murphy
9d49418a1f Set m.fully_read marker when marking rooms as read (#2587)
Previously markAsRead() only sent m.read receipts via sendReadReceipt().
This meant the read position was not persisted across page refreshes,
especially noticeable in bridged rooms.

Now uses setRoomReadMarkers() which sets both:
- m.fully_read marker (persistent read position)
- m.read receipt

Fixes issue where rooms would still show as unread after refresh.
2026-02-14 17:32:10 +11:00
Ajay Bura
3522751a15 Prevent invalid mxc from getting used (#2609) 2026-02-14 17:12:28 +11:00
Ajay Bura
074c555294 Post session info to service worker instead of asking from sw (#2605)
post session info to service worker instead of asking from sw on each request
2026-02-14 17:11:36 +11:00
renovate[bot]
206a927f30 fix(deps): update dependency react-router-dom to v6.30.3 (#2612)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-14 17:10:43 +11:00
Andrew Murphy
fd37dfe3f9 Fix muted rooms showing unread badges (#2581)
fix: detect muted rooms with empty actions array

The mute detection was checking for `actions[0] === "dont_notify"` but
Cinny sets `actions: []` (empty array) when muting a room, which is
the correct behavior per Matrix spec where empty actions means no
notification.

This caused muted rooms to still show unread badges and contribute to
space badge counts.

Fixes the isMutedRule check to handle both:
- Empty actions array (current Matrix spec)
- "dont_notify" string (deprecated but may exist in older rules)
2026-02-12 21:45:37 +11:00
Gimle Larpes
1ce6ca2b07 Re-add mEvent.getSender() === mx.getUserId() check for deletion of messages (#2607)
* hide "Delete Message" if it is forbidden

* Fix the stuff I broke :/
2026-02-12 21:40:11 +11:00
renovate[bot]
83e5125b37 fix(deps): update dependency folds to v2.5.0 (#2606)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-12 16:56:47 +11:00
Gimle Larpes
ca82aa283a Hide "Delete Message" if it is forbidden (#2602)
hide "Delete Message" if it is forbidden
2026-02-12 16:27:17 +11:00
Zach
8ce33ee6ff Replace envs.net with unredacted.org in config (#2601)
* Replace 'envs.net' with 'unredacted.org' in config

https://envs.net/ is shutting down their Matrix server

* Update defaultHomeserver and reorder servers list

* Remove 'monero.social' from homeserver list
2026-02-12 10:39:58 +11:00
Santhoshkumar044
073a9f5786 Fix room alias mention triggering room-wide notifications (#2562)
* fix: prevent room alias mentions from triggering @room notifications

* fix: Simplify room mention to exact match on @room
2026-01-12 23:21:00 +11:00
dependabot[bot]
655c1c9aff Bump docker/login-action from 3.5.0 to 3.6.0 (#2496)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.5.0 to 3.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3.5.0...v3.6.0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 3.6.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-11-27 16:30:39 +11:00
dependabot[bot]
17d4bceb42 Bump nginx from 1.29.1-alpine to 1.29.3-alpine (#2525)
Bumps nginx from 1.29.1-alpine to 1.29.3-alpine.

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-27 16:16:20 +11:00
willow
0f61f2f328 Fix typo: change "Advance Options" to "Advanced Options" (#2537) 2025-11-27 16:01:40 +11:00
Krishan
c88cb4bca9 Release v4.10.2 (#2528) 2025-11-05 17:49:56 +11:00
Ajay Bura
46c02b89de Update folds to fix broken scrollbar color (#2505) 2025-10-15 17:30:03 +11:00
Ajay Bura
e13d97aa98 Fix member are not sorted correctly after last js-sdk update (#2504) 2025-10-15 17:27:11 +11:00
Krishan
958ae8945d Release v4.10.1 (#2495) 2025-09-29 14:34:38 +10:00
renovate[bot]
f55a3764d5 fix(deps): update dependency matrix-js-sdk to v38 [security] (#2493)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-27 10:00:04 +05:30
dependabot[bot]
3bdcf37bf0 Bump softprops/action-gh-release from 2.3.2 to 2.3.3 (#2478)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.3.2 to 2.3.3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](72f2c25fcb...6cbd405e2c)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-26 22:24:59 +10:00
dependabot[bot]
9d7808ec46 Bump nginx from 1.29.0-alpine to 1.29.1-alpine (#2450)
Bumps nginx from 1.29.0-alpine to 1.29.1-alpine.

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-26 22:21:32 +10:00
dependabot[bot]
20d30903fd Bump docker/setup-buildx-action from 3.10.0 to 3.11.1 (#2373)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.10.0 to 3.11.1.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.10.0...v3.11.1)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 3.11.1
  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-09-26 22:19:28 +10:00
Ginger
b78f6f23b5 Add support to mark videos as spoilers (#2255)
* Add support for MSC4193: Spoilers on Media

* Clarify variable names and wording

* Restore list atom

* Improve spoilered image UX with autoload off

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

* Improve spoiler button tooltip wording, keep reveal button from conflicting with load errors

* Make it possible to mark videos as spoilers

* Allow videos to be marked as spoilers when uploaded

* Apply requested changes

* Show a loading spinner on spoiled media when unblurred

---------

Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
2025-09-25 13:41:35 +10:00
Mari
867a47218a fix: Prevent IME-exiting Enter press from sending message on Safari (#2175)
On most browsers, pressing Enter to end IME composition produces this
sequence of events:
* keydown (keycode 229, key Processing/Unidentified, isComposing true)
* compositionend
* keyup (keycode 13, key Enter, isComposing false)

On Safari, the sequence is different:
* compositionend
* keydown (keycode 229, key Enter, isComposing false)
* keyup (keycode 13, key Enter, isComposing false)

This causes Safari users to mistakenly send their messages when they
press Enter to confirm their choice in an IME.

The workaround is to treat the next keydown with keycode 229 as if it
were part of the IME composition period if it occurs within a short time
of the compositionend event.

Fixes #2103, but needs confirmation from a Safari user.
2025-09-25 09:05:42 +05:30
Ajay Bura
afc251aa7c Add arrow to message bubbles and improve spacing (#2474)
* Add arrow to message bubbles and improve spacing

* make bubble message avatar smaller

* add bubble layout for event content

* adjust bubble arrow

* fix missing return statement for event content

* hide bubble for event content

* add new arrow to bubble message

* fix avatar username relative alignment

* fix types

* fix code block header background

* revert avatar size and make arrow less sharp

* show event messages timestamp to right when bubble is hidden

* fix avatar base css

* move message header outside bubble

* fix event time appears on left in hidden bubles
2025-09-19 21:06:05 +10:00
Ajay Bura
31efbf73b7 Make emojiboard lightweight on low end devices (#2484)
* extract emoji search component

* extract emoji board tabs component

* extract sidebar component

* extract no stickers component

* create emoji/sticker preview atom

* extract component from emoji/sticker item and sidebar buttons

* fix image group icon not loading

* separate emojis and sticker groups logic

* extract layout and emoji group components

* add virtualization in emoji board groups

* fix scroll to alignment
2025-09-18 11:14:08 +10:00
Ajay Bura
31c6d13fdf fix ctrl + k hotkey not working for browser with some extensions (#2481) 2025-09-12 21:52:51 +10:00
Ajay Bura
b3497d9ed6 fix room address checkbox prop (#2480) 2025-09-12 21:51:13 +10:00
63 changed files with 1493 additions and 1040 deletions

View File

@@ -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@72f2c25fcb47643c292f7107632f7a47c1df5cd8 uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
with: with:
files: | files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz cinny-${{ steps.vars.outputs.tag }}.tar.gz
@@ -70,14 +70,14 @@ jobs:
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.6.0 uses: docker/setup-qemu-action@v3.6.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.10.0 uses: docker/setup-buildx-action@v3.11.1
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3.5.0 uses: docker/login-action@v3.6.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to the Container registry - name: Login to the Container registry
uses: docker/login-action@v3.5.0 uses: docker/login-action@v3.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

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

View File

@@ -1,11 +1,10 @@
{ {
"defaultHomeserver": 2, "defaultHomeserver": 1,
"homeserverList": [ "homeserverList": [
"converser.eu", "converser.eu",
"envs.net",
"matrix.org", "matrix.org",
"monero.social",
"mozilla.org", "mozilla.org",
"unredacted.org",
"xmr.se" "xmr.se"
], ],
"allowCustomHomeservers": true, "allowCustomHomeservers": true,
@@ -15,7 +14,7 @@
"spaces": [ "spaces": [
"#cinny-space:matrix.org", "#cinny-space:matrix.org",
"#community:matrix.org", "#community:matrix.org",
"#space:envs.net", "#space:unredacted.org",
"#science-space:matrix.org", "#science-space:matrix.org",
"#libregaming-games:tchncs.de", "#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org" "#mathematics-on:matrix.org"
@@ -28,7 +27,7 @@
"#PrivSec.dev:arcticfoxes.net", "#PrivSec.dev:arcticfoxes.net",
"#disroot:aria-net.org" "#disroot:aria-net.org"
], ],
"servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"] "servers": [ "matrix.org", "mozilla.org", "unredacted.org" ]
}, },
"hashRouter": { "hashRouter": {

View File

@@ -90,6 +90,7 @@
window.global ||= window; window.global ||= window;
</script> </script>
<div id="root"></div> <div id="root"></div>
<div id="portalContainer"></div>
<script type="module" src="./src/index.tsx"></script> <script type="module" src="./src/index.tsx"></script>
</body> </body>
</html> </html>

66
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "cinny", "name": "cinny",
"version": "4.10.0", "version": "4.10.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cinny", "name": "cinny",
"version": "4.10.0", "version": "4.10.2",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
@@ -32,7 +32,7 @@
"emojibase-data": "15.3.2", "emojibase-data": "15.3.2",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"focus-trap-react": "10.0.2", "focus-trap-react": "10.0.2",
"folds": "2.2.0", "folds": "2.5.0",
"html-dom-parser": "4.0.0", "html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0", "html-react-parser": "4.2.0",
"i18next": "23.12.2", "i18next": "23.12.2",
@@ -43,7 +43,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": "37.5.0", "matrix-js-sdk": "38.2.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",
@@ -56,7 +56,7 @@
"react-google-recaptcha": "2.1.0", "react-google-recaptcha": "2.1.0",
"react-i18next": "15.0.0", "react-i18next": "15.0.0",
"react-range": "1.8.14", "react-range": "1.8.14",
"react-router-dom": "6.20.0", "react-router-dom": "6.30.3",
"sanitize-html": "2.12.1", "sanitize-html": "2.12.1",
"slate": "0.112.0", "slate": "0.112.0",
"slate-dom": "0.112.2", "slate-dom": "0.112.2",
@@ -2256,20 +2256,14 @@
} }
}, },
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": { "node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
"version": "14.1.0", "version": "15.3.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-14.1.0.tgz", "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-15.3.0.tgz",
"integrity": "sha512-vcSxHJIr6lP0Fgo8jl0sTHg+OZxZn+skGjiyB62erfgw/R2QqJl0ZVSY8SRcbk9LtHo/ZGld1tnaOyjL2e3cLQ==", "integrity": "sha512-QyxHvncvkl7nf+tnn92PjQ54gMNV8hMSpiukiDgNrqF6IYwgySTlcSdkPYdw8QjZJ0NR6fnVrNzMec0OohM3wA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/@matrix-org/olm": {
"version": "3.2.15",
"resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz",
"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",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3705,9 +3699,10 @@
} }
}, },
"node_modules/@remix-run/router": { "node_modules/@remix-run/router": {
"version": "1.13.0", "version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.0.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
"integrity": "sha512-5dMOnVnefRsl4uRnAdoWjtVTdh8e6aZqgM4puy9nmEADH72ck+uXwzpJLEKE9Q6F8ZljNewLgmTfkxUrBdv4WA==", "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
"license": "MIT",
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@@ -7163,9 +7158,9 @@
} }
}, },
"node_modules/folds": { "node_modules/folds": {
"version": "2.2.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/folds/-/folds-2.2.0.tgz", "resolved": "https://registry.npmjs.org/folds/-/folds-2.5.0.tgz",
"integrity": "sha512-uOfck5eWEIK11rhOAEdSoPIvMXwv+D1Go03pxSAKezWVb+uRoBdmE6LEqiLOF+ac4DGmZRMPvpdDsXCg7EVNIg==", "integrity": "sha512-UJhvXAQ1XnZ9w10KJwSW+frvzzWE/zcF0dH3fDVCD70RFHAxwEi0UkkVS8CaZGxZF2Wvt3qTJyTS5LW3LwwUAw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peerDependencies": { "peerDependencies": {
"@vanilla-extract/css": "1.9.2", "@vanilla-extract/css": "1.9.2",
@@ -8631,14 +8626,13 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/matrix-js-sdk": { "node_modules/matrix-js-sdk": {
"version": "37.5.0", "version": "38.2.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-37.5.0.tgz", "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-38.2.0.tgz",
"integrity": "sha512-5tyuAi5hnKud1UkVq8Z2/3c22hWGELBZzErJPZkE6Hju2uGUfGtrIx6uj6puv0ZjvsUU3X6Qgm8vdReKO1PGig==", "integrity": "sha512-R3jzK8rDGi/3OXOax8jFFyxblCG9KTT5yuXAbvnZCGcpTm8lZ4mHQAn5UydVD8qiyUMNMpaaMd6/k7N+5I/yaQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^14.0.1", "@matrix-org/matrix-sdk-crypto-wasm": "^15.1.0",
"@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",
@@ -8653,7 +8647,7 @@
"uuid": "11" "uuid": "11"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=22.0.0"
} }
}, },
"node_modules/matrix-js-sdk/node_modules/uuid": { "node_modules/matrix-js-sdk/node_modules/uuid": {
@@ -9612,11 +9606,12 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "6.20.0", "version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.20.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
"integrity": "sha512-pVvzsSsgUxxtuNfTHC4IxjATs10UaAtvLGVSA1tbUE4GDaOSU1Esu2xF5nWLz7KPiMuW8BJWuPFdlGYJ7/rW0w==", "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/router": "1.13.0" "@remix-run/router": "1.23.2"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@@ -9626,12 +9621,13 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "6.20.0", "version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.20.0.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
"integrity": "sha512-CbcKjEyiSVpA6UtCHOIYLUYn/UJfwzp55va4yEfpk7JBN3GPqWfHrdLkAvNCcpXr8QoihcDMuk0dzWZxtlB/mQ==", "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
"license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/router": "1.13.0", "@remix-run/router": "1.23.2",
"react-router": "6.20.0" "react-router": "6.30.3"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"

View File

@@ -1,6 +1,6 @@
{ {
"name": "cinny", "name": "cinny",
"version": "4.10.0", "version": "4.10.2",
"description": "Yet another matrix client", "description": "Yet another matrix client",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -43,7 +43,7 @@
"emojibase-data": "15.3.2", "emojibase-data": "15.3.2",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"focus-trap-react": "10.0.2", "focus-trap-react": "10.0.2",
"folds": "2.2.0", "folds": "2.5.0",
"html-dom-parser": "4.0.0", "html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0", "html-react-parser": "4.2.0",
"i18next": "23.12.2", "i18next": "23.12.2",
@@ -54,7 +54,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": "37.5.0", "matrix-js-sdk": "38.2.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",
@@ -67,7 +67,7 @@
"react-google-recaptcha": "2.1.0", "react-google-recaptcha": "2.1.0",
"react-i18next": "15.0.0", "react-i18next": "15.0.0",
"react-range": "1.8.14", "react-range": "1.8.14",
"react-router-dom": "6.20.0", "react-router-dom": "6.30.3",
"sanitize-html": "2.12.1", "sanitize-html": "2.12.1",
"slate": "0.112.0", "slate": "0.112.0",
"slate-dom": "0.112.2", "slate-dom": "0.112.2",

View File

@@ -209,13 +209,11 @@ export function RenderMessageContent({
<MVideo <MVideo
content={getContent()} content={getContent()}
renderAsFile={renderFile} renderAsFile={renderFile}
renderVideoContent={({ body, info, mimeType, url, encInfo }) => ( renderVideoContent={({ body, info, ...props }) => (
<VideoContent <VideoContent
body={body} body={body}
info={info} info={info}
mimeType={mimeType} {...props}
url={url}
encInfo={encInfo}
renderThumbnail={ renderThumbnail={
mediaAutoLoad mediaAutoLoad
? () => ( ? () => (

View File

@@ -88,6 +88,8 @@ export function EmoticonAutocomplete({
{autoCompleteEmoticon.map((emoticon) => { {autoCompleteEmoticon.map((emoticon) => {
const isCustomEmoji = 'url' in emoticon; const isCustomEmoji = 'url' in emoticon;
const key = isCustomEmoji ? emoticon.url : emoticon.unicode; const key = isCustomEmoji ? emoticon.url : emoticon.unicode;
const customEmojiUrl = mxcUrlToHttp(mx, key, useAuthentication);
return ( return (
<MenuItem <MenuItem
key={emoticon.shortcode + key} key={emoticon.shortcode + key}
@@ -98,11 +100,11 @@ export function EmoticonAutocomplete({
} }
onClick={() => handleAutocomplete(key, emoticon.shortcode)} onClick={() => handleAutocomplete(key, emoticon.shortcode)}
before={ before={
isCustomEmoji ? ( isCustomEmoji && customEmojiUrl ? (
<Box <Box
shrink="No" shrink="No"
as="img" as="img"
src={mxcUrlToHttp(mx, key, useAuthentication) || key} src={customEmojiUrl}
alt={emoticon.shortcode} alt={emoticon.shortcode}
style={{ width: toRem(24), height: toRem(24), objectFit: 'contain' }} style={{ width: toRem(24), height: toRem(24), objectFit: 'contain' }}
/> />

View File

@@ -212,9 +212,10 @@ export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): M
if (node.type === BlockType.CodeBlock) return; if (node.type === BlockType.CodeBlock) return;
if (node.type === BlockType.Mention) { if (node.type === BlockType.Mention) {
if (node.id === getCanonicalAliasOrRoomId(mx, roomId)) { if (node.name === '@room') {
mentionData.room = true; mentionData.room = true;
} }
if (isUserId(node.id) && node.id !== mx.getUserId()) { if (isUserId(node.id) && node.id !== mx.getUserId()) {
mentionData.users.add(node.id); mentionData.users.add(node.id);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
import { as, Box, Text } from 'folds';
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import * as css from './styles.css';
export const getDOMGroupId = (id: string): string => `EmojiBoardGroup-${id}`;
export const EmojiGroup = as<
'div',
{
id: string;
label: string;
children: ReactNode;
}
>(({ className, id, label, children, ...props }, ref) => (
<Box
id={getDOMGroupId(id)}
data-group-id={id}
className={classNames(css.EmojiGroup, className)}
direction="Column"
gap="200"
{...props}
ref={ref}
>
<Text id={`EmojiGroup-${id}-label`} as="label" className={css.EmojiGroupLabel} size="O400">
{label}
</Text>
<div aria-labelledby={`EmojiGroup-${id}-label`} className={css.EmojiGroupContent}>
<Box wrap="Wrap" justifyContent="Center">
{children}
</Box>
</div>
</Box>
));

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { Box } from 'folds';
import { MatrixClient } from 'matrix-js-sdk';
import { EmojiItemInfo, EmojiType } from '../types';
import * as css from './styles.css';
import { PackImageReader } from '../../../plugins/custom-emoji';
import { IEmoji } from '../../../plugins/emoji';
import { mxcUrlToHttp } from '../../../utils/matrix';
export const getEmojiItemInfo = (element: Element): EmojiItemInfo | undefined => {
const label = element.getAttribute('title');
const type = element.getAttribute('data-emoji-type') as EmojiType | undefined;
const data = element.getAttribute('data-emoji-data');
const shortcode = element.getAttribute('data-emoji-shortcode');
if (type && data && shortcode && label)
return {
type,
data,
shortcode,
label,
};
return undefined;
};
type EmojiItemProps = {
emoji: IEmoji;
};
export function EmojiItem({ emoji }: EmojiItemProps) {
return (
<Box
as="button"
type="button"
alignItems="Center"
justifyContent="Center"
className={css.EmojiItem}
title={emoji.label}
aria-label={`${emoji.label} emoji`}
data-emoji-type={EmojiType.Emoji}
data-emoji-data={emoji.unicode}
data-emoji-shortcode={emoji.shortcode}
>
{emoji.unicode}
</Box>
);
}
type CustomEmojiItemProps = {
mx: MatrixClient;
useAuthentication?: boolean;
image: PackImageReader;
};
export function CustomEmojiItem({ mx, useAuthentication, image }: CustomEmojiItemProps) {
return (
<Box
as="button"
type="button"
alignItems="Center"
justifyContent="Center"
className={css.EmojiItem}
title={image.body || image.shortcode}
aria-label={`${image.body || image.shortcode} emoji`}
data-emoji-type={EmojiType.CustomEmoji}
data-emoji-data={image.url}
data-emoji-shortcode={image.shortcode}
>
<img
loading="lazy"
className={css.CustomEmojiImg}
alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''}
/>
</Box>
);
}
type StickerItemProps = {
mx: MatrixClient;
useAuthentication?: boolean;
image: PackImageReader;
};
export function StickerItem({ mx, useAuthentication, image }: StickerItemProps) {
return (
<Box
as="button"
type="button"
alignItems="Center"
justifyContent="Center"
className={css.StickerItem}
title={image.body || image.shortcode}
aria-label={`${image.body || image.shortcode} emoji`}
data-emoji-type={EmojiType.Sticker}
data-emoji-data={image.url}
data-emoji-shortcode={image.shortcode}
>
<img
loading="lazy"
className={css.StickerImg}
alt={image.body || image.shortcode}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''}
/>
</Box>
);
}

View File

@@ -0,0 +1,30 @@
import { as, Box, Line } from 'folds';
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import * as css from './styles.css';
export const EmojiBoardLayout = as<
'div',
{
header: ReactNode;
sidebar?: ReactNode;
children: ReactNode;
}
>(({ className, header, sidebar, children, ...props }, ref) => (
<Box
display="InlineFlex"
className={classNames(css.Base, className)}
direction="Row"
{...props}
ref={ref}
>
<Box direction="Column" grow="Yes">
<Box className={css.Header} direction="Column" shrink="No">
{header}
</Box>
{children}
</Box>
<Line size="300" direction="Vertical" />
{sidebar}
</Box>
));

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Box, toRem, config, Icons, Icon, Text } from 'folds';
export function NoStickerPacks() {
return (
<Box
style={{ padding: `${toRem(60)} ${config.space.S500}` }}
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="300"
>
<Icon size="600" src={Icons.Sticker} />
<Box direction="Inherit">
<Text align="Center">No Sticker Packs!</Text>
<Text priority="300" align="Center" size="T200">
Add stickers from user, room or space settings.
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,53 @@
import { Box, Text } from 'folds';
import React from 'react';
import { Atom, atom, useAtomValue } from 'jotai';
import * as css from './styles.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../../../utils/matrix';
export type PreviewData = {
key: string;
shortcode: string;
};
export const createPreviewDataAtom = (initial?: PreviewData) =>
atom<PreviewData | undefined>(initial);
type PreviewProps = {
previewAtom: Atom<PreviewData | undefined>;
};
export function Preview({ previewAtom }: PreviewProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { key, shortcode } = useAtomValue(previewAtom) ?? {};
if (!shortcode) return null;
return (
<Box shrink="No" className={css.Preview} gap="300" alignItems="Center">
{key && (
<Box
display="InlineFlex"
className={css.PreviewEmoji}
alignItems="Center"
justifyContent="Center"
>
{key.startsWith('mxc://') ? (
<img
className={css.PreviewImg}
src={mxcUrlToHttp(mx, key, useAuthentication) ?? key}
alt={shortcode}
/>
) : (
key
)}
</Box>
)}
<Text size="H5" truncate>
:{shortcode}:
</Text>
</Box>
);
}

View File

@@ -0,0 +1,51 @@
import React, { ChangeEventHandler, useRef } from 'react';
import { Input, Chip, Icon, Icons, Text } from 'folds';
import { mobileOrTablet } from '../../../utils/user-agent';
type SearchInputProps = {
query?: string;
onChange: ChangeEventHandler<HTMLInputElement>;
allowTextCustomEmoji?: boolean;
onTextCustomEmojiSelect?: (text: string) => void;
};
export function SearchInput({
query,
onChange,
allowTextCustomEmoji,
onTextCustomEmojiSelect,
}: SearchInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleReact = () => {
const textEmoji = inputRef.current?.value.trim();
if (!textEmoji) return;
onTextCustomEmojiSelect?.(textEmoji);
};
return (
<Input
ref={inputRef}
variant="SurfaceVariant"
size="400"
placeholder={allowTextCustomEmoji ? 'Search or Text Reaction ' : 'Search'}
maxLength={50}
after={
allowTextCustomEmoji && query ? (
<Chip
variant="Primary"
radii="Pill"
after={<Icon src={Icons.ArrowRight} size="50" />}
outlined
onClick={handleReact}
>
<Text size="L400">React</Text>
</Chip>
) : (
<Icon src={Icons.Search} size="50" />
)
}
onChange={onChange}
autoFocus={!mobileOrTablet()}
/>
);
}

View File

@@ -0,0 +1,130 @@
import React, { ReactNode } from 'react';
import {
Box,
Scroll,
Line,
as,
TooltipProvider,
Tooltip,
Text,
IconButton,
Icon,
IconSrc,
Icons,
} from 'folds';
import classNames from 'classnames';
import * as css from './styles.css';
export function Sidebar({ children }: { children: ReactNode }) {
return (
<Box className={css.Sidebar} shrink="No">
<Scroll size="0">
<Box className={css.SidebarContent} direction="Column" alignItems="Center" gap="100">
{children}
</Box>
</Scroll>
</Box>
);
}
export const SidebarStack = as<'div'>(({ className, children, ...props }, ref) => (
<Box
className={classNames(css.SidebarStack, className)}
direction="Column"
alignItems="Center"
gap="100"
{...props}
ref={ref}
>
{children}
</Box>
));
export function SidebarDivider() {
return <Line className={css.SidebarDivider} size="300" variant="Surface" />;
}
function SidebarBtn<T extends string>({
active,
label,
id,
onClick,
children,
}: {
active?: boolean;
label: string;
id: T;
onClick: (id: T) => void;
children: ReactNode;
}) {
return (
<TooltipProvider
delay={500}
position="Left"
tooltip={
<Tooltip id={`SidebarStackItem-${id}-label`}>
<Text size="T300">{label}</Text>
</Tooltip>
}
>
{(ref) => (
<IconButton
aria-pressed={active}
aria-labelledby={`SidebarStackItem-${id}-label`}
ref={ref}
onClick={() => onClick(id)}
size="400"
radii="300"
variant="Surface"
>
{children}
</IconButton>
)}
</TooltipProvider>
);
}
type GroupIconProps<T extends string> = {
active: boolean;
id: T;
label: string;
icon: IconSrc;
onClick: (id: T) => void;
};
export function GroupIcon<T extends string>({
active,
id,
label,
icon,
onClick,
}: GroupIconProps<T>) {
return (
<SidebarBtn active={active} id={id} label={label} onClick={onClick}>
<Icon src={icon} filled={active} />
</SidebarBtn>
);
}
type ImageGroupIconProps<T extends string> = {
active: boolean;
id: T;
label: string;
url?: string;
onClick: (id: T) => void;
};
export function ImageGroupIcon<T extends string>({
active,
id,
label,
url,
onClick,
}: ImageGroupIconProps<T>) {
return (
<SidebarBtn active={active} id={id} label={label} onClick={onClick}>
{url ? (
<img className={css.SidebarBtnImg} src={url} alt={label} />
) : (
<Icon src={Icons.Photo} filled={active} />
)}
</SidebarBtn>
);
}

View File

@@ -0,0 +1,44 @@
import React, { CSSProperties } from 'react';
import { Badge, Box, Text } from 'folds';
import { EmojiBoardTab } from '../types';
const styles: CSSProperties = {
cursor: 'pointer',
};
export function EmojiBoardTabs({
tab,
onTabChange,
}: {
tab: EmojiBoardTab;
onTabChange: (tab: EmojiBoardTab) => void;
}) {
return (
<Box gap="100">
<Badge
style={styles}
as="button"
variant="Secondary"
fill={tab === EmojiBoardTab.Sticker ? 'Solid' : 'None'}
size="500"
onClick={() => onTabChange(EmojiBoardTab.Sticker)}
>
<Text as="span" size="L400">
Sticker
</Text>
</Badge>
<Badge
style={styles}
as="button"
variant="Secondary"
fill={tab === EmojiBoardTab.Emoji ? 'Solid' : 'None'}
size="500"
onClick={() => onTabChange(EmojiBoardTab.Emoji)}
>
<Text as="span" size="L400">
Emoji
</Text>
</Badge>
</Box>
);
}

View File

@@ -0,0 +1,8 @@
export * from './SearchInput';
export * from './Tabs';
export * from './Sidebar';
export * from './NoStickerPacks';
export * from './Preview';
export * from './Item';
export * from './Group';
export * from './Layout';

View File

@@ -1,5 +1,9 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds'; import { toRem, color, config, DefaultReset, FocusOutline } from 'folds';
/**
* Layout
*/
export const Base = style({ export const Base = style({
maxWidth: toRem(432), maxWidth: toRem(432),
@@ -13,6 +17,15 @@ export const Base = style({
overflow: 'hidden', overflow: 'hidden',
}); });
export const Header = style({
padding: config.space.S300,
paddingBottom: 0,
});
/**
* Sidebar
*/
export const Sidebar = style({ export const Sidebar = style({
width: toRem(54), width: toRem(54),
backgroundColor: color.Surface.Container, backgroundColor: color.Surface.Container,
@@ -29,26 +42,21 @@ export const SidebarStack = style({
backgroundColor: color.Surface.Container, backgroundColor: color.Surface.Container,
}); });
export const NativeEmojiSidebarStack = style({
position: 'sticky',
bottom: '-67%',
zIndex: 1,
});
export const SidebarDivider = style({ export const SidebarDivider = style({
width: toRem(18), width: toRem(18),
}); });
export const Header = style({ export const SidebarBtnImg = style({
padding: config.space.S300, width: toRem(24),
paddingBottom: 0, height: toRem(24),
objectFit: 'contain',
}); });
export const EmojiBoardTab = style({ /**
cursor: 'pointer', * Preview
}); */
export const Footer = style({ export const Preview = style({
padding: config.space.S200, padding: config.space.S200,
margin: config.space.S300, margin: config.space.S300,
marginTop: 0, marginTop: 0,
@@ -59,7 +67,30 @@ export const Footer = style({
color: color.SurfaceVariant.OnContainer, color: color.SurfaceVariant.OnContainer,
}); });
export const PreviewEmoji = style([
DefaultReset,
{
width: toRem(32),
height: toRem(32),
fontSize: toRem(32),
lineHeight: toRem(32),
},
]);
export const PreviewImg = style([
DefaultReset,
{
width: toRem(32),
height: toRem(32),
objectFit: 'contain',
},
]);
/**
* Group
*/
export const EmojiGroup = style({ export const EmojiGroup = style({
position: 'relative',
padding: `${config.space.S300} 0`, padding: `${config.space.S300} 0`,
}); });
@@ -82,15 +113,9 @@ export const EmojiGroupContent = style([
}, },
]); ]);
export const EmojiPreview = style([ /**
DefaultReset, * Item
{ */
width: toRem(32),
height: toRem(32),
fontSize: toRem(32),
lineHeight: toRem(32),
},
]);
export const EmojiItem = style([ export const EmojiItem = style([
DefaultReset, DefaultReset,

View File

@@ -1 +1,2 @@
export * from './EmojiBoard'; export * from './EmojiBoard';
export * from './types';

View File

@@ -0,0 +1,17 @@
export enum EmojiBoardTab {
Emoji = 'Emoji',
Sticker = 'Sticker',
}
export enum EmojiType {
Emoji = 'emoji',
CustomEmoji = 'customEmoji',
Sticker = 'sticker',
}
export type EmojiItemInfo = {
type: EmojiType;
data: string;
shortcode: string;
label: string;
};

View File

@@ -27,7 +27,8 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
const [downloadState, download] = useAsyncCallback( const [downloadState, download] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);

View File

@@ -224,6 +224,8 @@ type RenderVideoContentProps = {
mimeType: string; mimeType: string;
url: string; url: string;
encInfo?: IEncryptedFile; encInfo?: IEncryptedFile;
markedAsSpoiler?: boolean;
spoilerReason?: string;
}; };
type MVideoProps = { type MVideoProps = {
content: IVideoContent; content: IVideoContent;
@@ -274,6 +276,8 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
mimeType: safeMimeType, mimeType: safeMimeType,
url: mxcUrl, url: mxcUrl,
encInfo: content.file, encInfo: content.file,
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})} })}
</AttachmentBox> </AttachmentBox>
</Attachment> </Attachment>

View File

@@ -54,7 +54,8 @@ export function AudioContent({
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);

View File

@@ -1,6 +1,6 @@
import { Box, Icon, IconSrc } from 'folds'; import { Box, Icon, IconSrc } from 'folds';
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { CompactLayout, ModernLayout } from '..'; import { BubbleLayout, CompactLayout, ModernLayout } from '..';
import { MessageLayout } from '../../../state/settings'; import { MessageLayout } from '../../../state/settings';
export type EventContentProps = { export type EventContentProps = {
@@ -30,9 +30,15 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
</Box> </Box>
); );
return messageLayout === MessageLayout.Compact ? ( if (messageLayout === MessageLayout.Compact) {
<CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout> return <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>;
) : ( }
<ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout> if (messageLayout === MessageLayout.Bubble) {
); return (
<BubbleLayout hideBubble before={beforeJSX}>
{msgContentJSX}
</BubbleLayout>
);
}
return <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>;
} }

View File

@@ -86,7 +86,8 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
const [textState, loadText] = useAsyncCallback( const [textState, loadText] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);
@@ -176,7 +177,8 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
const [pdfState, loadPdf] = useAsyncCallback( const [pdfState, loadPdf] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);
@@ -253,7 +255,8 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
const [downloadState, download] = useAsyncCallback( const [downloadState, download] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);

View File

@@ -87,7 +87,8 @@ export const ImageContent = as<'div', ImageContentProps>(
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
if (encInfo) { if (encInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo) decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo)
@@ -214,7 +215,7 @@ export const ImageContent = as<'div', ImageContentProps>(
)} )}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) && {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load && !load &&
!markedAsSpoiler && ( !blurred && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center"> <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" /> <Spinner variant="Secondary" />
</Box> </Box>

View File

@@ -23,7 +23,8 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
throw new Error('Failed to load thumbnail'); throw new Error('Failed to load thumbnail');
} }
const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? thumbMxcUrl; const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
if (encInfo) { if (encInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo) decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo)

View File

@@ -3,6 +3,7 @@ import {
Badge, Badge,
Box, Box,
Button, Button,
Chip,
Icon, Icon,
Icons, Icons,
Spinner, Spinner,
@@ -47,6 +48,8 @@ type VideoContentProps = {
info: IVideoInfo & IThumbnailContent; info: IVideoInfo & IThumbnailContent;
encInfo?: EncryptedAttachmentInfo; encInfo?: EncryptedAttachmentInfo;
autoPlay?: boolean; autoPlay?: boolean;
markedAsSpoiler?: boolean;
spoilerReason?: string;
renderThumbnail?: () => ReactNode; renderThumbnail?: () => ReactNode;
renderVideo: (props: RenderVideoProps) => ReactNode; renderVideo: (props: RenderVideoProps) => ReactNode;
}; };
@@ -60,6 +63,8 @@ export const VideoContent = as<'div', VideoContentProps>(
info, info,
encInfo, encInfo,
autoPlay, autoPlay,
markedAsSpoiler,
spoilerReason,
renderThumbnail, renderThumbnail,
renderVideo, renderVideo,
...props ...props
@@ -72,10 +77,12 @@ export const VideoContent = as<'div', VideoContentProps>(
const [load, setLoad] = useState(false); const [load, setLoad] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => ? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo) decryptFile(encBuf, mimeType, encInfo)
@@ -114,11 +121,15 @@ export const VideoContent = as<'div', VideoContentProps>(
/> />
)} )}
{renderThumbnail && !load && ( {renderThumbnail && !load && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center"> <Box
className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
alignItems="Center"
justifyContent="Center"
>
{renderThumbnail()} {renderThumbnail()}
</Box> </Box>
)} )}
{!autoPlay && srcState.status === AsyncStatus.Idle && ( {!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center"> <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Button <Button
variant="Secondary" variant="Secondary"
@@ -133,7 +144,7 @@ export const VideoContent = as<'div', VideoContentProps>(
</Box> </Box>
)} )}
{srcState.status === AsyncStatus.Success && ( {srcState.status === AsyncStatus.Success && (
<Box className={css.AbsoluteContainer}> <Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
{renderVideo({ {renderVideo({
title: body, title: body,
src: srcState.data, src: srcState.data,
@@ -144,8 +155,39 @@ export const VideoContent = as<'div', VideoContentProps>(
})} })}
</Box> </Box>
)} )}
{blurred && !error && srcState.status !== AsyncStatus.Error && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<TooltipProvider
tooltip={
typeof spoilerReason === 'string' && (
<Tooltip variant="Secondary">
<Text>{spoilerReason}</Text>
</Tooltip>
)
}
position="Top"
align="Center"
>
{(triggerRef) => (
<Chip
ref={triggerRef}
variant="Secondary"
radii="Pill"
size="500"
outlined
onClick={() => {
setBlurred(false);
}}
>
<Text size="B300">Spoiler</Text>
</Chip>
)}
</TooltipProvider>
</Box>
)}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) && {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load && ( !load &&
!blurred && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center"> <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" /> <Spinner variant="Secondary" />
</Box> </Box>

View File

@@ -1,18 +1,63 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { Box, as } from 'folds'; import classNames from 'classnames';
import { Box, ContainerColor, as, color } from 'folds';
import * as css from './layout.css'; import * as css from './layout.css';
type BubbleArrowProps = {
variant: ContainerColor;
};
function BubbleLeftArrow({ variant }: BubbleArrowProps) {
return (
<svg
className={css.BubbleLeftArrow}
width="9"
height="8"
viewBox="0 0 9 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.00004 8V0H4.82847C3.04666 0 2.15433 2.15428 3.41426 3.41421L8.00004 8H9.00004Z"
fill={color[variant].Container}
/>
</svg>
);
}
type BubbleLayoutProps = { type BubbleLayoutProps = {
hideBubble?: boolean;
before?: ReactNode; before?: ReactNode;
header?: ReactNode;
}; };
export const BubbleLayout = as<'div', BubbleLayoutProps>(({ before, children, ...props }, ref) => ( export const BubbleLayout = as<'div', BubbleLayoutProps>(
<Box gap="300" {...props} ref={ref}> ({ hideBubble, before, header, children, ...props }, ref) => (
<Box className={css.BubbleBefore} shrink="No"> <Box gap="300" {...props} ref={ref}>
{before} <Box className={css.BubbleBefore} shrink="No">
{before}
</Box>
<Box grow="Yes" direction="Column">
{header}
{hideBubble ? (
children
) : (
<Box>
<Box
className={
hideBubble
? undefined
: classNames(css.BubbleContent, before ? css.BubbleContentArrowLeft : undefined)
}
direction="Column"
>
{before ? <BubbleLeftArrow variant="SurfaceVariant" /> : null}
{children}
</Box>
</Box>
)}
</Box>
</Box> </Box>
<Box className={css.BubbleContent} direction="Column"> )
{children} );
</Box>
</Box>
));

View File

@@ -120,6 +120,7 @@ export const CompactHeader = style([
export const AvatarBase = style({ export const AvatarBase = style({
paddingTop: toRem(4), paddingTop: toRem(4),
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)', transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
display: 'flex',
alignSelf: 'start', alignSelf: 'start',
selectors: { selectors: {
@@ -133,14 +134,31 @@ export const ModernBefore = style({
minWidth: toRem(36), minWidth: toRem(36),
}); });
export const BubbleBefore = style([ModernBefore]); export const BubbleBefore = style({
minWidth: toRem(36),
});
export const BubbleContent = style({ export const BubbleContent = style({
maxWidth: toRem(800), maxWidth: toRem(800),
padding: config.space.S200, padding: config.space.S200,
backgroundColor: color.SurfaceVariant.Container, backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer, color: color.SurfaceVariant.OnContainer,
borderRadius: config.radii.R400, borderRadius: config.radii.R500,
position: 'relative',
});
export const BubbleContentArrowLeft = style({
borderTopLeftRadius: 0,
});
export const BubbleLeftArrow = style({
width: toRem(9),
height: toRem(8),
position: 'absolute',
top: 0,
left: toRem(-8),
zIndex: 1,
}); });
export const Username = style({ export const Username = style({

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React, { ReactNode, useEffect } from 'react';
import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds'; import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard'; import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload'; import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
@@ -13,8 +13,54 @@ import {
import { useObjectURL } from '../../hooks/useObjectURL'; import { useObjectURL } from '../../hooks/useObjectURL';
import { useMediaConfig } from '../../hooks/useMediaConfig'; import { useMediaConfig } from '../../hooks/useMediaConfig';
type ImagePreviewProps = { fileItem: TUploadItem; onSpoiler: (marked: boolean) => void }; type PreviewImageProps = {
function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) { fileItem: TUploadItem;
};
function PreviewImage({ fileItem }: PreviewImageProps) {
const { originalFile, metadata } = fileItem;
const fileUrl = useObjectURL(originalFile);
return (
<img
style={{
objectFit: 'contain',
width: '100%',
height: toRem(152),
filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
}}
alt={originalFile.name}
src={fileUrl}
/>
);
}
type PreviewVideoProps = {
fileItem: TUploadItem;
};
function PreviewVideo({ fileItem }: PreviewVideoProps) {
const { originalFile, metadata } = fileItem;
const fileUrl = useObjectURL(originalFile);
return (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
style={{
objectFit: 'contain',
width: '100%',
height: toRem(152),
filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
}}
src={fileUrl}
/>
);
}
type MediaPreviewProps = {
fileItem: TUploadItem;
onSpoiler: (marked: boolean) => void;
children: ReactNode;
};
function MediaPreview({ fileItem, onSpoiler, children }: MediaPreviewProps) {
const { originalFile, metadata } = fileItem; const { originalFile, metadata } = fileItem;
const fileUrl = useObjectURL(originalFile); const fileUrl = useObjectURL(originalFile);
@@ -27,16 +73,7 @@ function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
position: 'relative', position: 'relative',
}} }}
> >
<img {children}
style={{
objectFit: 'contain',
width: '100%',
height: toRem(152),
filter: fileItem.metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
}}
src={fileUrl}
alt={originalFile.name}
/>
<Box <Box
justifyContent="End" justifyContent="End"
style={{ style={{
@@ -136,7 +173,14 @@ export function UploadCardRenderer({
bottom={ bottom={
<> <>
{fileItem.originalFile.type.startsWith('image') && ( {fileItem.originalFile.type.startsWith('image') && (
<ImagePreview fileItem={fileItem} onSpoiler={handleSpoiler} /> <MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
<PreviewImage fileItem={fileItem} />
</MediaPreview>
)}
{fileItem.originalFile.type.startsWith('video') && (
<MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
<PreviewVideo fileItem={fileItem} />
</MediaPreview>
)} )}
{upload.status === UploadStatus.Idle && !fileSizeExceeded && ( {upload.status === UploadStatus.Idle && !fileSizeExceeded && (
<UploadCardProgress sentBytes={0} totalBytes={file.size} /> <UploadCardProgress sentBytes={0} totalBytes={file.size} />

View File

@@ -329,7 +329,7 @@ function LocalAddressesList({
<Box shrink="No"> <Box shrink="No">
<Checkbox <Checkbox
checked={selected} checked={selected}
onChange={() => toggleSelect(alias)} onClick={() => toggleSelect(alias)}
size="50" size="50"
variant="Primary" variant="Primary"
disabled={loading} disabled={loading}

View File

@@ -27,7 +27,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
import { useRoom } from '../../../hooks/useRoom'; import { useRoom } from '../../../hooks/useRoom';
import { useRoomMembers } from '../../../hooks/useRoomMembers'; import { useRoomMembers } from '../../../hooks/useRoomMembers';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { useGetMemberPowerLevel, usePowerLevels } from '../../../hooks/usePowerLevels';
import { VirtualTile } from '../../../components/virtualizer'; import { VirtualTile } from '../../../components/virtualizer';
import { MemberTile } from '../../../components/member-tile'; import { MemberTile } from '../../../components/member-tile';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
@@ -87,12 +87,13 @@ export function Members({ requestClose }: MembersProps) {
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room); const creators = useRoomCreators(room);
const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels); const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const getPowerLevel = useGetMemberPowerLevel(powerLevels);
const [membershipFilterIndex, setMembershipFilterIndex] = useState(0); const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex'); const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
const membershipFilter = useMembershipFilter(membershipFilterIndex, useMembershipFilterMenu()); const membershipFilter = useMembershipFilter(membershipFilterIndex, useMembershipFilterMenu());
const memberSort = useMemberSort(sortFilterIndex, useMemberSortMenu()); const memberSort = useMemberSort(sortFilterIndex, useMemberSortMenu());
const memberPowerSort = useMemberPowerSort(creators); const memberPowerSort = useMemberPowerSort(creators, getPowerLevel);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);

View File

@@ -183,7 +183,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
onClick={() => setAdvance(!advance)} onClick={() => setAdvance(!advance)}
type="button" type="button"
> >
<Text size="T200">Advance Options</Text> <Text size="T200">Advanced Options</Text>
</Chip> </Chip>
</Box> </Box>
</Box> </Box>

View File

@@ -184,7 +184,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
onClick={() => setAdvance(!advance)} onClick={() => setAdvance(!advance)}
type="button" type="button"
> >
<Text size="T200">Advance Options</Text> <Text size="T200">Advanced Options</Text>
</Chip> </Chip>
</Box> </Box>
</Box> </Box>

View File

@@ -59,7 +59,7 @@ export function General({ requestClose }: GeneralProps) {
<RoomLocalAddresses permissions={permissions} /> <RoomLocalAddresses permissions={permissions} />
</Box> </Box>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Advance Options</Text> <Text size="L400">Advanced Options</Text>
<RoomUpgrade permissions={permissions} requestClose={requestClose} /> <RoomUpgrade permissions={permissions} requestClose={requestClose} />
</Box> </Box>
</Box> </Box>

View File

@@ -51,7 +51,7 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter'; import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter';
import { useMemberPowerSort, useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort'; import { useMemberPowerSort, useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { useGetMemberPowerLevel, usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { MembershipFilterMenu } from '../../components/MembershipFilterMenu'; import { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
import { MemberSortMenu } from '../../components/MemberSortMenu'; import { MemberSortMenu } from '../../components/MemberSortMenu';
import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile'; import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile';
@@ -185,6 +185,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
const powerLevels = usePowerLevelsContext(); const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room); const creators = useRoomCreators(room);
const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels); const getPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const getPowerLevel = useGetMemberPowerLevel(powerLevels);
const fetchingMembers = members.length < room.getJoinedMemberCount(); const fetchingMembers = members.length < room.getJoinedMemberCount();
const openUserRoomProfile = useOpenUserRoomProfile(); const openUserRoomProfile = useOpenUserRoomProfile();
@@ -198,7 +199,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
const membershipFilter = useMembershipFilter(membershipFilterIndex, membershipFilterMenu); const membershipFilter = useMembershipFilter(membershipFilterIndex, membershipFilterMenu);
const memberSort = useMemberSort(sortFilterIndex, sortFilterMenu); const memberSort = useMemberSort(sortFilterIndex, sortFilterMenu);
const memberPowerSort = useMemberPowerSort(creators); const memberPowerSort = useMemberPowerSort(creators, getPowerLevel);
const typingMembers = useRoomTypingMember(room.roomId); const typingMembers = useRoomTypingMember(room.roomId);

View File

@@ -116,6 +116,7 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useTheme } from '../../hooks/useTheme'; import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck';
interface RoomInputProps { interface RoomInputProps {
editor: Editor; editor: Editor;
@@ -217,6 +218,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles); const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500); const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
const isComposing = useComposingCheck();
useElementSizeObserver( useElementSizeObserver(
useCallback(() => document.body, []), useCallback(() => document.body, []),
useCallback((width) => setHideStickerBtn(width < 500), []) useCallback((width) => setHideStickerBtn(width < 500), [])
@@ -380,7 +383,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
(evt) => { (evt) => {
if ( if (
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) && (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
!evt.nativeEvent.isComposing !isComposing(evt)
) { ) {
evt.preventDefault(); evt.preventDefault();
submit(); submit();
@@ -394,7 +397,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setReplyDraft(undefined); setReplyDraft(undefined);
} }
}, },
[submit, setReplyDraft, enterForNewline, autocompleteQuery] [submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing]
); );
const handleKeyUp: KeyboardEventHandler = useCallback( const handleKeyUp: KeyboardEventHandler = useCallback(

View File

@@ -471,6 +471,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const permissions = useRoomPermissions(creators, powerLevels); const permissions = useRoomPermissions(creators, powerLevels);
const canRedact = permissions.action('redact', mx.getSafeUserId()); const canRedact = permissions.action('redact', mx.getSafeUserId());
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId()); const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId()); const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
const [editId, setEditId] = useState<string>(); const [editId, setEditId] = useState<string>();
@@ -1047,7 +1048,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
collapse={collapse} collapse={collapse}
highlight={highlighted} highlight={highlighted}
edit={editId === mEventId} edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent} canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
@@ -1129,7 +1130,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
collapse={collapse} collapse={collapse}
highlight={highlighted} highlight={highlighted}
edit={editId === mEventId} edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent} canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
@@ -1247,7 +1248,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
messageLayout={messageLayout} messageLayout={messageLayout}
collapse={collapse} collapse={collapse}
highlight={highlighted} highlight={highlighted}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
canPinEvent={canPinEvent} canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}

View File

@@ -79,9 +79,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
useCallback( useCallback(
(evt) => { (evt) => {
if (editableActiveElement()) return; if (editableActiveElement()) return;
// means some menu or modal window is open const portalContainer = document.getElementById('portalContainer');
const lastNode = document.body.lastElementChild; if (portalContainer && portalContainer.children.length > 0) {
if (lastNode && !lastNode.hasAttribute('data-last-node')) {
return; return;
} }
if (shouldFocusMessageField(evt) || isKeyHotkey('mod+v', evt)) { if (shouldFocusMessageField(evt) || isKeyHotkey('mod+v', evt)) {

View File

@@ -723,6 +723,7 @@ export const Message = as<'div', MessageProps>(
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover }); const { hoverProps } = useHover({ onHoverChange: setHover });
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
@@ -790,7 +791,9 @@ export const Message = as<'div', MessageProps>(
); );
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && ( const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
<AvatarBase> <AvatarBase
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
>
<Avatar <Avatar
className={css.MessageAvatar} className={css.MessageAvatar}
as="button" as="button"
@@ -875,7 +878,9 @@ export const Message = as<'div', MessageProps>(
return ( return (
<MessageBase <MessageBase
className={classNames(css.MessageBase, className)} className={classNames(css.MessageBase, className, {
[css.MessageBaseBubbleCollapsed]: messageLayout === MessageLayout.Bubble && collapse,
})}
tabIndex={0} tabIndex={0}
space={messageSpacing} space={messageSpacing}
collapse={collapse} collapse={collapse}
@@ -1132,8 +1137,7 @@ export const Message = as<'div', MessageProps>(
</CompactLayout> </CompactLayout>
)} )}
{messageLayout === MessageLayout.Bubble && ( {messageLayout === MessageLayout.Bubble && (
<BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}> <BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}>
{headerJSX}
{msgContentJSX} {msgContentJSX}
</BubbleLayout> </BubbleLayout>
)} )}

View File

@@ -53,6 +53,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room'; import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
import { mobileOrTablet } from '../../../utils/user-agent'; import { mobileOrTablet } from '../../../utils/user-agent';
import { useComposingCheck } from '../../../hooks/useComposingCheck';
type MessageEditorProps = { type MessageEditorProps = {
roomId: string; roomId: string;
@@ -69,6 +70,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [toolbar, setToolbar] = useState(globalToolbar); const [toolbar, setToolbar] = useState(globalToolbar);
const isComposing = useComposingCheck();
const [autocompleteQuery, setAutocompleteQuery] = const [autocompleteQuery, setAutocompleteQuery] =
useState<AutocompleteQuery<AutocompletePrefix>>(); useState<AutocompleteQuery<AutocompletePrefix>>();
@@ -163,7 +165,10 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const handleKeyDown: KeyboardEventHandler = useCallback( const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => { (evt) => {
if ((isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) && !evt.nativeEvent.isComposing) { if (
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
!isComposing(evt)
) {
evt.preventDefault(); evt.preventDefault();
handleSave(); handleSave();
} }
@@ -172,7 +177,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
onCancel(); onCancel();
} }
}, },
[onCancel, handleSave, enterForNewline] [onCancel, handleSave, enterForNewline, isComposing]
); );
const handleKeyUp: KeyboardEventHandler = useCallback( const handleKeyUp: KeyboardEventHandler = useCallback(

View File

@@ -4,6 +4,9 @@ import { DefaultReset, config, toRem } from 'folds';
export const MessageBase = style({ export const MessageBase = style({
position: 'relative', position: 'relative',
}); });
export const MessageBaseBubbleCollapsed = style({
paddingTop: 0,
});
export const MessageOptionsBase = style([ export const MessageOptionsBase = style([
DefaultReset, DefaultReset,
@@ -21,6 +24,10 @@ export const MessageOptionsBar = style([
}, },
]); ]);
export const BubbleAvatarBase = style({
paddingTop: 0,
});
export const MessageAvatar = style({ export const MessageAvatar = style({
cursor: 'pointer', cursor: 'pointer',
}); });

View File

@@ -82,7 +82,7 @@ export const getVideoMsgContent = async (
item: TUploadItem, item: TUploadItem,
mxc: string mxc: string
): Promise<IContent> => { ): Promise<IContent> => {
const { file, originalFile, encInfo } = item; const { file, originalFile, encInfo, metadata } = item;
const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile))); const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
if (videoError) console.warn(videoError); if (videoError) console.warn(videoError);
@@ -91,6 +91,7 @@ export const getVideoMsgContent = async (
msgtype: MsgType.Video, msgtype: MsgType.Video,
filename: file.name, filename: file.name,
body: file.name, body: file.name,
[MATRIX_SPOILER_PROPERTY_NAME]: metadata.markedAsSpoiler,
}; };
if (videoEl) { if (videoEl) {
const [thumbError, thumbContent] = await to( const [thumbError, thumbContent] = await to(

View File

@@ -434,9 +434,8 @@ export function SearchModalRenderer() {
return; return;
} }
// means some menu or modal window is open const portalContainer = document.getElementById('portalContainer');
const lastNode = document.body.lastElementChild; if (portalContainer && portalContainer.children.length > 0) {
if (lastNode && !lastNode.hasAttribute('data-last-node')) {
return; return;
} }
setOpen(true); setOpen(true);

View File

@@ -46,7 +46,7 @@ export function About({ requestClose }: AboutProps) {
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Box gap="100" alignItems="End"> <Box gap="100" alignItems="End">
<Text size="H3">Cinny</Text> <Text size="H3">Cinny</Text>
<Text size="T200">v4.10.0</Text> <Text size="T200">v4.10.2</Text>
</Box> </Box>
<Text>Yet another matrix client.</Text> <Text>Yet another matrix client.</Text>
</Box> </Box>

View File

@@ -55,7 +55,7 @@ export function General({ requestClose }: GeneralProps) {
<RoomLocalAddresses permissions={permissions} /> <RoomLocalAddresses permissions={permissions} />
</Box> </Box>
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Advance Options</Text> <Text size="L400">Advanced Options</Text>
<RoomUpgrade permissions={permissions} requestClose={requestClose} /> <RoomUpgrade permissions={permissions} requestClose={requestClose} />
</Box> </Box>
</Box> </Box>

View File

@@ -0,0 +1,47 @@
import { useCallback, useEffect } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { lastCompositionEndAtom } from '../state/lastCompositionEnd';
interface TimeStamped {
readonly timeStamp: number;
}
export function useCompositionEndTracking(): void {
const setLastCompositionEnd = useSetAtom(lastCompositionEndAtom);
const recordCompositionEnd = useCallback(
(evt: TimeStamped) => {
setLastCompositionEnd(evt.timeStamp);
},
[setLastCompositionEnd]
);
useEffect(() => {
window.addEventListener('compositionend', recordCompositionEnd, { capture: true });
return () => {
window.removeEventListener('compositionend', recordCompositionEnd, { capture: true });
};
});
}
interface IsComposingLike {
readonly timeStamp: number;
readonly keyCode: number;
readonly nativeEvent: {
readonly isComposing?: boolean;
};
}
export function useComposingCheck({
compositionEndThreshold = 500,
}: { compositionEndThreshold?: number } = {}): (evt: IsComposingLike) => boolean {
const compositionEnd = useAtomValue(lastCompositionEndAtom);
return useCallback(
(evt: IsComposingLike): boolean =>
evt.nativeEvent.isComposing ||
(evt.keyCode === 229 &&
typeof compositionEnd !== 'undefined' &&
evt.timeStamp - compositionEnd < compositionEndThreshold),
[compositionEndThreshold, compositionEnd]
);
}

View File

@@ -47,7 +47,10 @@ export const useMemberSort = (index: number, memberSort: MemberSortItem[]): Memb
return item; return item;
}; };
export const useMemberPowerSort = (creators: Set<string>): MemberSortFn => { export const useMemberPowerSort = (
creators: Set<string>,
getPowerLevel: (userId: string) => number
): MemberSortFn => {
const sort: MemberSortFn = useCallback( const sort: MemberSortFn = useCallback(
(a, b) => { (a, b) => {
if (creators.has(a.userId) && creators.has(b.userId)) { if (creators.has(a.userId) && creators.has(b.userId)) {
@@ -56,7 +59,7 @@ export const useMemberPowerSort = (creators: Set<string>): MemberSortFn => {
if (creators.has(a.userId)) return -1; if (creators.has(a.userId)) return -1;
if (creators.has(b.userId)) return 1; if (creators.has(b.userId)) return 1;
return b.powerLevel - a.powerLevel; return getPowerLevel(b.userId) - getPowerLevel(a.userId);
}, },
[creators] [creators]
); );

View File

@@ -1,5 +1,6 @@
import React, { useEffect } from 'react'; import React from 'react';
import { Provider as JotaiProvider } from 'jotai'; import { Provider as JotaiProvider } from 'jotai';
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
import { RouterProvider } from 'react-router-dom'; import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
@@ -10,44 +11,44 @@ import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig';
import { FeatureCheck } from './FeatureCheck'; import { FeatureCheck } from './FeatureCheck';
import { createRouter } from './Router'; import { createRouter } from './Router';
import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize'; import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const useLastNodeToDetectReactPortalEntry = () => {
useEffect(() => {
const lastDiv = document.createElement('div');
lastDiv.setAttribute('data-last-node', 'true');
document.body.appendChild(lastDiv);
}, []);
};
function App() { function App() {
const screenSize = useScreenSize(); const screenSize = useScreenSize();
useCompositionEndTracking();
useLastNodeToDetectReactPortalEntry(); const portalContainer = document.getElementById('portalContainer') ?? undefined;
return ( return (
<ScreenSizeProvider value={screenSize}> <TooltipContainerProvider value={portalContainer}>
<FeatureCheck> <PopOutContainerProvider value={portalContainer}>
<ClientConfigLoader <OverlayContainerProvider value={portalContainer}>
fallback={() => <ConfigConfigLoading />} <ScreenSizeProvider value={screenSize}>
error={(err, retry, ignore) => ( <FeatureCheck>
<ConfigConfigError error={err} retry={retry} ignore={ignore} /> <ClientConfigLoader
)} fallback={() => <ConfigConfigLoading />}
> error={(err, retry, ignore) => (
{(clientConfig) => ( <ConfigConfigError error={err} retry={retry} ignore={ignore} />
<ClientConfigProvider value={clientConfig}> )}
<QueryClientProvider client={queryClient}> >
<JotaiProvider> {(clientConfig) => (
<RouterProvider router={createRouter(clientConfig, screenSize)} /> <ClientConfigProvider value={clientConfig}>
</JotaiProvider> <QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} /> <JotaiProvider>
</QueryClientProvider> <RouterProvider router={createRouter(clientConfig, screenSize)} />
</ClientConfigProvider> </JotaiProvider>
)} <ReactQueryDevtools initialIsOpen={false} />
</ClientConfigLoader> </QueryClientProvider>
</FeatureCheck> </ClientConfigProvider>
</ScreenSizeProvider> )}
</ClientConfigLoader>
</FeatureCheck>
</ScreenSizeProvider>
</OverlayContainerProvider>
</PopOutContainerProvider>
</TooltipContainerProvider>
); );
} }

View File

@@ -68,6 +68,7 @@ import { Create } from './client/create';
import { CreateSpaceModalRenderer } from '../features/create-space'; import { CreateSpaceModalRenderer } from '../features/create-space';
import { SearchModalRenderer } from '../features/search'; import { SearchModalRenderer } from '../features/search';
import { getFallbackSession } from '../state/sessions'; import { getFallbackSession } from '../state/sessions';
import { pushSessionToSW } from '../../sw-session';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig; const { hashRouter } = clientConfig;
@@ -106,7 +107,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route <Route
loader={() => { loader={() => {
if (!getFallbackSession()) { const session = getFallbackSession();
if (!session) {
const afterLoginPath = getAppPathFromHref( const afterLoginPath = getAppPathFromHref(
getOriginBaseUrl(hashRouter), getOriginBaseUrl(hashRouter),
window.location.href window.location.href
@@ -114,6 +116,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath); if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
return redirect(getLoginPath()); return redirect(getLoginPath());
} }
pushSessionToSW(session.baseUrl, session.accessToken);
return null; return null;
}} }}
element={ element={

View File

@@ -15,7 +15,7 @@ export function AuthFooter() {
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
v4.10.0 v4.10.2
</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

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

View File

@@ -0,0 +1,3 @@
import { atom } from 'jotai';
export const lastCompositionEndAtom = atom<number | undefined>(undefined);

View File

@@ -1,6 +1,7 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes'; import { recipe } from '@vanilla-extract/recipes';
import { color, config, DefaultReset, toRem } from 'folds'; import { color, config, DefaultReset, toRem } from 'folds';
import { ContainerColor } from './ContainerColor.css';
export const MarginSpaced = style({ export const MarginSpaced = style({
marginBottom: config.space.S200, marginBottom: config.space.S200,
@@ -92,11 +93,14 @@ export const CodeBlock = style([
overflow: 'hidden', overflow: 'hidden',
}, },
]); ]);
export const CodeBlockHeader = style({ export const CodeBlockHeader = style([
padding: `0 ${config.space.S200} 0 ${config.space.S300}`, ContainerColor({ variant: 'Surface' }),
borderBottomWidth: config.borderWidth.B300, {
gap: config.space.S200, padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
}); borderBottomWidth: config.borderWidth.B300,
gap: config.space.S200,
},
]);
export const CodeBlockInternal = style([ export const CodeBlockInternal = style([
CodeFont, CodeFont,
{ {

View File

@@ -1,6 +1,6 @@
import { MatrixClient, ReceiptType } from 'matrix-js-sdk'; import { MatrixClient, ReceiptType } from 'matrix-js-sdk';
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) { export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt?: boolean) {
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
if (!room) return; if (!room) return;
@@ -19,8 +19,15 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
const latestEvent = getLatestValidEvent(); const latestEvent = getLatestValidEvent();
if (latestEvent === null) return; if (latestEvent === null) return;
await mx.sendReadReceipt( const latestEventId = latestEvent.getId();
latestEvent, if (!latestEventId) return;
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read
// Set both the read receipt AND the fully_read marker
// The fully_read marker is what persists your read position across sessions
await mx.setRoomReadMarkers(
roomId,
latestEventId, // m.fully_read marker
latestEvent, // m.read receipt event
privateReceipt ? { receiptType: ReceiptType.ReadPrivate } : undefined
); );
} }

View File

@@ -160,7 +160,8 @@ export const getOrphanParents = (roomToParents: RoomToParents, roomId: string):
}; };
export const isMutedRule = (rule: IPushRule) => export const isMutedRule = (rule: IPushRule) =>
rule.actions[0] === 'dont_notify' && rule.conditions?.[0]?.kind === 'event_match'; // Check for empty actions (new spec) or dont_notify (deprecated)
(rule.actions.length === 0 || rule.actions[0] === 'dont_notify') && rule.conditions?.[0]?.kind === 'event_match';
export const findMutedRule = (overrideRules: IPushRule[], roomId: string) => export const findMutedRule = (overrideRules: IPushRule[], roomId: string) =>
overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule)); overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule));

View File

@@ -2,6 +2,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
import { cryptoCallbacks } from './secretStorageKeys'; import { cryptoCallbacks } from './secretStorageKeys';
import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { clearNavToActivePathStore } from '../app/state/navToActivePath';
import { pushSessionToSW } from '../sw-session';
type Session = { type Session = {
baseUrl: string; baseUrl: string;
@@ -53,6 +54,7 @@ export const clearCacheAndReload = async (mx: MatrixClient) => {
}; };
export const logoutClient = async (mx: MatrixClient) => { export const logoutClient = async (mx: MatrixClient) => {
pushSessionToSW();
mx.stopClient(); mx.stopClient();
try { try {
await mx.logout(); await mx.logout();

View File

@@ -15,6 +15,8 @@ import App from './app/pages/App';
// import i18n (needs to be bundled ;)) // import i18n (needs to be bundled ;))
import './app/i18n'; import './app/i18n';
import { pushSessionToSW } from './sw-session';
import { getFallbackSession } from './app/state/sessions';
document.body.classList.add(configClass, varsClass); document.body.classList.add(configClass, varsClass);
@@ -25,17 +27,24 @@ if ('serviceWorker' in navigator) {
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
: `/dev-sw.js?dev-sw`; : `/dev-sw.js?dev-sw`;
navigator.serviceWorker.register(swUrl); const sendSessionToSW = () => {
navigator.serviceWorker.addEventListener('message', (event) => { const session = getFallbackSession();
if (event.data?.type === 'token' && event.data?.responseKey) { pushSessionToSW(session?.baseUrl, session?.accessToken);
// Get the token for SW. };
const token = localStorage.getItem('cinny_access_token') ?? undefined;
event.source!.postMessage({ navigator.serviceWorker.register(swUrl).then(sendSessionToSW);
responseKey: event.data.responseKey, navigator.serviceWorker.ready.then(sendSessionToSW);
token, window.addEventListener('load', sendSessionToSW);
});
// When returning from background
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
sendSessionToSW();
} }
}); });
// When restored from bfcache (important on iOS)
window.addEventListener('pageshow', sendSessionToSW);
} }
const mountApp = () => { const mountApp = () => {

10
src/sw-session.ts Normal file
View File

@@ -0,0 +1,10 @@
export function pushSessionToSW(baseUrl?: string, accessToken?: string) {
if (!('serviceWorker' in navigator)) return;
if (!navigator.serviceWorker.controller) return;
navigator.serviceWorker.controller.postMessage({
type: 'setSession',
accessToken,
baseUrl,
});
}

View File

@@ -3,22 +3,64 @@
export type {}; export type {};
declare const self: ServiceWorkerGlobalScope; declare const self: ServiceWorkerGlobalScope;
async function askForAccessToken(client: Client): Promise<string | undefined> { self.addEventListener('install', () => {
return new Promise((resolve) => { self.skipWaiting();
const responseKey = Math.random().toString(36); });
const listener = (event: ExtendableMessageEvent) => {
if (event.data.responseKey !== responseKey) return; self.addEventListener('activate', (event: ExtendableEvent) => {
resolve(event.data.token); event.waitUntil(self.clients.claim());
self.removeEventListener('message', listener); });
};
self.addEventListener('message', listener); type SessionInfo = {
client.postMessage({ responseKey, type: 'token' }); accessToken: string;
baseUrl: string;
};
/**
* Store session per client (tab)
*/
const sessions = new Map<string, SessionInfo>();
async function cleanupDeadClients() {
const activeClients = await self.clients.matchAll();
const activeIds = new Set(activeClients.map((c) => c.id));
Array.from(sessions.keys()).forEach((id) => {
if (!activeIds.has(id)) {
sessions.delete(id);
}
}); });
} }
function fetchConfig(token?: string): RequestInit | undefined { /**
if (!token) return undefined; * Receive session updates from clients
*/
self.addEventListener('message', (event: ExtendableMessageEvent) => {
const client = event.source as Client | null;
if (!client) return;
const { type, accessToken, baseUrl } = event.data || {};
if (type !== 'setSession') return;
cleanupDeadClients();
if (typeof accessToken === 'string' && typeof baseUrl === 'string') {
sessions.set(client.id, { accessToken, baseUrl });
} else {
// Logout or invalid session
sessions.delete(client.id);
}
});
function validMediaRequest(url: string, baseUrl: string): boolean {
const downloadUrl = new URL('/_matrix/client/v1/media/download', baseUrl);
const thumbnailUrl = new URL('/_matrix/client/v1/media/thumbnail', baseUrl);
return url.startsWith(downloadUrl.href) || url.startsWith(thumbnailUrl.href);
}
function fetchConfig(token: string): RequestInit {
return { return {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@@ -27,26 +69,16 @@ function fetchConfig(token?: string): RequestInit | undefined {
}; };
} }
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(clients.claim());
});
self.addEventListener('fetch', (event: FetchEvent) => { self.addEventListener('fetch', (event: FetchEvent) => {
const { url, method } = event.request; const { url, method } = event.request;
if (method !== 'GET') return;
if (
!url.includes('/_matrix/client/v1/media/download') &&
!url.includes('/_matrix/client/v1/media/thumbnail')
) {
return;
}
event.respondWith(
(async (): Promise<Response> => {
const client = await self.clients.get(event.clientId);
let token: string | undefined;
if (client) token = await askForAccessToken(client);
return fetch(url, fetchConfig(token)); if (method !== 'GET') return;
})() if (!event.clientId) return;
);
const session = sessions.get(event.clientId);
if (!session) return;
if (!validMediaRequest(url, session.baseUrl)) return;
event.respondWith(fetch(url, fetchConfig(session.accessToken)));
}); });