Compare commits
26
Commits
a29be7953b
...
34fe223aa4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34fe223aa4 | ||
|
|
19eded89c1 | ||
|
|
d0c13b1a49 | ||
|
|
ff575212ee | ||
|
|
a461543916 | ||
|
|
9f8fae964d | ||
|
|
7e64ea398b | ||
|
|
086e4b3b03 | ||
|
|
d0017e4a78 | ||
|
|
e96bd527a0 | ||
|
|
1a5ca81513 | ||
|
|
d280f66d5c | ||
|
|
e2e9f75e40 | ||
|
|
7aba71490a | ||
|
|
2e14980de6 | ||
|
|
6b16c789a4 | ||
|
|
535302aed9 | ||
|
|
a25777a77a | ||
|
|
3adba22ddf | ||
|
|
bacfef5558 | ||
|
|
6aa459df31 | ||
|
|
8f92029b47 | ||
|
|
49c4641ca8 | ||
|
|
ac0ec9f42d | ||
|
|
0f7f0100af | ||
|
|
91def3ad34 |
+22
-18
@@ -64,8 +64,12 @@ jobs:
|
||||
# gate real regressions. NOTE: the lotus-build.sh upstream-merge path can
|
||||
# deploy without CI; a later normal push surfaces any introduced issue here
|
||||
# — fix forward (or briefly re-soften a gate) rather than deploy broken.
|
||||
# eslint gates on errors only (existing no-explicit-any warnings stay
|
||||
# informational — check:eslint has no --max-warnings).
|
||||
# eslint gates on errors, plus a warning ratchet (Gitea #97): `check:eslint`
|
||||
# runs with `--max-warnings 74`, the exact warning count on this tree at
|
||||
# the time the ratchet was added. New warnings push the count over that
|
||||
# ceiling and fail the build; fixing an existing warning is free to do
|
||||
# and should lower the ceiling in the same PR so the count can only go
|
||||
# down over time, never back up.
|
||||
- name: Prettier
|
||||
run: npm run check:prettier
|
||||
|
||||
@@ -87,25 +91,25 @@ jobs:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
VITE_APP_VERSION: ${{ github.sha }}
|
||||
|
||||
# ── Security (informational — findings shouldn't block a deploy) ─────
|
||||
# ── Boot check — actually loads the built dist/, not just builds it ──
|
||||
- name: Boot check
|
||||
run: node scripts/boot-check.mjs
|
||||
|
||||
# ── Security — hard gate. #24 cleared the outstanding advisories (0
|
||||
# vulnerabilities on this tree, verified with `npm audit --omit=dev`), so
|
||||
# there is nothing left this should be soft against. Hard on both
|
||||
# `push` and `pull_request`: a new high/critical advisory should block
|
||||
# the deploy just as much as it should block the PR.
|
||||
- name: Audit (high/critical)
|
||||
run: npm audit --audit-level=high --omit=dev
|
||||
continue-on-error: true
|
||||
|
||||
# ── Bundle size report (informational — never blocks a deploy) ───────
|
||||
- name: Report bundle sizes
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Bundle sizes" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| File | Size | Gzip |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------|------|------|" >> $GITHUB_STEP_SUMMARY
|
||||
find dist/assets -name "*.js" -not -name "*.map" | sort | while read f; do
|
||||
name=$(basename "$f")
|
||||
size=$(du -sh "$f" | cut -f1)
|
||||
gzip_size=$(gzip -c "$f" | wc -c | awk '{printf "%.1f kB", $1/1024}')
|
||||
echo "| $name | $size | $gzip_size |" >> $GITHUB_STEP_SUMMARY
|
||||
done
|
||||
# ── Bundle size budget — hard gate on pull_request, warning on push (a
|
||||
# push has already merged; failing it can only delay deploying an
|
||||
# otherwise-good commit, not prevent the regression, so pull_request is
|
||||
# where this should be caught). Budgets live in scripts/bundle-budget.json.
|
||||
- name: Check bundle size budget
|
||||
continue-on-error: ${{ github.event_name == 'push' }}
|
||||
run: node scripts/check-bundle-size.mjs ${{ github.event_name }}
|
||||
|
||||
# ── Desktop build trigger ──────────────────────────────────────────────
|
||||
# Gated on `build` succeeding so a broken push (e.g. failing `npm ci` or
|
||||
|
||||
+5
-5
@@ -154,13 +154,13 @@ A "Pause Background Animations" toggle is exposed in **Settings → Appearance**
|
||||
|
||||
### Animation Improvements (June 2026)
|
||||
|
||||
All five animated backgrounds were rewritten for smoother, more organic motion:
|
||||
All five animated backgrounds were rewritten for smoother, more organic motion. Each background drives its own single drift/scroll keyframe (no secondary glow or blink layers):
|
||||
|
||||
- **Digital Rain** — added a phosphor glow flicker (`animRainGlowKeyframe`, 2.1 s) layered on top of the column scroll; stripe opacity increased for better visibility
|
||||
- **Digital Rain** — column scroll keyframe; stripe opacity increased for better visibility
|
||||
- **Star Drift** — each of the three dot layers now moves by exactly its own tile width/height per cycle (`−130 px`, `−190 px`, `−260 px`), eliminating the visible seam on loop
|
||||
- **Grid Pulse** — independent brightness oscillation (`animGridBrightnessKeyframe`, 3.3 s) runs alongside the size breathe (4 s) at a prime period ratio so they never synchronise
|
||||
- **Grid Pulse** — size breathe keyframe (4 s)
|
||||
- **Aurora Flow** — four gradient layers now have individual `backgroundSize` values (`200%`, `250%`, `300%`, `220%`); the keyframe drives each layer through a distinct 5-stop path, replacing the robotic single back-and-forth
|
||||
- **Fireflies** — glow pulse (`animFirefliesGlowKeyframe`, 2.3 s `filter: brightness`) and opacity blink (`animFirefliesBlinkKeyframe`, 1.7 s) added on top of the position drift; prime periods create unsynchronised bioluminescence
|
||||
- **Fireflies** — position drift keyframe with per-firefly duration/delay so motion stays unsynchronised
|
||||
|
||||
### Files
|
||||
|
||||
@@ -1205,7 +1205,7 @@ Hook: `src/app/hooks/usePendingKnocks.ts`
|
||||
|
||||
### Room Emoji Prefix
|
||||
|
||||
A leading emoji in a room name is rendered at 1.15× size in the sidebar for visual hierarchy. An emoji picker button (😊) is added to all room name input fields, prepending the selected emoji to the room name.
|
||||
An emoji picker button (😊) is added to all room name input fields, prepending the selected emoji to the room name.
|
||||
|
||||
### Configurable Composer Toolbar (P3-6)
|
||||
|
||||
|
||||
@@ -73,7 +73,6 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
|
||||
- Toggle to pause background animations
|
||||
- Glassmorphism sidebar — frosted glass effect that lets the background show through
|
||||
- Night Light / blue light filter with an adjustable intensity slider
|
||||
- Emoji prefixes on room names render larger in the sidebar (e.g. 🎮 general)
|
||||
- Rename any room for yourself only — other members see the original name
|
||||
- Emoji picker on all room name inputs
|
||||
|
||||
|
||||
Generated
+202
-96
@@ -50,13 +50,13 @@
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.20.0",
|
||||
"jsqr": "1.4.0",
|
||||
"katex": "0.16.11",
|
||||
"katex": "0.16.47",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"matrix-js-sdk": "41.7.0",
|
||||
"matrix-widget-api": "1.17.0",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "5.7.284",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
"prismjs": "1.30.0",
|
||||
"qrcode": "1.5.4",
|
||||
"qrcode.react": "4.2.0",
|
||||
@@ -69,8 +69,8 @@
|
||||
"react-google-recaptcha": "3.1.0",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-range": "1.10.0",
|
||||
"react-router-dom": "7.15.1",
|
||||
"sanitize-html": "2.17.4",
|
||||
"react-router-dom": "7.18.3",
|
||||
"sanitize-html": "2.17.7",
|
||||
"slate": "0.124.1",
|
||||
"slate-dom": "0.124.1",
|
||||
"slate-history": "0.113.1",
|
||||
@@ -2493,20 +2493,6 @@
|
||||
"uuid": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@giphy/js-util/node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@giphy/react-components": {
|
||||
"version": "10.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@giphy/react-components/-/react-components-10.1.2.tgz",
|
||||
@@ -2709,9 +2695,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
|
||||
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.9.tgz",
|
||||
"integrity": "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
@@ -2725,23 +2711,23 @@
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
|
||||
"@napi-rs/canvas-android-arm64": "1.0.9",
|
||||
"@napi-rs/canvas-darwin-arm64": "1.0.9",
|
||||
"@napi-rs/canvas-darwin-x64": "1.0.9",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "1.0.9",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "1.0.9",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.9",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "1.0.9",
|
||||
"@napi-rs/canvas-linux-x64-musl": "1.0.9",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "1.0.9",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "1.0.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz",
|
||||
"integrity": "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2759,9 +2745,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz",
|
||||
"integrity": "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2779,9 +2765,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
|
||||
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz",
|
||||
"integrity": "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2799,9 +2785,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
|
||||
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz",
|
||||
"integrity": "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2819,9 +2805,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz",
|
||||
"integrity": "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2839,9 +2825,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz",
|
||||
"integrity": "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2859,9 +2845,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz",
|
||||
"integrity": "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2879,9 +2865,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz",
|
||||
"integrity": "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2899,9 +2885,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz",
|
||||
"integrity": "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2919,9 +2905,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz",
|
||||
"integrity": "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2939,9 +2925,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz",
|
||||
"integrity": "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5008,9 +4994,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -6161,6 +6147,7 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
@@ -6174,6 +6161,7 @@
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
@@ -6189,6 +6177,7 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6231,6 +6220,7 @@
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
@@ -6244,6 +6234,7 @@
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
@@ -6327,6 +6318,7 @@
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
@@ -8039,6 +8031,7 @@
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
@@ -8058,6 +8051,7 @@
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
@@ -8073,6 +8067,7 @@
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
@@ -9005,9 +9000,19 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -9125,9 +9130,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.11",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz",
|
||||
"integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==",
|
||||
"version": "0.16.47",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
|
||||
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
@@ -10202,9 +10207,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -10662,15 +10667,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.7.284",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz",
|
||||
"integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==",
|
||||
"version": "6.3.289",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz",
|
||||
"integrity": "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=22.13.0 || >=24"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.100"
|
||||
"@napi-rs/canvas": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
@@ -10722,9 +10727,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -10741,7 +10746,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.18",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -11113,9 +11118,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.15.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
|
||||
"integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==",
|
||||
"version": "7.18.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
|
||||
"integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -11135,12 +11140,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.15.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz",
|
||||
"integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==",
|
||||
"version": "7.18.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz",
|
||||
"integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.15.1"
|
||||
"react-router": "7.18.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -11646,18 +11651,106 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.4",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.4.tgz",
|
||||
"integrity": "sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==",
|
||||
"version": "2.17.7",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz",
|
||||
"integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"launder": "^1.7.1",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/dom-serializer": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
|
||||
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/domelementtype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
|
||||
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/domutils": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
|
||||
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^3.0.0",
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/entities": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
|
||||
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/htmlparser2": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
|
||||
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"domutils": "^4.0.2",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
@@ -12938,6 +13031,19 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
|
||||
"integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.14",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
|
||||
|
||||
+9
-8
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "lotus-chat",
|
||||
"version": "4.12.3-lotus",
|
||||
"description": "Lotus Chat — Matrix client for Lotus Guild",
|
||||
"description": "Lotus Chat \u2014 Matrix client for Lotus Guild",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "npm run check:eslint && npm run check:prettier",
|
||||
"check:eslint": "eslint src/*",
|
||||
"check:eslint": "eslint src/* --max-warnings 68",
|
||||
"check:prettier": "prettier --check .",
|
||||
"fix:prettier": "prettier --write .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -75,13 +75,13 @@
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.20.0",
|
||||
"jsqr": "1.4.0",
|
||||
"katex": "0.16.11",
|
||||
"katex": "0.16.47",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"matrix-js-sdk": "41.7.0",
|
||||
"matrix-widget-api": "1.17.0",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "5.7.284",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
"prismjs": "1.30.0",
|
||||
"qrcode": "1.5.4",
|
||||
"qrcode.react": "4.2.0",
|
||||
@@ -94,8 +94,8 @@
|
||||
"react-google-recaptcha": "3.1.0",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-range": "1.10.0",
|
||||
"react-router-dom": "7.15.1",
|
||||
"sanitize-html": "2.17.4",
|
||||
"react-router-dom": "7.18.3",
|
||||
"sanitize-html": "2.17.7",
|
||||
"slate": "0.124.1",
|
||||
"slate-dom": "0.124.1",
|
||||
"slate-history": "0.113.1",
|
||||
@@ -146,7 +146,8 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@giphy/js-util": {
|
||||
"dompurify": ">=3.3.4"
|
||||
"dompurify": ">=3.3.4",
|
||||
"uuid": ">=11.1.1"
|
||||
},
|
||||
"js-cookie": ">=3.0.6"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
// Boot-check (Gitea #92): after `npm run build`, nothing actually loaded the
|
||||
// built dist/ — a build that produces a broken bundle (bad base path, a 500
|
||||
// from an asset, a malformed config.json) still went green. This script
|
||||
// serves dist/ the same way production does (vite preview) and makes a
|
||||
// handful of real HTTP requests against it, so a broken bundle fails CI
|
||||
// instead of surfacing after deploy.
|
||||
//
|
||||
// No Playwright here: playwright-core is not a project dependency (only the
|
||||
// browser binary caches happen to be present on this machine), so we don't
|
||||
// depend on it being installed. Plain fetch is enough to catch the class of
|
||||
// bug this check exists for — a page/asset/config that doesn't come back.
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const PORT = 4173;
|
||||
const HOST = '127.0.0.1';
|
||||
const BASE_URL = `http://${HOST}:${PORT}`;
|
||||
const BOOT_TIMEOUT_MS = 30_000;
|
||||
|
||||
function log(msg) {
|
||||
console.log(`[boot-check] ${msg}`);
|
||||
}
|
||||
|
||||
function waitForPort(url, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const attempt = async () => {
|
||||
try {
|
||||
const res = await fetch(url, { method: 'GET' });
|
||||
return res;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = async () => {
|
||||
const res = await attempt();
|
||||
if (res) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
reject(new Error(`Timed out waiting for ${url} to come up`));
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, 300);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
async function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(`Assertion failed: ${message}`);
|
||||
}
|
||||
log(`ok: ${message}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const preview = spawn(
|
||||
'npx',
|
||||
['vite', 'preview', '--port', String(PORT), '--strictPort', '--host', HOST],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
|
||||
let previewOutput = '';
|
||||
preview.stdout.on('data', (d) => (previewOutput += d.toString()));
|
||||
preview.stderr.on('data', (d) => (previewOutput += d.toString()));
|
||||
|
||||
const cleanup = () => {
|
||||
if (!preview.killed) {
|
||||
preview.kill('SIGTERM');
|
||||
}
|
||||
};
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', () => {
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForPort(BASE_URL, BOOT_TIMEOUT_MS);
|
||||
|
||||
// 1. index page loads and contains the SPA mount point.
|
||||
const indexRes = await fetch(`${BASE_URL}/`);
|
||||
await assert(indexRes.status === 200, `GET / returns 200 (got ${indexRes.status})`);
|
||||
const indexHtml = await indexRes.text();
|
||||
await assert(indexHtml.includes('<div id="root"'), 'GET / contains <div id="root"');
|
||||
|
||||
// 2. runtime config is valid JSON.
|
||||
const configRes = await fetch(`${BASE_URL}/config.json`);
|
||||
await assert(
|
||||
configRes.status === 200,
|
||||
`GET /config.json returns 200 (got ${configRes.status})`,
|
||||
);
|
||||
const configText = await configRes.text();
|
||||
let configJson;
|
||||
try {
|
||||
configJson = JSON.parse(configText);
|
||||
} catch (err) {
|
||||
throw new Error(`GET /config.json is not valid JSON: ${err.message}`);
|
||||
}
|
||||
await assert(
|
||||
typeof configJson === 'object' && configJson !== null,
|
||||
'/config.json parses to an object',
|
||||
);
|
||||
|
||||
// 3. the main JS entry referenced from index.html actually loads.
|
||||
const scriptMatch = indexHtml.match(/<script[^>]+type="module"[^>]+src="([^"]+)"/);
|
||||
await assert(!!scriptMatch, 'index.html references a module script entry');
|
||||
const mainScriptUrl = new URL(scriptMatch[1], BASE_URL).toString();
|
||||
const scriptRes = await fetch(mainScriptUrl);
|
||||
await assert(
|
||||
scriptRes.status === 200,
|
||||
`GET ${scriptMatch[1]} returns 200 (got ${scriptRes.status})`,
|
||||
);
|
||||
const scriptContentType = scriptRes.headers.get('content-type') || '';
|
||||
await assert(
|
||||
/javascript/.test(scriptContentType),
|
||||
`GET ${scriptMatch[1]} has a JS content-type (got "${scriptContentType}")`,
|
||||
);
|
||||
|
||||
// 4. Element Call widget bundle is present.
|
||||
const callRes = await fetch(`${BASE_URL}/public/element-call/index.html`);
|
||||
await assert(
|
||||
callRes.status === 200,
|
||||
`GET /public/element-call/index.html returns 200 (got ${callRes.status})`,
|
||||
);
|
||||
|
||||
log('all checks passed');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(`[boot-check] FAILED: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => {
|
||||
// The killed preview server's stdio pipes can keep the event loop alive
|
||||
// briefly; force the process down promptly with whatever exit code was set.
|
||||
process.exit(process.exitCode ?? 0);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$comment": "Gitea #96. Budgets are seeded from the dist/assets gzip sizes on the tree at seed time, +10% headroom. Regenerate deliberately (not just to silence a failure) when a real feature addition grows the bundle: measure the new gzip sizes and bump these with the same +10% margin.",
|
||||
"totalGzipBytes": 1731120,
|
||||
"largestChunkGzipBytes": 358317
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
// Bundle size budget (Gitea #96). The CI "Report bundle sizes" step printed
|
||||
// numbers with nothing to compare them against, so a bundle could balloon
|
||||
// silently. This script compares dist/assets gzip sizes against a small
|
||||
// checked-in budget (scripts/bundle-budget.json) and prints the same report.
|
||||
//
|
||||
// Mode is passed via argv: `pull_request` fails the build over budget,
|
||||
// anything else (e.g. `push`) only warns — a push has already merged, so
|
||||
// blocking it can't prevent the regression, only delay the deploy of an
|
||||
// otherwise-good commit; the pull_request gate is where this should be caught.
|
||||
import { appendFileSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const mode = process.argv[2] || 'push';
|
||||
const distAssetsDir = path.join(__dirname, '..', 'dist', 'assets');
|
||||
const budgetPath = path.join(__dirname, 'bundle-budget.json');
|
||||
|
||||
function formatKb(bytes) {
|
||||
return `${(bytes / 1024).toFixed(1)} kB`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const budget = JSON.parse(readFileSync(budgetPath, 'utf-8'));
|
||||
|
||||
const jsFiles = readdirSync(distAssetsDir)
|
||||
.filter((f) => f.endsWith('.js') && !f.endsWith('.map'))
|
||||
.sort();
|
||||
|
||||
if (jsFiles.length === 0) {
|
||||
console.error(
|
||||
`[check-bundle-size] No .js files found in ${distAssetsDir} — did the build run?`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = jsFiles.map((name) => {
|
||||
const filePath = path.join(distAssetsDir, name);
|
||||
const size = statSync(filePath).size;
|
||||
const gzipSize = gzipSync(readFileSync(filePath)).length;
|
||||
return { name, size, gzipSize };
|
||||
});
|
||||
|
||||
const totalGzipBytes = rows.reduce((sum, r) => sum + r.gzipSize, 0);
|
||||
const largest = rows.reduce((max, r) => (r.gzipSize > max.gzipSize ? r : max), rows[0]);
|
||||
|
||||
const summaryLines = [
|
||||
'### Bundle sizes',
|
||||
'',
|
||||
'| File | Size | Gzip |',
|
||||
'|------|------|------|',
|
||||
...rows.map((r) => `| ${r.name} | ${formatKb(r.size)} | ${formatKb(r.gzipSize)} |`),
|
||||
'',
|
||||
`**Total gzip:** ${formatKb(totalGzipBytes)} (budget ${formatKb(budget.totalGzipBytes)})`,
|
||||
`**Largest chunk gzip:** ${largest.name} — ${formatKb(largest.gzipSize)} (budget ${formatKb(
|
||||
budget.largestChunkGzipBytes,
|
||||
)})`,
|
||||
];
|
||||
|
||||
const summaryText = summaryLines.join('\n');
|
||||
console.log(summaryText);
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
// Gitea Actions/act_runner honors the same GITHUB_STEP_SUMMARY convention.
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summaryText}\n`);
|
||||
}
|
||||
|
||||
const overBudget = [];
|
||||
if (totalGzipBytes > budget.totalGzipBytes) {
|
||||
overBudget.push(
|
||||
`total gzip ${formatKb(totalGzipBytes)} exceeds budget ${formatKb(budget.totalGzipBytes)}`,
|
||||
);
|
||||
}
|
||||
if (largest.gzipSize > budget.largestChunkGzipBytes) {
|
||||
overBudget.push(
|
||||
`largest chunk (${largest.name}) gzip ${formatKb(largest.gzipSize)} exceeds budget ${formatKb(
|
||||
budget.largestChunkGzipBytes,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (overBudget.length > 0) {
|
||||
const message = `[check-bundle-size] Over budget: ${overBudget.join('; ')}`;
|
||||
if (mode === 'pull_request') {
|
||||
console.error(message);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.warn(`${message} (warning only on "${mode}")`);
|
||||
}
|
||||
} else {
|
||||
console.log('[check-bundle-size] Within budget.');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -631,6 +631,22 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
}, [setCallEmbed]),
|
||||
);
|
||||
|
||||
// [Gitea #58] Warn before an accidental tab close/reload drops the user out
|
||||
// of a live call. Only armed while actually joined, and torn down on
|
||||
// hangup/dispose since this effect re-runs when `joined` flips back to false.
|
||||
useEffect(() => {
|
||||
if (!joined) return undefined;
|
||||
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
// Legacy browsers require returnValue to be set to show the prompt.
|
||||
event.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', onBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
};
|
||||
}, [joined]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -735,12 +751,6 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync pip mode into CallControl so it can adjust behavior accordingly
|
||||
useEffect(() => {
|
||||
if (!callEmbed) return;
|
||||
callEmbed.control.setPipMode(!!pipMode);
|
||||
}, [pipMode, callEmbed]);
|
||||
|
||||
// When entering pip with screenshare active (or screenshare starts while in pip),
|
||||
// enable spotlight so the screenshare fills the pip window.
|
||||
// When screenshare ends, release the spotlight we auto-enabled.
|
||||
|
||||
@@ -209,6 +209,13 @@ type GifPickerProps = {
|
||||
|
||||
export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
||||
|
||||
// Searches (and every keystroke) go straight to Giphy's API, so the picker
|
||||
// is opt-in (Settings → Messages) and must not render or fetch until then.
|
||||
if (!gifPickerEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const containerStyle = lotusTerminal
|
||||
? {
|
||||
|
||||
@@ -21,12 +21,31 @@ type KaTeXProps = {
|
||||
* inline (in its error colour) rather than throwing. The HTML returned by
|
||||
* `renderToString` is produced by our own trusted call from a fixed options
|
||||
* object — it is safe to inject via `dangerouslySetInnerHTML`.
|
||||
*
|
||||
* `maxSize`/`maxExpand` cap how large a single glyph (`\\rule`, etc.) or macro
|
||||
* expansion remote LaTeX can request, and `trust: false` disables commands
|
||||
* that can embed arbitrary HTML/URLs (e.g. `\\includegraphics`, `\\href`) —
|
||||
* without these a hostile `$$...$$` from a remote message can DoS or (via
|
||||
* `trust`) inject unsafe links (Gitea #65). `strict: 'ignore'` keeps unknown-
|
||||
* but-harmless LaTeX from spamming the console as before. Extremely long
|
||||
* source is rendered as plain text rather than handed to KaTeX at all.
|
||||
*/
|
||||
const MAX_LATEX_LENGTH = 5000;
|
||||
|
||||
export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
|
||||
if (latex.length > MAX_LATEX_LENGTH) {
|
||||
const Plain = displayMode ? 'div' : 'span';
|
||||
return <Plain>{latex}</Plain>;
|
||||
}
|
||||
|
||||
const html = katex.renderToString(latex, {
|
||||
displayMode,
|
||||
throwOnError: false,
|
||||
output: 'htmlAndMathml',
|
||||
maxSize: 10,
|
||||
maxExpand: 100,
|
||||
trust: false,
|
||||
strict: 'ignore',
|
||||
});
|
||||
|
||||
const Wrapper = displayMode ? 'div' : 'span';
|
||||
@@ -34,7 +53,7 @@ export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
|
||||
return (
|
||||
<Wrapper
|
||||
// KaTeX output is generated by our own render call (trusted-safe).
|
||||
// eslint-disable-next-line react/no-danger
|
||||
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
MessageVerificationRequestContent,
|
||||
} from './content';
|
||||
import { useMessageTranslation } from '../../hooks/useMessageTranslation';
|
||||
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||
import { languageName } from '../../utils/translation/langUtils';
|
||||
import {
|
||||
IAudioContent,
|
||||
@@ -62,8 +63,9 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const prefersReducedMotion =
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
// A one-time matchMedia() read never updated if the OS setting changed mid-session
|
||||
// (Gitea #85); useReducedMotion subscribes to the change event instead.
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -633,6 +635,13 @@ type MLocationProps = {
|
||||
};
|
||||
export function MLocation({ content }: MLocationProps) {
|
||||
const { t } = useTranslation();
|
||||
// The OpenStreetMap iframe used to mount unconditionally on render, silently
|
||||
// handing the sender's coordinates (and this client's IP/UA) to
|
||||
// openstreetmap.org for every location message rendered, autoplay or not.
|
||||
// Gate it behind an explicit click instead (Gitea #66). There's no
|
||||
// location-specific auto-load setting in settings.ts to opt back into this,
|
||||
// so it always requires a click.
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
// Prefer the legacy top-level geo_uri, but fall back to the MSC3488 extensible
|
||||
// location block so events from clients that only send the new shape (uri under
|
||||
// org.matrix.msc3488.location / m.location) still render instead of appearing
|
||||
@@ -660,20 +669,33 @@ export function MLocation({ content }: MLocationProps) {
|
||||
|
||||
return (
|
||||
<Box direction="Column" alignItems="Start" gap="200">
|
||||
<iframe
|
||||
title="Location"
|
||||
src={mapSrc}
|
||||
style={{
|
||||
width: '280px',
|
||||
height: '160px',
|
||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: '8px',
|
||||
display: 'block',
|
||||
}}
|
||||
scrolling="no"
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts"
|
||||
/>
|
||||
{mapLoaded ? (
|
||||
<iframe
|
||||
title="Location"
|
||||
src={mapSrc}
|
||||
style={{
|
||||
width: '280px',
|
||||
height: '160px',
|
||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: '8px',
|
||||
display: 'block',
|
||||
}}
|
||||
scrolling="no"
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="400"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
onClick={() => setMapLoaded(true)}
|
||||
before={<Icon src={Icons.Pin} size="50" />}
|
||||
>
|
||||
<Text size="B300">{t('Organisms.Message.load_map', 'Load map')}</Text>
|
||||
</Button>
|
||||
)}
|
||||
{description && (
|
||||
<Text size="T300" style={{ wordBreak: 'break-word', maxWidth: '280px' }}>
|
||||
{description}
|
||||
|
||||
@@ -183,15 +183,17 @@ export function EarthDayOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Blue-marble Earth tucked into the bottom-right corner ── */}
|
||||
{/* ── Blue-marble Earth tucked into the bottom-right corner ──
|
||||
Kept above the composer strip: bottom offset + size keep the
|
||||
globe's footprint within the top ~90% of the viewport. ── */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '-6%',
|
||||
bottom: '-10%',
|
||||
width: '300px',
|
||||
height: '300px',
|
||||
right: '-4%',
|
||||
bottom: '10%',
|
||||
width: '200px',
|
||||
height: '200px',
|
||||
contain: 'layout paint style',
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
transform: reduced ? 'scale(1.02)' : undefined,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
@@ -12,8 +12,31 @@ import { StackedAvatar } from '../../components/stacked-avatar';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { CallEmbed } from '../../plugins/call/CallEmbed';
|
||||
import { CallControlEvent } from '../../plugins/call/CallControl';
|
||||
import * as css from './styles.css';
|
||||
|
||||
// [Gitea #56] Subscribes to CallControl's focus pin so the menu can render a
|
||||
// "Focus camera" / "Unfocus camera" toggle instead of a one-way pin.
|
||||
function useFocusedUserId(callEmbed?: CallEmbed): string | null {
|
||||
const control = callEmbed?.control;
|
||||
const [focusedUserId, setFocusedUserId] = useState<string | null>(control?.focusedUserId ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!control) {
|
||||
setFocusedUserId(null);
|
||||
return undefined;
|
||||
}
|
||||
setFocusedUserId(control.focusedUserId);
|
||||
const handleUpdate = () => setFocusedUserId(control.focusedUserId);
|
||||
control.on(CallControlEvent.StateUpdate, handleUpdate);
|
||||
return () => {
|
||||
control.off(CallControlEvent.StateUpdate, handleUpdate);
|
||||
};
|
||||
}, [control]);
|
||||
|
||||
return focusedUserId;
|
||||
}
|
||||
|
||||
type ParticipantMenuProps = {
|
||||
anchor: RectCords;
|
||||
name: string;
|
||||
@@ -33,15 +56,23 @@ function ParticipantMenu({
|
||||
profileCords,
|
||||
}: ParticipantMenuProps) {
|
||||
const openUserProfile = useOpenUserRoomProfile();
|
||||
const focusedUserId = useFocusedUserId(callEmbed);
|
||||
const isFocused = focusedUserId === userId;
|
||||
|
||||
const handleViewProfile = () => {
|
||||
onClose();
|
||||
openUserProfile(room.roomId, undefined, userId, profileCords, 'Top');
|
||||
};
|
||||
|
||||
// [Gitea #56] Toggle: focusing the already-focused participant clears the
|
||||
// pin and returns EC to speaker-follows, instead of leaving no way back.
|
||||
const handleFocusCamera = () => {
|
||||
onClose();
|
||||
callEmbed?.control.focusCameraParticipant(userId);
|
||||
if (isFocused) {
|
||||
callEmbed?.control.clearFocusParticipant();
|
||||
} else {
|
||||
callEmbed?.control.focusCameraParticipant(userId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -78,7 +109,7 @@ function ParticipantMenu({
|
||||
before={<Icon size="100" src={Icons.VideoCamera} />}
|
||||
onClick={handleFocusCamera}
|
||||
>
|
||||
<Text size="B300">Focus camera</Text>
|
||||
<Text size="B300">{isFocused ? 'Unfocus camera' : 'Focus camera'}</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
|
||||
@@ -55,7 +55,6 @@ export function RoomQuality({ permissions }: RoomQualityProps) {
|
||||
const [submitState, submit] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (next: RoomQualityContent) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.LotusRoomQuality, next);
|
||||
},
|
||||
[mx, room.roomId],
|
||||
|
||||
@@ -31,7 +31,7 @@ export function RoomRetention({ permissions }: RoomRetentionProps) {
|
||||
const content: RetentionContent = ms > 0 ? { max_lifetime: ms } : {};
|
||||
// Lotus custom-state convention: cast the type key (RoomRetention isn't a
|
||||
// typed key in the SDK's StateEvents map).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.RoomRetention, content);
|
||||
},
|
||||
[mx, room.roomId],
|
||||
|
||||
Binary file not shown.
@@ -205,8 +205,9 @@ export const useLocalMessageSearch = () => {
|
||||
const isMessageLike =
|
||||
evType === EventType.RoomMessage || POLL_START_TYPES.includes(evType);
|
||||
|
||||
// Sender-only mode indexes/returns all message types; text mode needs text.
|
||||
if (!senderOnlyMode && !isMessageLike && !isSticker) continue;
|
||||
// Both modes are restricted to message-like events/stickers — sender-only
|
||||
// mode must not surface membership/state/reaction/redaction events (Gitea #62).
|
||||
if (!isMessageLike && !isSticker) continue;
|
||||
|
||||
const sender = event.getSender() ?? '';
|
||||
const ts = event.getTs();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRoom } from '../../hooks/useRoom';
|
||||
import { useRoomName } from '../../hooks/useRoomMeta';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||
import { RawExportRecord, formatExportBody, resolveMessageEdits } from './exportRoomHistory.utils';
|
||||
|
||||
type ExportFormat = 'txt' | 'json' | 'html';
|
||||
|
||||
@@ -63,15 +64,11 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null;
|
||||
const toTs = toDate ? new Date(`${toDate}T23:59:59`).getTime() : null;
|
||||
|
||||
type MsgRecord = {
|
||||
ts: number;
|
||||
sender: string;
|
||||
body: string;
|
||||
eventId: string;
|
||||
msgtype: string;
|
||||
};
|
||||
|
||||
const collected: MsgRecord[] = [];
|
||||
const rawRecords: RawExportRecord[] = [];
|
||||
// mxc/media-file URL for message events that carry one, keyed by eventId -
|
||||
// surfaced in the JSON export since the export never includes the actual
|
||||
// media (see the UI note below the Export button).
|
||||
const mediaUrlByEventId = new Map<string, string>();
|
||||
// timeline.getEvents() returns the entire growing window on every call,
|
||||
// so we must deduplicate by eventId to avoid re-adding the same events
|
||||
// on each pagination step.
|
||||
@@ -113,11 +110,39 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
if (fromTs !== null && ts < fromTs) continue;
|
||||
if (toTs !== null && ts > toTs) continue;
|
||||
const content = ev.getContent();
|
||||
|
||||
// m.replace (edit) events must not become their own row — collect
|
||||
// them separately and substitute the new content into the original
|
||||
// event's row below, instead of adding a stale original + a garbled
|
||||
// "* new text" duplicate line.
|
||||
const relatesTo = content['m.relates_to'] as
|
||||
| { rel_type?: string; event_id?: string }
|
||||
| undefined;
|
||||
if (relatesTo?.rel_type === 'm.replace' && relatesTo.event_id) {
|
||||
const newContent = content['m.new_content'] as { body?: string } | undefined;
|
||||
const newBody = newContent?.body ?? '';
|
||||
if (!newBody) continue;
|
||||
rawRecords.push({
|
||||
eventId: evId,
|
||||
ts,
|
||||
sender: ev.getSender() ?? '',
|
||||
body: '',
|
||||
msgtype: '',
|
||||
editsEventId: relatesTo.event_id,
|
||||
newBody,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const body: string = content.body ?? '';
|
||||
const msgtype: string = content.msgtype ?? '';
|
||||
if (!body) continue;
|
||||
if (ts < oldestTs) oldestTs = ts;
|
||||
collected.push({
|
||||
const mediaUrl =
|
||||
(content.url as string | undefined) ??
|
||||
(content.file as { url?: string } | undefined)?.url;
|
||||
if (mediaUrl) mediaUrlByEventId.set(evId, mediaUrl);
|
||||
rawRecords.push({
|
||||
ts,
|
||||
sender: ev.getSender() ?? '',
|
||||
body,
|
||||
@@ -125,7 +150,9 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
msgtype,
|
||||
});
|
||||
}
|
||||
setExportCount(collected.length);
|
||||
// Approximate progress — excludes edit rows, which never become their
|
||||
// own line in the final (edit-resolved) output.
|
||||
setExportCount(rawRecords.filter((r) => !r.editsEventId).length);
|
||||
};
|
||||
|
||||
await addEvents(timeline.getEvents());
|
||||
@@ -159,6 +186,10 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
await addEvents(timeline.getEvents());
|
||||
}
|
||||
|
||||
// Resolve m.replace edits against their target row (drops the edit rows,
|
||||
// substitutes m.new_content into the original) before sorting/rendering.
|
||||
const collected = resolveMessageEdits(rawRecords);
|
||||
|
||||
if (cancelled) {
|
||||
setNotice(`Export cancelled after ${collected.length} messages.`);
|
||||
return;
|
||||
@@ -186,7 +217,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
const d = new Date(msg.ts);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const dateLabel = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
lines.push(`[${dateLabel}] ${msg.sender}: ${msg.body}`);
|
||||
lines.push(`[${dateLabel}] ${msg.sender}: ${formatExportBody(msg)}`);
|
||||
}
|
||||
content = lines.join('\n');
|
||||
mimeType = 'text/plain';
|
||||
@@ -201,6 +232,8 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
body: m.body,
|
||||
eventId: m.eventId,
|
||||
type: m.msgtype,
|
||||
edited: m.edited,
|
||||
mediaUrl: mediaUrlByEventId.get(m.eventId),
|
||||
})),
|
||||
};
|
||||
content = JSON.stringify(payload, null, 2);
|
||||
@@ -220,7 +253,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
const d = new Date(msg.ts);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const dateLabel = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
return `<div class="msg"><span class="ts">[${esc(dateLabel)}]</span> <span class="sender">${esc(msg.sender)}</span><span class="body">: ${esc(msg.body)}</span></div>`;
|
||||
return `<div class="msg"><span class="ts">[${esc(dateLabel)}]</span> <span class="sender">${esc(msg.sender)}</span><span class="body">: ${esc(formatExportBody(msg))}</span></div>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
@@ -388,6 +421,11 @@ ${msgRows}
|
||||
{notice}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="T200" priority="300">
|
||||
Attachments (images, videos, audio, files) are not included in the export -
|
||||
media messages are marked with a type label (e.g. "[Image]") but only
|
||||
their filename is exported, not the file itself.
|
||||
</Text>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -63,8 +63,26 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
|
||||
const prevMembership = prevContent.membership as string | undefined;
|
||||
const reason = content.reason as string | undefined;
|
||||
const targetName = getDisplayName(mx, stateKey);
|
||||
// getPrevContent() falls back to {} when unsigned.prev_content is missing,
|
||||
// which is common for state events fetched via back-pagination (homeservers
|
||||
// don't always include it in /messages). That makes prevMembership
|
||||
// indistinguishable from "no prior membership" (fresh join). Check the raw
|
||||
// unsigned field so we only apply the join/leave-transition assumptions
|
||||
// below to events where the SDK actually gave us prior state.
|
||||
const hasPrevContent = ev.getUnsigned().prev_content !== undefined;
|
||||
|
||||
if (membership === 'join') {
|
||||
if (!hasPrevContent) {
|
||||
return {
|
||||
text: (
|
||||
<>
|
||||
<strong>{targetName}</strong>'s membership changed to <strong>joined</strong>
|
||||
</>
|
||||
),
|
||||
iconSrc: Icons.User,
|
||||
filter: 'members',
|
||||
};
|
||||
}
|
||||
if (
|
||||
prevMembership === 'invite' ||
|
||||
prevMembership === 'knock' ||
|
||||
@@ -94,6 +112,17 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
|
||||
}
|
||||
|
||||
if (membership === 'leave') {
|
||||
if (!hasPrevContent) {
|
||||
return {
|
||||
text: (
|
||||
<>
|
||||
<strong>{targetName}</strong>'s membership changed to <strong>left</strong>
|
||||
</>
|
||||
),
|
||||
iconSrc: Icons.User,
|
||||
filter: 'members',
|
||||
};
|
||||
}
|
||||
if (prevMembership === 'ban') {
|
||||
return {
|
||||
text: (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Avatar, Box, Icon, IconButton, Icons, IconSrc, Scroll, Text, color, config } from 'folds';
|
||||
import { EventType } from 'matrix-js-sdk';
|
||||
import { EventType, MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
|
||||
import { Page, PageContent, PageHeader } from '../../components/page';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
@@ -20,6 +20,15 @@ function formatDate(ts: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
function formatUpdatedAt(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Throttle window for re-computing stats on new timeline events - avoids
|
||||
// re-running every heatmap/list computation on every single incoming message
|
||||
// during a burst.
|
||||
const RECOMPUTE_THROTTLE_MS = 2000;
|
||||
|
||||
// ── Section header ────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionHeader({ label }: { label: string }) {
|
||||
@@ -69,6 +78,52 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
||||
const room = useRoom();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [lastUpdated, setLastUpdated] = useState(() => Date.now());
|
||||
// Bumped to force the stats useMemo below to re-run; the value itself is unused.
|
||||
const [recomputeTick, setRecomputeTick] = useState(0);
|
||||
|
||||
const recomputeNow = useCallback(() => {
|
||||
setRecomputeTick((n) => n + 1);
|
||||
setLastUpdated(Date.now());
|
||||
}, []);
|
||||
|
||||
// Stats were previously computed once (keyed only on `room`, whose reference
|
||||
// never changes) and never reflected new activity while the panel stayed
|
||||
// open. Re-run on new timeline events for this room, throttled so a burst of
|
||||
// messages doesn't recompute on every single event.
|
||||
useEffect(() => {
|
||||
let throttleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pending = false;
|
||||
|
||||
const scheduleTrailing = () => {
|
||||
throttleTimer = setTimeout(() => {
|
||||
if (pending) {
|
||||
pending = false;
|
||||
recomputeNow();
|
||||
scheduleTrailing();
|
||||
} else {
|
||||
throttleTimer = undefined;
|
||||
}
|
||||
}, RECOMPUTE_THROTTLE_MS);
|
||||
};
|
||||
|
||||
const handleTimeline = (_event: MatrixEvent, eventRoom: Room | undefined) => {
|
||||
if (eventRoom?.roomId !== room.roomId) return;
|
||||
if (throttleTimer) {
|
||||
pending = true;
|
||||
return;
|
||||
}
|
||||
recomputeNow();
|
||||
scheduleTrailing();
|
||||
};
|
||||
|
||||
mx.on(RoomEvent.Timeline, handleTimeline);
|
||||
return () => {
|
||||
mx.removeListener(RoomEvent.Timeline, handleTimeline);
|
||||
if (throttleTimer) clearTimeout(throttleTimer);
|
||||
};
|
||||
}, [mx, room, recomputeNow]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const events = room.getLiveTimeline().getEvents();
|
||||
|
||||
@@ -137,7 +192,10 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
||||
newestTs,
|
||||
totalCached: events.length,
|
||||
};
|
||||
}, [room]);
|
||||
// recomputeTick is intentionally in the deps (unused in the body) - it's the
|
||||
// signal bumped by the timeline listener above to force this to re-run.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room, recomputeTick]);
|
||||
|
||||
const maxHour = Math.max(...stats.hourBuckets, 1);
|
||||
const maxMsgCount = stats.top5.length > 0 ? (stats.top5[0]?.[1] ?? 1) : 1;
|
||||
@@ -167,7 +225,7 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
||||
{/* ── Disclaimer banner ── */}
|
||||
<SequenceCard variant="SurfaceVariant" gap="200" alignItems="Center">
|
||||
<Icon src={Icons.Warning} size="200" style={{ color: color.Warning.Main }} />
|
||||
<Box direction="Column" gap="100">
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="T300">
|
||||
<strong>
|
||||
Based on {stats.totalMessages} locally cached message
|
||||
@@ -179,6 +237,20 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
||||
from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="T200" priority="300">
|
||||
Last updated {formatUpdatedAt(lastUpdated)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton
|
||||
onClick={recomputeNow}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Refresh insights"
|
||||
>
|
||||
<Icon src={Icons.Reload} size="100" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
|
||||
|
||||
@@ -341,7 +341,7 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
variant="Primary"
|
||||
/>
|
||||
<Box direction="Column" gap="0">
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
{}
|
||||
<label
|
||||
htmlFor="allow-ip-literals"
|
||||
style={{ cursor: canEdit ? 'pointer' : 'default' }}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
resolveMessageEdits,
|
||||
formatExportBody,
|
||||
RawExportRecord,
|
||||
ResolvedExportRecord,
|
||||
} from './exportRoomHistory.utils';
|
||||
|
||||
const msg = (over: Partial<RawExportRecord>): RawExportRecord => ({
|
||||
eventId: '$1',
|
||||
ts: 1000,
|
||||
sender: '@alice:example.org',
|
||||
body: 'hello',
|
||||
msgtype: 'm.text',
|
||||
...over,
|
||||
});
|
||||
|
||||
test('resolveMessageEdits leaves unedited messages untouched', () => {
|
||||
const out = resolveMessageEdits([msg({ eventId: '$1', body: 'hi' })]);
|
||||
assert.deepEqual(out, [
|
||||
{
|
||||
eventId: '$1',
|
||||
ts: 1000,
|
||||
sender: '@alice:example.org',
|
||||
body: 'hi',
|
||||
msgtype: 'm.text',
|
||||
edited: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveMessageEdits substitutes the new content into the original row and drops the edit row', () => {
|
||||
const out = resolveMessageEdits([
|
||||
msg({ eventId: '$1', body: 'origianl typo' }),
|
||||
msg({ eventId: '$2', editsEventId: '$1', newBody: 'original fixed' }),
|
||||
]);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].eventId, '$1');
|
||||
assert.equal(out[0].body, 'original fixed');
|
||||
assert.equal(out[0].edited, true);
|
||||
});
|
||||
|
||||
test('resolveMessageEdits works regardless of whether the edit appears before its target', () => {
|
||||
const out = resolveMessageEdits([
|
||||
msg({ eventId: '$2', editsEventId: '$1', newBody: 'fixed' }),
|
||||
msg({ eventId: '$1', body: 'orig' }),
|
||||
]);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].body, 'fixed');
|
||||
assert.equal(out[0].edited, true);
|
||||
});
|
||||
|
||||
test('resolveMessageEdits ignores an edit with no matching target (target never collected)', () => {
|
||||
const out = resolveMessageEdits([msg({ eventId: '$2', editsEventId: '$1', newBody: 'fixed' })]);
|
||||
assert.deepEqual(out, []);
|
||||
});
|
||||
|
||||
test('formatExportBody prefixes media messages with a type marker', () => {
|
||||
const record: ResolvedExportRecord = {
|
||||
eventId: '$1',
|
||||
ts: 1000,
|
||||
sender: '@alice:example.org',
|
||||
body: 'photo.jpg',
|
||||
msgtype: 'm.image',
|
||||
edited: false,
|
||||
};
|
||||
assert.equal(formatExportBody(record), '[Image] photo.jpg');
|
||||
});
|
||||
|
||||
test('formatExportBody appends an (edited) suffix', () => {
|
||||
const record: ResolvedExportRecord = {
|
||||
eventId: '$1',
|
||||
ts: 1000,
|
||||
sender: '@alice:example.org',
|
||||
body: 'fixed text',
|
||||
msgtype: 'm.text',
|
||||
edited: true,
|
||||
};
|
||||
assert.equal(formatExportBody(record), 'fixed text (edited)');
|
||||
});
|
||||
|
||||
test('formatExportBody combines media prefix and edited suffix', () => {
|
||||
const record: ResolvedExportRecord = {
|
||||
eventId: '$1',
|
||||
ts: 1000,
|
||||
sender: '@alice:example.org',
|
||||
body: 'photo.jpg',
|
||||
msgtype: 'm.file',
|
||||
edited: true,
|
||||
};
|
||||
assert.equal(formatExportBody(record), '[File] photo.jpg (edited)');
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// Pure helpers for ExportRoomHistory.tsx, kept SDK/DOM-free so they're easy to
|
||||
// unit test in isolation.
|
||||
|
||||
export type RawExportRecord = {
|
||||
eventId: string;
|
||||
ts: number;
|
||||
sender: string;
|
||||
body: string;
|
||||
msgtype: string;
|
||||
// Set when this record IS an m.replace edit event, to the event id it targets.
|
||||
editsEventId?: string;
|
||||
// The m.new_content.body carried by an m.replace edit event.
|
||||
newBody?: string;
|
||||
};
|
||||
|
||||
export type ResolvedExportRecord = {
|
||||
eventId: string;
|
||||
ts: number;
|
||||
sender: string;
|
||||
body: string;
|
||||
msgtype: string;
|
||||
edited: boolean;
|
||||
};
|
||||
|
||||
// Human-readable label for media msgtypes, used to prefix txt/html export rows
|
||||
// so a media message isn't indistinguishable from a plain-text one.
|
||||
export const MEDIA_TYPE_LABELS: Partial<Record<string, string>> = {
|
||||
'm.image': 'Image',
|
||||
'm.video': 'Video',
|
||||
'm.audio': 'Audio',
|
||||
'm.file': 'File',
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves m.replace (edit) events against the original message they target:
|
||||
* the edit event is dropped from the output (it should not appear as its own
|
||||
* row), and the original message's body is replaced with the edit's
|
||||
* `m.new_content.body`, marked `edited: true`.
|
||||
*
|
||||
* Order-independent: edits may appear before or after their target in the
|
||||
* input (e.g. across separate back-pagination batches).
|
||||
*/
|
||||
export function resolveMessageEdits(records: RawExportRecord[]): ResolvedExportRecord[] {
|
||||
const edits = new Map<string, string>();
|
||||
for (const record of records) {
|
||||
if (record.editsEventId && record.newBody) {
|
||||
edits.set(record.editsEventId, record.newBody);
|
||||
}
|
||||
}
|
||||
|
||||
const resolved: ResolvedExportRecord[] = [];
|
||||
for (const record of records) {
|
||||
// Edit events never become their own row.
|
||||
if (record.editsEventId) continue;
|
||||
const editedBody = edits.get(record.eventId);
|
||||
resolved.push({
|
||||
eventId: record.eventId,
|
||||
ts: record.ts,
|
||||
sender: record.sender,
|
||||
body: editedBody ?? record.body,
|
||||
msgtype: record.msgtype,
|
||||
edited: editedBody !== undefined,
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Prefixes a media message's body with a "[Image]"-style marker for txt/html
|
||||
// export output, and appends an "(edited)" suffix when applicable. Plain-text
|
||||
// messages are returned unchanged (aside from the edited suffix).
|
||||
export function formatExportBody(record: ResolvedExportRecord): string {
|
||||
const label = MEDIA_TYPE_LABELS[record.msgtype];
|
||||
const mediaPrefix = label ? `[${label}] ` : '';
|
||||
const editedSuffix = record.edited ? ' (edited)' : '';
|
||||
return `${mediaPrefix}${record.body}${editedSuffix}`;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import classNames from 'classnames';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { Membership } from '../../../types/matrix/room';
|
||||
|
||||
@@ -71,6 +72,7 @@ import { MemberVerificationBadge } from '../../components/MemberVerificationBadg
|
||||
import { useUserPresence } from '../../hooks/useUserPresence';
|
||||
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
|
||||
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
|
||||
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
||||
|
||||
type MemberDrawerHeaderProps = {
|
||||
room: Room;
|
||||
@@ -239,11 +241,55 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
const myUserId = mx.getUserId();
|
||||
const myPowerLevel = readPowerLevel.user(powerLevels, myUserId ?? undefined);
|
||||
const invitePowerLevel = readPowerLevel.action(powerLevels, 'invite');
|
||||
const kickPowerLevel = readPowerLevel.action(powerLevels, 'kick');
|
||||
const canApproveKnock = myPowerLevel >= invitePowerLevel;
|
||||
// Deny uses mx.kick, which is gated by the room's kick power level, not invite -
|
||||
// these can differ (e.g. moderators can invite but only admins can kick).
|
||||
const canDenyKnock = myPowerLevel >= kickPowerLevel;
|
||||
const knockMembers = useMemo(
|
||||
() => (canApproveKnock ? room.getMembersWithMembership(Membership.Knock) : []),
|
||||
[room, canApproveKnock],
|
||||
);
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
const [pendingKnockAction, setPendingKnockAction] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleApproveKnock = useCallback(
|
||||
(userId: string) => {
|
||||
setPendingKnockAction(userId);
|
||||
mx.invite(room.roomId, userId)
|
||||
.catch((err: unknown) => {
|
||||
console.error('Failed to approve knock request:', err);
|
||||
setToast(
|
||||
createErrorToast(
|
||||
'Could not approve this request. Please try again.',
|
||||
Icons.Warning,
|
||||
'Failed',
|
||||
),
|
||||
);
|
||||
})
|
||||
.finally(() => setPendingKnockAction(undefined));
|
||||
},
|
||||
[mx, room.roomId, setToast],
|
||||
);
|
||||
|
||||
const handleDenyKnock = useCallback(
|
||||
(userId: string) => {
|
||||
setPendingKnockAction(userId);
|
||||
mx.kick(room.roomId, userId)
|
||||
.catch((err: unknown) => {
|
||||
console.error('Failed to deny knock request:', err);
|
||||
setToast(
|
||||
createErrorToast(
|
||||
'Could not deny this request. Please try again.',
|
||||
Icons.Warning,
|
||||
'Failed',
|
||||
),
|
||||
);
|
||||
})
|
||||
.finally(() => setPendingKnockAction(undefined));
|
||||
},
|
||||
[mx, room.roomId, setToast],
|
||||
);
|
||||
|
||||
const filteredMembers = useMemo(
|
||||
() => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort),
|
||||
@@ -462,20 +508,24 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
radii="300"
|
||||
fill="Soft"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => mx.invite(room.roomId, knockMember.userId)}
|
||||
disabled={pendingKnockAction === knockMember.userId}
|
||||
onClick={() => handleApproveKnock(knockMember.userId)}
|
||||
>
|
||||
<Text size="B300">Approve</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
radii="300"
|
||||
fill="Soft"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => mx.kick(room.roomId, knockMember.userId)}
|
||||
>
|
||||
<Text size="B300">Deny</Text>
|
||||
</Button>
|
||||
{canDenyKnock && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
radii="300"
|
||||
fill="Soft"
|
||||
className={MobileTouchTarget}
|
||||
disabled={pendingKnockAction === knockMember.userId}
|
||||
onClick={() => handleDenyKnock(knockMember.userId)}
|
||||
>
|
||||
<Text size="B300">Deny</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -241,7 +241,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const showFormat = composerToolbarButtons?.showFormat ?? true;
|
||||
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
||||
const showSticker = composerToolbarButtons?.showSticker ?? true;
|
||||
const showGif = composerToolbarButtons?.showGif ?? true;
|
||||
// [Gitea #68] The GIF picker is opt-in (searches go to Giphy); hide the
|
||||
// toolbar button entirely when it's off so it never opens an empty popover.
|
||||
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
||||
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
|
||||
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
||||
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||||
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
recentForwardTargetsAtom,
|
||||
addRecentForwardTarget,
|
||||
} from '../../../state/recentForwardTargets';
|
||||
import { buildForwardContent } from './forwardContent';
|
||||
import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent';
|
||||
|
||||
type RoomRowProps = {
|
||||
room: Room;
|
||||
@@ -314,12 +314,43 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
setError(null);
|
||||
|
||||
const ids = [...selectedRoomIds];
|
||||
|
||||
// `fwdContent.file` (present on encrypted attachments) carries the AES key/iv
|
||||
// needed to decrypt it. Sending it as-is into a room that isn't encrypted would
|
||||
// publish that key in plaintext (Gitea #63), so any unencrypted destination gets
|
||||
// a decrypted-and-re-uploaded plaintext version instead. Built once (not per
|
||||
// room) since every unencrypted destination gets the same re-upload.
|
||||
let plaintextContent: Record<string, unknown> | undefined;
|
||||
let plaintextContentError: string | undefined;
|
||||
if (fwdContent.file) {
|
||||
const needsPlaintext = ids.some((id) => !mx.getRoom(id)?.hasEncryptionStateEvent());
|
||||
if (needsPlaintext) {
|
||||
try {
|
||||
plaintextContent = await buildPlaintextAttachmentContent(
|
||||
mx,
|
||||
fwdContent,
|
||||
useAuthentication,
|
||||
);
|
||||
} catch {
|
||||
plaintextContentError = 'Could not prepare this attachment for an unencrypted room.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commentBody = comment.trim();
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => {
|
||||
const destEncrypted = !!mx.getRoom(id)?.hasEncryptionStateEvent();
|
||||
// Encrypted destinations keep the original (possibly encrypted-attachment)
|
||||
// content; unencrypted ones get the plaintext version, or fail outright if
|
||||
// that couldn't be built — never fall back to sending the encrypted `file`.
|
||||
const contentToSend = fwdContent.file && !destEncrypted ? plaintextContent : fwdContent;
|
||||
if (fwdContent.file && !destEncrypted && !contentToSend) {
|
||||
return Promise.reject(new Error(plaintextContentError));
|
||||
}
|
||||
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent);
|
||||
const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, contentToSend);
|
||||
// Send the optional comment first so it reads as a note above the
|
||||
// forwarded content. The room counts as failed if either send rejects.
|
||||
// Track rooms whose comment already landed so a retry (after the FORWARD
|
||||
@@ -370,7 +401,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
return;
|
||||
}
|
||||
setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`);
|
||||
}, [mx, mEvent, onClose, sending, selectedRoomIds, comment, setRecents]);
|
||||
}, [mx, mEvent, onClose, sending, selectedRoomIds, comment, setRecents, useAuthentication]);
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
||||
import { IEncryptedFile } from '../../../../types/matrix/common';
|
||||
|
||||
/**
|
||||
* Build the content to forward:
|
||||
@@ -40,3 +43,44 @@ export function buildForwardContent(
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* `content.file` on an encrypted attachment carries the AES key/iv/hashes
|
||||
* needed to decrypt it. Forwarding that content verbatim into a room that
|
||||
* isn't itself encrypted would publish the key in plaintext to anyone who can
|
||||
* read the destination room (Gitea #63). Re-encrypting for the destination is
|
||||
* out of scope, so instead download+decrypt the attachment here and re-upload
|
||||
* it as a plain (unencrypted) upload, sending `url` in place of `file`.
|
||||
*
|
||||
* Throws if the attachment can't be fetched/decrypted — callers must treat
|
||||
* that as a hard failure for this forward rather than falling back to
|
||||
* sending the encrypted `file` block into the plaintext room.
|
||||
*/
|
||||
export async function buildPlaintextAttachmentContent(
|
||||
mx: MatrixClient,
|
||||
content: Record<string, unknown>,
|
||||
useAuthentication: boolean,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const file = content.file as IEncryptedFile;
|
||||
const info = content.info as Record<string, unknown> | undefined;
|
||||
const mimeType = (info?.mimetype as string | undefined) ?? FALLBACK_MIMETYPE;
|
||||
|
||||
const mediaUrl = mxcUrlToHttp(mx, file.url, useAuthentication);
|
||||
if (!mediaUrl) throw new Error('Invalid attachment URL');
|
||||
const blob = await downloadEncryptedMedia(mediaUrl, (buf) => decryptFile(buf, mimeType, file));
|
||||
const uploadResult = await mx.uploadContent(blob, { type: mimeType });
|
||||
|
||||
const plainContent = { ...content };
|
||||
delete plainContent.file;
|
||||
plainContent.url = uploadResult.content_uri;
|
||||
|
||||
// The thumbnail can carry its own encryption key (`thumbnail_file`); drop it
|
||||
// rather than leak it too — the full attachment still forwards fine without
|
||||
// a thumbnail.
|
||||
if (info && (info.thumbnail_file || info.thumbnail_url)) {
|
||||
const { thumbnail_file: _tf, thumbnail_url: _tu, ...restInfo } = info;
|
||||
plainContent.info = restInfo;
|
||||
}
|
||||
|
||||
return plainContent;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||
clientApi.stop();
|
||||
iframe.remove();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mx, room.roomId, widget.id, widget.templateUrl]);
|
||||
|
||||
if (blocked) {
|
||||
|
||||
@@ -84,7 +84,6 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
||||
data: {},
|
||||
};
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.Widget, content, id);
|
||||
setAdding(false);
|
||||
} catch (e) {
|
||||
@@ -96,7 +95,7 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
if (viewingId === id) setViewingId(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
sendStateEvent(mx, room.roomId, StateEvent.Widget, {}, id).catch(() => undefined);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, {
|
||||
ChangeEventHandler,
|
||||
FocusEventHandler,
|
||||
FormEventHandler,
|
||||
KeyboardEventHandler,
|
||||
MouseEventHandler,
|
||||
@@ -395,6 +396,16 @@ function PageZoomInput() {
|
||||
setCurrentZoom(evt.target.value);
|
||||
};
|
||||
|
||||
// Shared by Enter and blur so a typed-but-unconfirmed value is never
|
||||
// silently dropped when the input loses focus.
|
||||
const commitZoom = (value: string) => {
|
||||
const newZoom = parseInt(value, 10);
|
||||
if (Number.isNaN(newZoom)) return;
|
||||
const safeZoom = Math.max(Math.min(newZoom, 150), 75);
|
||||
setPageZoom(safeZoom);
|
||||
setCurrentZoom(safeZoom.toString());
|
||||
};
|
||||
|
||||
const handleZoomEnter: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
evt.stopPropagation();
|
||||
@@ -405,14 +416,14 @@ function PageZoomInput() {
|
||||
'value' in evt.target &&
|
||||
typeof evt.target.value === 'string'
|
||||
) {
|
||||
const newZoom = parseInt(evt.target.value, 10);
|
||||
if (Number.isNaN(newZoom)) return;
|
||||
const safeZoom = Math.max(Math.min(newZoom, 150), 75);
|
||||
setPageZoom(safeZoom);
|
||||
setCurrentZoom(safeZoom.toString());
|
||||
commitZoom(evt.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomBlur: FocusEventHandler<HTMLInputElement> = (evt) => {
|
||||
commitZoom(evt.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
style={{ width: toRem(100) }}
|
||||
@@ -426,6 +437,7 @@ function PageZoomInput() {
|
||||
value={currentZoom}
|
||||
onChange={handleZoomChange}
|
||||
onKeyDown={handleZoomEnter}
|
||||
onBlur={handleZoomBlur}
|
||||
after={<Text size="T300">%</Text>}
|
||||
outlined
|
||||
/>
|
||||
@@ -2344,6 +2356,7 @@ function Messages() {
|
||||
'translateTargetLang',
|
||||
);
|
||||
const [autoTranslate, setAutoTranslate] = useSetting(settingsAtom, 'autoTranslate');
|
||||
const [gifPickerEnabled, setGifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
||||
const translationSupported = chromeTranslationEngine.isSupported();
|
||||
const selectedTargetLang = isSupportedTargetLang(translateTargetLang)
|
||||
? normalizeLang(translateTargetLang)
|
||||
@@ -2446,6 +2459,15 @@ function Messages() {
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="GIF Picker"
|
||||
description="Every search term (and your IP address) is sent directly to Giphy, not through your homeserver. Off by default."
|
||||
after={
|
||||
<Switch variant="Primary" value={gifPickerEnabled} onChange={setGifPickerEnabled} />
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Show Hidden Events"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Room, RoomEvent, RoomMemberEvent, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { Room, RoomEvent, RoomMember, RoomMemberEvent, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { reactionOrEditEvent } from '../utils/room';
|
||||
@@ -52,15 +52,21 @@ export function useRoomReadPositions(room: Room): Map<string, string[]> {
|
||||
debounceTimer = null;
|
||||
}, 150);
|
||||
};
|
||||
const onMembership = (): void => setPositions(computePositions(room, myUserId));
|
||||
// RoomMemberEvent.Membership is emitted on the RoomMember (and re-emitted on the
|
||||
// MatrixClient), never on Room — listening on `room` never fired. Subscribe on
|
||||
// the client and filter to this room (Gitea #61).
|
||||
const onMembership = (_ev: MatrixEvent, member: RoomMember): void => {
|
||||
if (member.roomId !== room.roomId) return;
|
||||
setPositions(computePositions(room, myUserId));
|
||||
};
|
||||
room.on(RoomEvent.Receipt, onReceipt);
|
||||
(room as any).on(RoomMemberEvent.Membership, onMembership);
|
||||
mx.on(RoomMemberEvent.Membership, onMembership);
|
||||
return () => {
|
||||
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
||||
room.removeListener(RoomEvent.Receipt, onReceipt);
|
||||
(room as any).removeListener(RoomMemberEvent.Membership, onMembership);
|
||||
mx.removeListener(RoomMemberEvent.Membership, onMembership);
|
||||
};
|
||||
}, [room, myUserId]);
|
||||
}, [room, myUserId, mx]);
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from 'react';
|
||||
import { getFallbackSession, subscribeSessionChanges } from '../state/sessions';
|
||||
|
||||
/**
|
||||
* #69 — `useSessionSync` (the full cross-tab session sync, with in-place token
|
||||
* rotation handling) is mounted only inside the authenticated `ClientRoot`
|
||||
* shell, so a tab parked on the login/register/OIDC-callback pages never
|
||||
* learns that another tab just signed in. Left alone, that tab keeps showing
|
||||
* the login form and, if submitted, starts a second device/session for the
|
||||
* same account.
|
||||
*
|
||||
* This is a deliberately lighter watcher for the unauthenticated routes: it
|
||||
* only cares about a session APPEARING where there was none before (a fresh
|
||||
* login completed in another tab) and reloads so this tab picks up the
|
||||
* authenticated shell instead. It does not need `useSessionSync`'s
|
||||
* rotation/relogin handling since there is no running client here to keep in
|
||||
* sync — do not import/duplicate that logic, just reload.
|
||||
*/
|
||||
export function useSessionAppeared(): void {
|
||||
useEffect(() => {
|
||||
// If we already have a session, there's nothing to watch for here — the
|
||||
// authenticated shell's own useSessionSync takes over once mounted.
|
||||
if (getFallbackSession()) return undefined;
|
||||
|
||||
const unsubscribe = subscribeSessionChanges((session) => {
|
||||
if (session) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
}
|
||||
@@ -14,5 +14,11 @@ export function useTauriCallPower(): void {
|
||||
const callEmbed = useAtomValue(callEmbedAtom);
|
||||
useEffect(() => {
|
||||
invokeTauri('set_call_active', { active: callEmbed !== undefined });
|
||||
// #76 — if the shell unmounts while a call is still active (forced
|
||||
// reload/HMR, or a future teardown path that doesn't end the call first),
|
||||
// release the native keep-awake state instead of leaving it held forever.
|
||||
return () => {
|
||||
invokeTauri('set_call_active', { active: false });
|
||||
};
|
||||
}, [callEmbed]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { MsgType } from 'matrix-js-sdk';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Icons } from 'folds';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useTauriEvent } from './useTauri';
|
||||
import { createErrorToast, toastQueueAtom } from '../state/toast';
|
||||
|
||||
/** Payload of the `lotus-notification-activate` event (a plain body click). */
|
||||
interface ActivateDetail {
|
||||
@@ -27,6 +30,7 @@ interface ReplyDetail {
|
||||
export function useTauriToastActions(): void {
|
||||
const navigate = useNavigate();
|
||||
const mx = useMatrixClient();
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
|
||||
useTauriEvent<ActivateDetail>('lotus-notification-activate', ({ path }) => {
|
||||
if (path) navigate(path);
|
||||
@@ -34,6 +38,17 @@ export function useTauriToastActions(): void {
|
||||
|
||||
useTauriEvent<ReplyDetail>('lotus-notification-reply', ({ roomId, text }) => {
|
||||
if (!roomId || !text) return;
|
||||
mx.sendMessage(roomId, { msgtype: MsgType.Text, body: text }).catch(() => undefined);
|
||||
// #79 — a quick-reply failure (offline, no send permission, etc.) used to
|
||||
// be swallowed with no feedback, leaving the user believing it was sent.
|
||||
// Surface it via the in-app error toast instead.
|
||||
mx.sendMessage(roomId, { msgtype: MsgType.Text, body: text }).catch(() => {
|
||||
setToast(
|
||||
createErrorToast(
|
||||
'Your quick reply could not be sent. Please try again.',
|
||||
Icons.Warning,
|
||||
'Reply failed',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { settingsAtom } from '../state/settings';
|
||||
import { isWithinTimeWindow } from '../utils/timeWindow';
|
||||
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
||||
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
||||
import { useSessionAppeared } from '../hooks/useSessionAppeared';
|
||||
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
|
||||
import { isTauri } from '../hooks/useTauri';
|
||||
import { TitleBar } from '../features/desktop/TitleBar';
|
||||
@@ -165,6 +166,12 @@ const queryClient = new QueryClient();
|
||||
function App() {
|
||||
const screenSize = useScreenSize();
|
||||
useCompositionEndTracking();
|
||||
// #69 — cross-tab session sync (useSessionSync) only mounts inside the
|
||||
// authenticated ClientRoot shell, so a tab still on the unauthenticated
|
||||
// routes (login/register/OIDC callback) never learns that another tab just
|
||||
// signed in. This lighter watcher covers that gap by reloading once a
|
||||
// session appears where there was none.
|
||||
useSessionAppeared();
|
||||
|
||||
const portalContainer = document.getElementById('portalContainer') ?? undefined;
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
// re-observe pass so a busy EC re-render doesn't thrash the control observer.
|
||||
private bodyMutationTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
private _pipMode = false;
|
||||
// [Gitea #56] Tracks the participant currently pinned via focusCameraParticipant(),
|
||||
// so callers (MemberGlance) can render a "Focus camera" / "Unfocus camera" toggle
|
||||
// instead of a one-way pin with no way back. null == no manual pin (speaker-follows).
|
||||
private _focusedUserId: string | null = null;
|
||||
|
||||
// C-M3: last quality payload requested via setQuality(). Held so we can (re)send
|
||||
// it once joined (io.lotus.set_quality must not be sent before call-join — a
|
||||
@@ -422,10 +425,6 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
this.spotlightButton?.click();
|
||||
}
|
||||
|
||||
public setPipMode(pip: boolean) {
|
||||
this._pipMode = pip;
|
||||
}
|
||||
|
||||
public toggleReactions() {
|
||||
this.reactionsButton?.click();
|
||||
}
|
||||
@@ -446,17 +445,29 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
* participant has their camera off and EC didn't render a video tile for
|
||||
* them yet).
|
||||
*/
|
||||
public get focusedUserId(): string | null {
|
||||
return this._focusedUserId;
|
||||
}
|
||||
|
||||
public focusCameraParticipant(userId: string): void {
|
||||
// [lotus #4] Pin the participant via the fork's widget action instead of
|
||||
// DOM-poking tiles. EC's layout honors it — including surfacing the camera
|
||||
// alongside a screenshare (A5) — and it's version-stable. The fork always
|
||||
// acks, so the promise resolves regardless.
|
||||
this._focusedUserId = userId;
|
||||
this.call.transport.send('io.lotus.focus_participant', { userId }).catch(() => undefined);
|
||||
// [Gitea #56] Notify state-update listeners so the menu can flip to "Unfocus camera".
|
||||
this.emitStateUpdate();
|
||||
}
|
||||
|
||||
/** [lotus #4] Clear any manual spotlight pin and return to speaker-follows. */
|
||||
public clearFocusParticipant(): void {
|
||||
// [Gitea #56] No-op (and no redundant widget send) if nothing is pinned —
|
||||
// dispose() calls this unconditionally on every call teardown.
|
||||
if (this._focusedUserId === null) return;
|
||||
this._focusedUserId = null;
|
||||
this.call.transport.send('io.lotus.focus_participant', { userId: null }).catch(() => undefined);
|
||||
this.emitStateUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -502,6 +513,8 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
clearTimeout(this.bodyMutationTimer);
|
||||
this.bodyMutationTimer = undefined;
|
||||
}
|
||||
// [Gitea #56] Don't let a manual focus pin outlive the call.
|
||||
this.clearFocusParticipant();
|
||||
this.bodyMutationObserver.disconnect();
|
||||
this.controlMutationObserver.disconnect();
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ export const usePdfDocumentLoader = (pdfJS: typeof PdfJsDist | undefined, src: s
|
||||
if (!pdfJS) {
|
||||
throw new Error('PdfJS is not loaded');
|
||||
}
|
||||
const doc = await pdfJS.getDocument(src).promise;
|
||||
// pdfjs-dist 6 dropped the bare-string overload; pass the URL explicitly.
|
||||
const doc = await pdfJS.getDocument({ url: src }).promise;
|
||||
return doc;
|
||||
}, [pdfJS, src]),
|
||||
);
|
||||
|
||||
@@ -56,6 +56,18 @@ test('coerces unknown persisted denoise model / ringtone id back to defaults', (
|
||||
assert.equal(valid.ringtoneId, 'chime');
|
||||
});
|
||||
|
||||
test('coerces unknown persisted seasonalThemeOverride back to auto', () => {
|
||||
setStored(null);
|
||||
const defaults = getSettings();
|
||||
assert.equal(defaults.seasonalThemeOverride, 'auto');
|
||||
|
||||
setStored(JSON.stringify({ seasonalThemeOverride: 'retired-theme' }));
|
||||
assert.equal(getSettings().seasonalThemeOverride, 'auto');
|
||||
|
||||
setStored(JSON.stringify({ seasonalThemeOverride: 'earthday' }));
|
||||
assert.equal(getSettings().seasonalThemeOverride, 'earthday');
|
||||
});
|
||||
|
||||
test('merges stored values over defaults', () => {
|
||||
setStored(JSON.stringify({ callNoiseSuppression: 'off', someUnknownKey: 1 }));
|
||||
const s = getSettings();
|
||||
|
||||
+45
-14
@@ -25,6 +25,38 @@ export type DenoiseModelId = 'rnnoise' | 'speex' | 'dtln' | 'deepfilternet';
|
||||
// 'soft' / 'retro' are synthesized in-browser (see utils/ringtones.ts);
|
||||
// 'none' is silent (visual-only incoming-call UI).
|
||||
export type RingtoneId = 'classic' | 'chime' | 'soft' | 'retro' | 'none';
|
||||
|
||||
export type SeasonalThemeOverride =
|
||||
| 'auto'
|
||||
| 'off'
|
||||
| 'halloween'
|
||||
| 'christmas'
|
||||
| 'newyear'
|
||||
| 'autumn'
|
||||
| 'aprilfools'
|
||||
| 'lunar'
|
||||
| 'valentines'
|
||||
| 'stpatricks'
|
||||
| 'earthday'
|
||||
| 'deepspace'
|
||||
| 'arcade';
|
||||
|
||||
// Allow-list used to validate persisted values below.
|
||||
const SEASONAL_THEME_OVERRIDES: SeasonalThemeOverride[] = [
|
||||
'auto',
|
||||
'off',
|
||||
'halloween',
|
||||
'christmas',
|
||||
'newyear',
|
||||
'autumn',
|
||||
'aprilfools',
|
||||
'lunar',
|
||||
'valentines',
|
||||
'stpatricks',
|
||||
'earthday',
|
||||
'deepspace',
|
||||
'arcade',
|
||||
];
|
||||
// [P5-31] Granular call quality caps. 'auto' = don't cap (the EC fork keeps its
|
||||
// default encoding). Numbers are kbps (audio/screenshare bitrate) or fps
|
||||
// (screenshare framerate); converted to the fork's bits/sec + fps payload in
|
||||
@@ -254,24 +286,15 @@ export interface Settings {
|
||||
soundboardEnabled: boolean;
|
||||
soundboardVolume: number; // 0–100
|
||||
|
||||
seasonalThemeOverride:
|
||||
| 'auto'
|
||||
| 'off'
|
||||
| 'halloween'
|
||||
| 'christmas'
|
||||
| 'newyear'
|
||||
| 'autumn'
|
||||
| 'aprilfools'
|
||||
| 'lunar'
|
||||
| 'valentines'
|
||||
| 'stpatricks'
|
||||
| 'earthday'
|
||||
| 'deepspace'
|
||||
| 'arcade';
|
||||
seasonalThemeOverride: SeasonalThemeOverride;
|
||||
|
||||
// On-device message translation
|
||||
translateTargetLang: string; // BCP-47 base code, default 'en'
|
||||
autoTranslate: boolean; // auto-translate incoming foreign messages (opt-in)
|
||||
|
||||
// GIF picker sends every search term (and the user's IP) directly to Giphy,
|
||||
// so it's opt-in and off by default.
|
||||
gifPickerEnabled: boolean;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -374,6 +397,8 @@ const defaultSettings: Settings = {
|
||||
|
||||
translateTargetLang: 'en',
|
||||
autoTranslate: false,
|
||||
|
||||
gifPickerEnabled: false,
|
||||
};
|
||||
|
||||
export const getSettings = (): Settings => {
|
||||
@@ -422,6 +447,12 @@ export const getSettings = (): Settings => {
|
||||
typeof saved.autoTranslate === 'boolean'
|
||||
? saved.autoTranslate
|
||||
: defaultSettings.autoTranslate,
|
||||
// Coerce any unknown/retired persisted seasonal theme id back to 'auto'.
|
||||
seasonalThemeOverride: SEASONAL_THEME_OVERRIDES.includes(
|
||||
saved.seasonalThemeOverride as SeasonalThemeOverride,
|
||||
)
|
||||
? (saved.seasonalThemeOverride as SeasonalThemeOverride)
|
||||
: defaultSettings.seasonalThemeOverride,
|
||||
composerToolbarButtons: {
|
||||
...DEFAULT_COMPOSER_TOOLBAR,
|
||||
...(saved.composerToolbarButtons ?? {}),
|
||||
|
||||
@@ -127,6 +127,36 @@ test('toastQueueAtom keeps a new transient toast even when the cap is full of st
|
||||
assert.equal(ids.length, 6);
|
||||
});
|
||||
|
||||
// --- #80: eviction prefers toasts that have already been visible a while ---
|
||||
|
||||
test('toastQueueAtom prefers evicting an old-enough toast over a fresh one when both exist', (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'] });
|
||||
const store = createStore();
|
||||
|
||||
store.set(toastQueueAtom, makeToast('old')); // t = 0
|
||||
t.mock.timers.tick(2000); // 'old' is now well past the 1.5s min-visible mark
|
||||
store.set(toastQueueAtom, makeToast('mid')); // t = 2000, too young to prefer
|
||||
for (let i = 0; i < 4; i += 1) store.set(toastQueueAtom, makeToast(`n${i}`)); // fills to cap + 1
|
||||
|
||||
const ids = store.get(toastQueueAtom).map((toast) => toast.id);
|
||||
// 'old' has been visible long enough to be preferred for eviction over the
|
||||
// still-young 'mid', even though 'old' isn't the only non-sticky candidate.
|
||||
assert.ok(!ids.includes('old'));
|
||||
assert.ok(ids.includes('mid'));
|
||||
assert.equal(ids.length, 5);
|
||||
});
|
||||
|
||||
test('toastQueueAtom falls back to oldest-first when no toast is old enough yet', () => {
|
||||
const store = createStore();
|
||||
// All appended back-to-back within the same tick — none has reached the
|
||||
// 1.5s min-visible mark, so eviction falls back to plain oldest-first.
|
||||
for (let i = 0; i < 7; i += 1) store.set(toastQueueAtom, makeToast(`t${i}`));
|
||||
assert.deepEqual(
|
||||
store.get(toastQueueAtom).map((toast) => toast.id),
|
||||
['t2', 't3', 't4', 't5', 't6'],
|
||||
);
|
||||
});
|
||||
|
||||
test('createDownloadToast: filename in body, no room navigation, unique ids', () => {
|
||||
const a = createDownloadToast('photo.jpg');
|
||||
assert.equal(a.displayName, 'Downloaded');
|
||||
|
||||
+34
-10
@@ -12,6 +12,7 @@ export type ToastNotif = {
|
||||
hashPath?: string; // overrides window.location.hash navigation when set
|
||||
onClick?: () => void; // custom click handler; skips hash navigation when set
|
||||
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
||||
createdAt?: number; // set by toastQueueAtom when enqueued; used for #80 eviction ordering
|
||||
};
|
||||
|
||||
// Build a "download complete" system toast. Kept folds-free here (the icon src is
|
||||
@@ -50,22 +51,45 @@ const baseAtom = atom<ToastNotif[]>([]);
|
||||
// can't stack unbounded and cover the viewport.
|
||||
const MAX_TOASTS = 5;
|
||||
|
||||
// #80 — a toast younger than this hasn't had a fair chance to be read yet, so
|
||||
// eviction prefers dropping an older, already-seen toast over a fresh one when
|
||||
// a burst arrives while several toasts are still on screen.
|
||||
const MIN_VISIBLE_MS = 1500;
|
||||
|
||||
// Pick the index (within [0, excludeLastIndex)) of the toast to evict: prefer
|
||||
// the oldest non-sticky toast that's already been visible at least
|
||||
// MIN_VISIBLE_MS; if none qualifies yet (e.g. a burst that all arrived within
|
||||
// the same beat), fall back to the oldest non-sticky toast regardless of age
|
||||
// so the cap is still enforced. Sticky action toasts are never evicted.
|
||||
const findEvictionIndex = (toasts: ToastNotif[], excludeLastIndex: number, now: number): number => {
|
||||
for (let i = 0; i < excludeLastIndex; i += 1) {
|
||||
const t = toasts[i];
|
||||
if (!t.sticky && now - (t.createdAt ?? now) >= MIN_VISIBLE_MS) return i;
|
||||
}
|
||||
for (let i = 0; i < excludeLastIndex; i += 1) {
|
||||
if (!toasts[i].sticky) return i;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
// Write-only setter used in ClientNonUIFeatures
|
||||
export const toastQueueAtom = atom<ToastNotif[], [ToastNotif | null], void>(
|
||||
(get) => get(baseAtom),
|
||||
(get, set, notif) => {
|
||||
if (notif === null) return; // no-op guard
|
||||
// Stamp in place (not a copy) so the queue keeps holding the exact object
|
||||
// the caller passed in — callers/tests may rely on reference equality.
|
||||
notif.createdAt = Date.now();
|
||||
const now = notif.createdAt;
|
||||
const next = [...get(baseAtom), notif];
|
||||
// Over cap: drop the oldest NON-sticky toasts (transient message/error
|
||||
// toasts auto-dismiss anyway); never drop a sticky action toast, which
|
||||
// requires a click. The `length - 1` bound excludes the just-appended
|
||||
// newest, so a fresh toast is never the one dropped — if everything older
|
||||
// is sticky the cap simply stretches rather than eating the new notice.
|
||||
for (let i = 0; i < next.length - 1 && next.length > MAX_TOASTS; i += 1) {
|
||||
if (!next[i].sticky) {
|
||||
next.splice(i, 1);
|
||||
i -= 1;
|
||||
}
|
||||
// The last index (the just-appended newest toast) is never a candidate, so
|
||||
// a fresh toast is never the one dropped — if everything older is sticky
|
||||
// (or, now, too young) the cap simply stretches rather than eating the new
|
||||
// notice.
|
||||
while (next.length > MAX_TOASTS) {
|
||||
const idx = findEvictionIndex(next, next.length - 1, now);
|
||||
if (idx === -1) break;
|
||||
next.splice(idx, 1);
|
||||
}
|
||||
set(baseAtom, next);
|
||||
},
|
||||
|
||||
@@ -36,25 +36,46 @@ const glowPulse = keyframes({
|
||||
},
|
||||
});
|
||||
|
||||
// Wobble/glow/spin only run under the same no-preference guard as
|
||||
// MsgAppearClass below; prefers-reduced-motion users get a static state.
|
||||
export const WobbleAnimation = style({
|
||||
animation: `${wobble} 2000ms ease-in-out`,
|
||||
animationIterationCount: 'infinite',
|
||||
'@media': {
|
||||
'(prefers-reduced-motion: no-preference)': {
|
||||
animation: `${wobble} 2000ms ease-in-out`,
|
||||
animationIterationCount: 'infinite',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const GlowAnimation = style({
|
||||
animation: `${glowPulse} 2000ms ease-out`,
|
||||
animationIterationCount: 'infinite',
|
||||
'@media': {
|
||||
'(prefers-reduced-motion: no-preference)': {
|
||||
animation: `${glowPulse} 2000ms ease-out`,
|
||||
animationIterationCount: 'infinite',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const CallAvatarAnimation = style({
|
||||
animation: `${wobble} 2000ms ease-in-out, ${glowPulse} 2000ms ease-out`,
|
||||
animationIterationCount: 'infinite',
|
||||
'@media': {
|
||||
'(prefers-reduced-motion: no-preference)': {
|
||||
animation: `${wobble} 2000ms ease-in-out, ${glowPulse} 2000ms ease-out`,
|
||||
animationIterationCount: 'infinite',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const SendingSpinClass = style({
|
||||
display: 'inline-block',
|
||||
animation: `${spin} 900ms linear infinite`,
|
||||
transformOrigin: 'center',
|
||||
// Static fallback: dim instead of spinning when motion is reduced.
|
||||
opacity: 0.6,
|
||||
'@media': {
|
||||
'(prefers-reduced-motion: no-preference)': {
|
||||
animation: `${spin} 900ms linear infinite`,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const msgAppearKeyframe = keyframes({
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
getOrphanParents,
|
||||
isMutedRule,
|
||||
findMutedRule,
|
||||
getNotificationType,
|
||||
isNotificationEvent,
|
||||
isVerificationFlowEvent,
|
||||
unreadIsOnlyVerification,
|
||||
@@ -46,7 +47,7 @@ import {
|
||||
getMentionContent,
|
||||
getAllVersionsRoomCreator,
|
||||
} from './room';
|
||||
import { RoomType, StateEvent, RoomToParents } from '../../types/matrix/room';
|
||||
import { RoomType, StateEvent, RoomToParents, NotificationType } from '../../types/matrix/room';
|
||||
|
||||
// --- Mock helpers ---------------------------------------------------------
|
||||
|
||||
@@ -341,6 +342,43 @@ test('findMutedRule finds rule by id that is also muted', () => {
|
||||
assert.equal(findMutedRule(rules, '!missing'), undefined);
|
||||
});
|
||||
|
||||
// --- getNotificationType (memoization, #77) -------------------------------
|
||||
|
||||
test('getNotificationType memoizes per push-rules content object and room id', () => {
|
||||
let scanCalls = 0;
|
||||
const overrideRules = [{ rule_id: '!muted', actions: [], conditions: [{ kind: 'event_match' }] }];
|
||||
const pushRulesContent = { global: { override: overrideRules } };
|
||||
const mx = {
|
||||
getRoomPushRule: () => {
|
||||
scanCalls += 1;
|
||||
return undefined;
|
||||
},
|
||||
getAccountData: () => ({ getContent: () => pushRulesContent }),
|
||||
} as never;
|
||||
|
||||
assert.equal(getNotificationType(mx, '!muted'), NotificationType.Mute);
|
||||
assert.equal(getNotificationType(mx, '!muted'), NotificationType.Mute);
|
||||
assert.equal(getNotificationType(mx, '!muted'), NotificationType.Mute);
|
||||
// Repeated calls with the same (content object, roomId) pair must hit the
|
||||
// cache instead of re-running getRoomPushRule + the override scan.
|
||||
assert.equal(scanCalls, 1);
|
||||
|
||||
// A different room id under the same push-rules content is a separate cache
|
||||
// entry and still gets computed.
|
||||
assert.equal(getNotificationType(mx, '!other'), NotificationType.Default);
|
||||
assert.equal(scanCalls, 2);
|
||||
|
||||
// A new push-rules content object (simulating an `m.push_rules` account-data
|
||||
// update) invalidates the cache and forces a recompute.
|
||||
const newContent = { global: { override: overrideRules } };
|
||||
const mx2 = {
|
||||
getRoomPushRule: mx.getRoomPushRule,
|
||||
getAccountData: () => ({ getContent: () => newContent }),
|
||||
} as never;
|
||||
assert.equal(getNotificationType(mx2, '!muted'), NotificationType.Mute);
|
||||
assert.equal(scanCalls, 3);
|
||||
});
|
||||
|
||||
// --- isNotificationEvent --------------------------------------------------
|
||||
|
||||
test('isNotificationEvent accepts message/sticker but rejects member, redacted, edits', () => {
|
||||
|
||||
+37
-3
@@ -188,7 +188,11 @@ export const isMutedRule = (rule: IPushRule) =>
|
||||
export const findMutedRule = (overrideRules: IPushRule[], roomId: string) =>
|
||||
overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule));
|
||||
|
||||
export const getNotificationType = (mx: MatrixClient, roomId: string): NotificationType => {
|
||||
const computeNotificationType = (
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
pushRulesContent: IPushRules | undefined,
|
||||
): NotificationType => {
|
||||
let roomPushRule: IPushRule | undefined;
|
||||
try {
|
||||
roomPushRule = mx.getRoomPushRule('global', roomId);
|
||||
@@ -197,8 +201,7 @@ export const getNotificationType = (mx: MatrixClient, roomId: string): Notificat
|
||||
}
|
||||
|
||||
if (!roomPushRule) {
|
||||
const overrideRules = mx.getAccountData(EventType.PushRules)?.getContent<IPushRules>()
|
||||
?.global?.override;
|
||||
const overrideRules = pushRulesContent?.global?.override;
|
||||
if (!overrideRules) return NotificationType.Default;
|
||||
|
||||
return findMutedRule(overrideRules, roomId) ? NotificationType.Mute : NotificationType.Default;
|
||||
@@ -208,6 +211,37 @@ export const getNotificationType = (mx: MatrixClient, roomId: string): Notificat
|
||||
return NotificationType.MentionsAndKeywords;
|
||||
};
|
||||
|
||||
// #77 — `getNotificationType` is called synchronously for every live timeline
|
||||
// event in every room, but its result only ever changes when the `m.push_rules`
|
||||
// account-data event updates. matrix-js-sdk replaces the stored account-data
|
||||
// `MatrixEvent`'s content wholesale on every `m.push_rules` update (see
|
||||
// `store.storeAccountDataEvents`), so keying a cache off that content object's
|
||||
// identity naturally invalidates itself on `ClientEvent.AccountData` without
|
||||
// needing an explicit listener here. A bursty sync backlog across many rooms
|
||||
// then hits this cache instead of re-running `getRoomPushRule` + a linear scan
|
||||
// of the override rules per event.
|
||||
const notificationTypeCache = new WeakMap<object, Map<string, NotificationType>>();
|
||||
// Stable key to use when there is no push-rules account data at all yet.
|
||||
const NO_PUSH_RULES_KEY: object = {};
|
||||
|
||||
export const getNotificationType = (mx: MatrixClient, roomId: string): NotificationType => {
|
||||
const pushRulesContent = mx.getAccountData(EventType.PushRules)?.getContent<IPushRules>();
|
||||
const cacheKey = pushRulesContent ?? NO_PUSH_RULES_KEY;
|
||||
|
||||
let roomCache = notificationTypeCache.get(cacheKey);
|
||||
if (!roomCache) {
|
||||
roomCache = new Map();
|
||||
notificationTypeCache.set(cacheKey, roomCache);
|
||||
}
|
||||
|
||||
const cached = roomCache.get(roomId);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const result = computeNotificationType(mx, roomId, pushRulesContent);
|
||||
roomCache.set(roomId, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const NOTIFICATION_EVENT_TYPES = [
|
||||
'm.room.create',
|
||||
'm.room.message',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { MatrixClient } from 'matrix-js-sdk';
|
||||
import { resolveClipObjectUrl, clearSoundboardClipCache } from './soundboardClips';
|
||||
|
||||
// [Gitea #57] resolveClipObjectUrl/clearSoundboardClipCache talk to
|
||||
// URL.createObjectURL/revokeObjectURL and `fetch` — neither exists in this
|
||||
// Node test environment, so each case stubs minimal fakes and restores them.
|
||||
|
||||
const originalCreateObjectURL = URL.createObjectURL;
|
||||
const originalRevokeObjectURL = URL.revokeObjectURL;
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
let nextUrlId = 0;
|
||||
let revoked: string[] = [];
|
||||
|
||||
const fakeMx = {
|
||||
getHomeserverUrl: () => 'https://example.org',
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
beforeEach(() => {
|
||||
nextUrlId = 0;
|
||||
revoked = [];
|
||||
(URL as unknown as { createObjectURL: (b: Blob) => string }).createObjectURL = () =>
|
||||
`blob:fake-${nextUrlId++}`;
|
||||
(URL as unknown as { revokeObjectURL: (u: string) => void }).revokeObjectURL = (url: string) => {
|
||||
revoked.push(url);
|
||||
};
|
||||
globalThis.fetch = (async () =>
|
||||
({
|
||||
ok: true,
|
||||
blob: async () => new Blob(['clip']),
|
||||
}) as Response) as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearSoundboardClipCache();
|
||||
URL.createObjectURL = originalCreateObjectURL;
|
||||
URL.revokeObjectURL = originalRevokeObjectURL;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test('caches the resolved object URL per mxc and does not re-download', async () => {
|
||||
let downloads = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
downloads += 1;
|
||||
return { ok: true, blob: async () => new Blob(['clip']) } as Response;
|
||||
}) as typeof fetch;
|
||||
|
||||
const first = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/clip1');
|
||||
const second = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/clip1');
|
||||
|
||||
assert.equal(first, second);
|
||||
assert.equal(downloads, 1);
|
||||
});
|
||||
|
||||
test('clearSoundboardClipCache revokes every cached URL and empties the map', async () => {
|
||||
const urlA = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/a');
|
||||
const urlB = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/b');
|
||||
|
||||
clearSoundboardClipCache();
|
||||
|
||||
assert.deepEqual(new Set(revoked), new Set([urlA, urlB]));
|
||||
|
||||
// Cache is empty, so resolving the same mxc again re-downloads (new blob URL).
|
||||
const urlAAgain = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/a');
|
||||
assert.notEqual(urlAAgain, urlA);
|
||||
});
|
||||
|
||||
test('evicts the least-recently-used entry once the cache exceeds its cap', async () => {
|
||||
// Cap is 64; fill it, then add one more and confirm exactly one eviction.
|
||||
for (let i = 0; i < 64; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await resolveClipObjectUrl(fakeMx, `mxc://example.org/clip-${i}`);
|
||||
}
|
||||
assert.equal(revoked.length, 0);
|
||||
|
||||
const firstUrl = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/clip-0');
|
||||
// Re-resolving clip-0 above marked it as recently used, so the least-recently
|
||||
// used entry is now clip-1 — adding a new clip should evict that one.
|
||||
await resolveClipObjectUrl(fakeMx, 'mxc://example.org/clip-new');
|
||||
|
||||
assert.equal(revoked.length, 1);
|
||||
// clip-0 must not have been evicted since it was refreshed just before.
|
||||
assert.equal(revoked.includes(firstUrl), false);
|
||||
});
|
||||
@@ -12,8 +12,12 @@ export const SOUNDBOARD_MAX_CLIPS = 40;
|
||||
export const SOUNDBOARD_ACCEPT = 'audio/mpeg,audio/ogg,audio/wav,audio/webm,audio/mp4,audio/aac';
|
||||
|
||||
// Cache resolved object URLs per mxc so re-triggering a clip doesn't re-download
|
||||
// it. Object URLs live for the page session; the set is tiny (<= MAX_CLIPS).
|
||||
// it. [Gitea #57] Clips are decrypted media held live via `blob:` URLs, so the
|
||||
// cache is capped LRU-style (oldest entry revoked on evict) and fully revoked
|
||||
// on logout — see clearSoundboardClipCache().
|
||||
const objectUrlCache = new Map<string, string>();
|
||||
/** Cap is global (across every pack), not per-pack like SOUNDBOARD_MAX_CLIPS. */
|
||||
const OBJECT_URL_CACHE_MAX = 64;
|
||||
|
||||
/**
|
||||
* Resolve an mxc clip to a `blob:` object URL the Element Call widget can fetch
|
||||
@@ -23,16 +27,42 @@ const objectUrlCache = new Map<string, string>();
|
||||
*/
|
||||
export const resolveClipObjectUrl = async (mx: MatrixClient, mxcUrl: string): Promise<string> => {
|
||||
const cached = objectUrlCache.get(mxcUrl);
|
||||
if (cached) return cached;
|
||||
if (cached) {
|
||||
// Refresh recency: re-insert so this entry is last to be evicted.
|
||||
objectUrlCache.delete(mxcUrl);
|
||||
objectUrlCache.set(mxcUrl, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const httpUrl = mxcUrlToHttp(mx, mxcUrl, true);
|
||||
if (!httpUrl) throw new Error('invalid mxc url');
|
||||
const blob = await downloadMedia(httpUrl);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
if (objectUrlCache.size >= OBJECT_URL_CACHE_MAX) {
|
||||
// Map preserves insertion order, so the first key is the least recently used.
|
||||
const oldestKey = objectUrlCache.keys().next().value;
|
||||
if (oldestKey !== undefined) {
|
||||
const oldestUrl = objectUrlCache.get(oldestKey);
|
||||
if (oldestUrl) URL.revokeObjectURL(oldestUrl);
|
||||
objectUrlCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
objectUrlCache.set(mxcUrl, objectUrl);
|
||||
return objectUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #57] Revoke every cached soundboard clip blob URL and empty the
|
||||
* cache. Decrypted clip bytes must not stay reachable past logout — call this
|
||||
* from every logout/clear-cache path alongside clearPlaintextCaches().
|
||||
*/
|
||||
export const clearSoundboardClipCache = (): void => {
|
||||
objectUrlCache.forEach((objectUrl) => URL.revokeObjectURL(objectUrl));
|
||||
objectUrlCache.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* Play a resolved clip locally so the person who pressed it gets immediate
|
||||
* feedback — LiveKit doesn't loop a participant's own published track back to
|
||||
|
||||
@@ -8,6 +8,7 @@ import { revokeOidcTokens } from './oidcLogout';
|
||||
import { pushSessionToSW } from '../sw-session';
|
||||
import { deleteSearchCacheDatabase } from '../app/utils/searchCache';
|
||||
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
|
||||
import { clearSoundboardClipCache } from '../app/utils/soundboardClips';
|
||||
|
||||
// Thrown when the local IndexedDB has a higher schema version than this SDK expects.
|
||||
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
|
||||
@@ -103,6 +104,9 @@ export const startClient = async (mx: MatrixClient) => {
|
||||
export const clearCacheAndReload = async (mx: MatrixClient) => {
|
||||
mx.stopClient();
|
||||
clearNavToActivePathStore(mx.getSafeUserId());
|
||||
// [Gitea #57] Soundboard clip blob URLs hold decrypted media reachable for
|
||||
// the whole page session — revoke them alongside the rest of the caches.
|
||||
clearSoundboardClipCache();
|
||||
await mx.store.deleteAllData();
|
||||
window.location.reload();
|
||||
};
|
||||
@@ -128,6 +132,9 @@ export const logoutClient = async (mx: MatrixClient) => {
|
||||
// scheduled messages, recent searches/forwards/gifs/stickers, nav paths) —
|
||||
// wipe them too.
|
||||
clearPlaintextCaches(mx.getUserId() ?? undefined);
|
||||
// [Gitea #57] Same reasoning as clearPlaintextCaches: decrypted soundboard
|
||||
// clip bytes stay reachable via live blob: URLs until the reload otherwise.
|
||||
clearSoundboardClipCache();
|
||||
// Remove only the session credential keys, preserving user preferences and
|
||||
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
||||
removeFallbackSession();
|
||||
|
||||
+6
-2
@@ -500,7 +500,9 @@ export const midnightTheme = createTheme(color, {
|
||||
},
|
||||
|
||||
Primary: {
|
||||
Main: '#6b7ca8',
|
||||
// #6b7ca8 measured 4.29:1 against Background.Container (#111827), below
|
||||
// WCAG AA 4.5:1. Lightened along the same hue/saturation to 4.51:1.
|
||||
Main: '#6f80aa',
|
||||
MainHover: '#7989b1',
|
||||
MainActive: '#8493b8',
|
||||
MainLine: '#8f9dbf',
|
||||
@@ -651,7 +653,9 @@ export const lotusTerminalLightTheme = createTheme(color, {
|
||||
OnContainer: '#2d3d56',
|
||||
},
|
||||
Primary: {
|
||||
Main: '#c44e00',
|
||||
// #c44e00 measured 4.14:1 against Background.Container (#edf0f5), below
|
||||
// WCAG AA 4.5:1. Darkened along the same hue/saturation to 4.52:1.
|
||||
Main: '#ba4a00',
|
||||
MainHover: '#a33f00',
|
||||
MainActive: '#8f3700',
|
||||
MainLine: '#d45800',
|
||||
|
||||
@@ -34,6 +34,8 @@ export function runLotusBootSequence(force = false): void {
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'lt-boot';
|
||||
// Decorative boot animation, not content — hide it from assistive tech.
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
overlay.style.cssText = [
|
||||
'position:fixed',
|
||||
'inset:0',
|
||||
@@ -79,6 +81,7 @@ export function runLotusBootSequence(force = false): void {
|
||||
const dismiss = (): void => {
|
||||
clearInterval(interval);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
overlay.removeEventListener('click', dismiss);
|
||||
overlay.style.transition = 'opacity 0.4s ease';
|
||||
overlay.style.opacity = '0';
|
||||
setTimeout(() => overlay.remove(), 400);
|
||||
@@ -88,6 +91,9 @@ export function runLotusBootSequence(force = false): void {
|
||||
if (e.key === 'Escape') dismiss();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
// No touch/click dismiss previously existed — mouse/touch users had no way
|
||||
// to skip besides the keyboard-only Escape hint.
|
||||
overlay.addEventListener('click', dismiss);
|
||||
|
||||
let i = 0;
|
||||
let text = '';
|
||||
|
||||
@@ -149,11 +149,19 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const windowClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
const client = windowClients.find((c): c is WindowClient => 'focus' in c);
|
||||
const windowClients = (
|
||||
await self.clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true,
|
||||
})
|
||||
).filter((c): c is WindowClient => 'focus' in c);
|
||||
// #78 — prefer a client that's actually focused, then one that's merely
|
||||
// visible, before falling back to whatever matchAll() returned first (its
|
||||
// ordering is unspecified across browsers and not "most recently used").
|
||||
const client =
|
||||
windowClients.find((c) => c.focused) ??
|
||||
windowClients.find((c) => c.visibilityState === 'visible') ??
|
||||
windowClients[0];
|
||||
if (client) {
|
||||
await client.focus();
|
||||
if (path) client.postMessage({ type: 'notificationClick', path });
|
||||
|
||||
Reference in New Issue
Block a user