Compare commits

...
26 Commits
Author SHA1 Message Date
jaredandClaude Opus 5 34fe223aa4 chore(lint): remove unused eslint-disable directives
CI / Trigger Desktop Build (push) Successful in 8s
CI / Build & Quality Checks (push) Successful in 1m22s
Part of the #97 warning ratchet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:59 -04:00
jaredandClaude Opus 5 19eded89c1 ci: engines >=20 + .nvmrc; hard audit gate; boot check; bundle budget; eslint ratchet
- engines.node >=20.0.0 and .nvmrc mirroring .node-version (#54)
- npm audit --audit-level=high is a hard gate (tree is at 0) (#91)
- scripts/boot-check.mjs serves dist/ with vite preview and asserts /,
  config.json, the entry chunk and the Element Call bundle all load (#92)
- scripts/check-bundle-size.mjs enforces gzip budgets from
  scripts/bundle-budget.json (seeded +10%); fails PRs, warns on push (#96)
- check:eslint runs with --max-warnings 68 so the count can only go down;
  7 unused eslint-disable directives removed to get there (#97)

Fixes #54
Fixes #91
Fixes #92
Fixes #96
Fixes #97

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:42 -04:00
jaredandClaude Opus 5 d0c13b1a49 fix(rooms): Room Insights refreshes on new timeline events (throttled) with a Refresh button
Fixes #83

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:42 -04:00
jaredandClaude Opus 5 ff575212ee fix(moderation): neutral phrasing for paginated member events without prev_content
Fixes #82

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:42 -04:00
jaredandClaude Opus 5 a461543916 fix(rooms): invite link / QR uses the shared via-server resolver
Fixes #51

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:42 -04:00
jaredandClaude Opus 5 9f8fae964d fix(export): resolve edits into the original row; mark media rows
m.replace events no longer appear as their own "* text" line — the
original row gets m.new_content and an (edited) marker. Media rows carry
a [Image]/[Video]/[Audio]/[File] prefix and the JSON export includes
mediaUrl; the UI notes attachments aren't included. Unit-tested.

Fixes #50
Fixes #81

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:42 -04:00
jaredandClaude Opus 5 7e64ea398b fix(moderation): knock Deny gated on kick power; approve/deny errors surfaced
Fixes #49

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:42 -04:00
jaredandClaude Opus 5 086e4b3b03 fix(auth): auth pages reload when a session appears in another tab
Fixes #69

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:42 -04:00
jaredandClaude Opus 5 d0017e4a78 fix(toast): prefer evicting toasts that have been visible >= 1.5s during a burst
Fixes #80

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:42 -04:00
jaredandClaude Opus 5 e96bd527a0 fix(desktop): surface native quick-reply send failures as an error toast
Fixes #79

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:42 -04:00
jaredandClaude Opus 5 1a5ca81513 fix(desktop): notification click prefers the focused/visible window
Fixes #78

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:42 -04:00
jaredandClaude Opus 5 d280f66d5c perf(notifications): memoize getNotificationType per push-rules object and room
Fixes #77

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00
jaredandClaude Opus 5 e2e9f75e40 fix(desktop): release keep-awake on unmount
Fixes #76

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00
jaredandClaude Opus 5 7aba71490a fix(a11y): reduced-motion for spinner/call-avatar; tap-to-skip boot; AA accent contrast
- SendingSpinClass and CallAvatarAnimation respect prefers-reduced-motion
  (static opacity fallback), matching MsgAppearClass.
- Terminal boot overlay dismisses on click/tap and is aria-hidden.
- Primary.Main: Midnight #6b7ca8 -> #6f80aa (4.29 -> 4.51:1), Lotus
  Terminal Light #c44e00 -> #ba4a00 (4.14 -> 4.52:1), hue preserved.

Fixes #84
Fixes #86
Fixes #52

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00
jaredandClaude Opus 5 2e14980de6 fix(settings): validate seasonalThemeOverride; commit page zoom on blur; GIF picker opt-in
- seasonalThemeOverride is coerced to 'auto' when the persisted value is
  outside the union, like every other enum setting (tested).
- PageZoomInput commits on blur as well as Enter.
- New gifPickerEnabled setting (default off) with a disclosure that GIF
  searches go to Giphy; the picker doesn't mount and the composer button
  is hidden while off.

Fixes #74
Fixes #75
Fixes #68

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00
jaredandClaude Opus 5 6b16c789a4 fix(seasonal): Earth Day globe clears the composer strip
Fixes #72

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00
jaredandClaude Opus 5 535302aed9 docs: drop unimplemented claims (leading-emoji sizing, secondary background keyframes)
Fixes #70
Fixes #71

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00
jaredandClaude Opus 5 a25777a77a fix(privacy): location map loads on click; CollapsibleBody tracks reduced motion live
m.location mounted an openstreetmap.org iframe on render, acting as a
read beacon; it's now behind a "Load map" button. CollapsibleBody uses
useReducedMotion() instead of a one-time matchMedia snapshot.

Fixes #66
Fixes #85

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00
jaredandClaude Opus 5 3adba22ddf fix(security): bound KaTeX rendering of remote LaTeX
maxSize 10, maxExpand 100, trust false, strict ignore, and a 5000-char
cap (rendered as plain text beyond it), so a remote sender can't blow out
layout or CPU with \rule{99999em} or deep macro expansion.

Fixes #65

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00
jaredandClaude Opus 5 bacfef5558 fix(security): forwarding encrypted media to a plaintext room re-uploads it
buildForwardContent copied content.file (AES key/iv/hashes) verbatim, so
forwarding from an E2EE room into an unencrypted one published the key.
For unencrypted destinations the attachment is now downloaded, decrypted
and re-uploaded as plaintext (url instead of file, thumbnail key
stripped); if that fails the forward is refused rather than leaking.
Encrypted destinations unchanged. Needs a manual check on a live
encrypted -> plaintext forward.

Fixes #63

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00
jaredandClaude Opus 5 6aa459df31 fix(search): sender-only search returns only message-like events
Fixes #62

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00
jaredandClaude Opus 5 8f92029b47 fix(timeline): read-position membership listener actually fires
RoomMemberEvent.Membership is emitted on RoomMember/MatrixClient, never
on Room, so the (room as any).on() handler was dead. Subscribe on the
client filtered by roomId; drop the cast.

Fixes #61

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00
jaredandClaude Opus 5 49c4641ca8 fix(calls): warn before unload during a joined call
Register beforeunload while joined so Ctrl+R / tab close prompts instead
of silently dropping the call. Also removes the setPipMode effect (#59).

Fixes #58

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00
jaredandClaude Opus 5 ac0ec9f42d fix(security): revoke soundboard blob URLs on logout; cap the cache
Decrypted clip blob: URLs lived in an unbounded module Map for the page
lifetime and survived logout. Add clearSoundboardClipCache() (called from
both logout paths next to clearPlaintextCaches) and a 64-entry LRU that
revokes on evict. Unit-tested.

Fixes #57

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00
jaredandClaude Opus 5 0f7f0100af fix(calls): "Focus camera" toggles to "Unfocus"; pin cleared on dispose; drop dead setPipMode
clearFocusParticipant() had no callers, so a spotlight pin was permanent.
CallControl now tracks focusedUserId, the member menu toggles, and
dispose() clears the pin. Removes _pipMode/setPipMode (never read).

Fixes #56
Fixes #59

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00
jaredandClaude Opus 5 91def3ad34 chore(deps): clear npm audit — pdfjs-dist 6, react-router-dom, sanitize-html, katex, uuid
npm audit --omit=dev went from 11 advisories (6 high) to 0:
- pdfjs-dist 5.7.284 -> 6.3.289 (GHSA-hq66-cqwq-w95j, arbitrary JS on
  opening a malicious PDF; the client has an inline PDF viewer). Only API
  change for us: getDocument() no longer accepts a bare string.
- react-router-dom 7.15.1 -> 7.18.3 (open redirect, DoS, RSC advisories)
- sanitize-html 2.17.4 -> 2.17.7 (javascript: URI bypasses; not
  exploitable in our config per the audit, bumped anyway)
- katex 0.16.11 -> 0.16.47 (\htmlData attribute validation)
- uuid under @giphy/js-util overridden to >= 11.1.1 (giphy imports v4,
  which is unchanged)
- npm audit fix for transitive brace-expansion / js-yaml / nanoid / postcss

Verified: tsc clean, 1000 tests pass, production build succeeds and
dist/pdf.worker.min.js is still copied from the new package.

Fixes #24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:00:56 -04:00
52 changed files with 1580 additions and 260 deletions
+22 -18
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
24.13.1
+5 -5
View File
@@ -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)
-1
View File
@@ -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
+202 -96
View File
@@ -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
View File
@@ -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"
}
+148
View File
@@ -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);
});
+5
View File
@@ -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
}
+97
View File
@@ -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();
+16 -6
View File
@@ -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.
+7
View File
@@ -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
? {
+20 -1
View File
@@ -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 }}
/>
);
+38 -16
View File
@@ -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,
+34 -3
View File
@@ -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],
@@ -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. &quot;[Image]&quot;) 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>&apos;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>&apos;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}`;
}
+61 -11
View File
@@ -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>
);
+4 -1
View File
@@ -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);
};
+27 -5
View File
@@ -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"
+11 -5
View File
@@ -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;
}
+33
View File
@@ -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;
}, []);
}
+6
View File
@@ -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]);
}
+16 -1
View File
@@ -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',
),
);
});
});
}
+7
View File
@@ -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;
+18 -5
View File
@@ -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();
}
+2 -1
View File
@@ -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]),
);
+12
View File
@@ -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
View File
@@ -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 ?? {}),
+30
View File
@@ -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
View File
@@ -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);
},
+28 -7
View File
@@ -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({
+39 -1
View File
@@ -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
View File
@@ -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',
+86
View File
@@ -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);
});
+32 -2
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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',
+6
View File
@@ -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 = '';
+13 -5
View File
@@ -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 });