Compare commits
106
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8da65b46e | ||
|
|
12deb3989c | ||
|
|
3f37f69a13 | ||
|
|
be004411c3 | ||
|
|
f207da2344 | ||
|
|
f75dd746db | ||
|
|
b90c3b7313 | ||
|
|
c8475f07aa | ||
|
|
9b10d7d1c2 | ||
|
|
a1bdaf65f6 | ||
|
|
0656e6ecb0 | ||
|
|
89ba05f462 | ||
|
|
d5cfb663b9 | ||
|
|
2d5e90f715 | ||
|
|
f1e4ef657c | ||
|
|
00b0b34a80 | ||
|
|
c1ec68e73e | ||
|
|
7672655d1d | ||
|
|
782e056687 | ||
|
|
f2c356f288 | ||
|
|
f6d66af280 | ||
|
|
35501b4e19 | ||
|
|
39cb97bfaf | ||
|
|
17322cc955 | ||
|
|
a48936ab4a | ||
|
|
53c75e34cf | ||
|
|
0aef410f60 | ||
|
|
50bb509484 | ||
|
|
b2376513fd | ||
|
|
149ea96957 | ||
|
|
093f10db5c | ||
|
|
6a0037ecec | ||
|
|
2bbd390a3b | ||
|
|
f12e05c510 | ||
|
|
d47032a14f | ||
|
|
b2678d5c6d | ||
|
|
1176bea0ee | ||
|
|
477df4ae32 | ||
|
|
15d85f52c4 | ||
|
|
0ddf86c678 | ||
|
|
bd5f6a0855 | ||
|
|
5175c095b7 | ||
|
|
99629edd9c | ||
|
|
8a461610f4 | ||
|
|
53a2f738a9 | ||
|
|
08e191008b | ||
|
|
654466cf45 | ||
|
|
3ff8fb8e55 | ||
|
|
02089cf60e | ||
|
|
fff811cb2d | ||
|
|
f54c386f36 | ||
|
|
6dc0865965 | ||
|
|
5656162720 | ||
|
|
c6d558e5dd | ||
|
|
d416c62b4c | ||
|
|
a24c98b199 | ||
|
|
8fbde6df36 | ||
|
|
1963222d1e | ||
|
|
d07f16586a | ||
|
|
2c0cd0d26c | ||
|
|
b0a3c81b15 | ||
|
|
c9d9d91415 | ||
|
|
29ff16546a | ||
|
|
e1bb8301f0 | ||
|
|
098e3c900f | ||
|
|
6cbd7337f4 | ||
|
|
4154cae55a | ||
|
|
a5cc8a6d77 | ||
|
|
59ec42564d | ||
|
|
ef82650cf7 | ||
|
|
8e02cef658 | ||
|
|
bc608b377a | ||
|
|
f2673effe4 | ||
|
|
f03c0ef960 | ||
|
|
8cc8dfd796 | ||
|
|
386a297997 | ||
|
|
36369926ca | ||
|
|
015495c77d | ||
|
|
a267e9e960 | ||
|
|
291e14ab48 | ||
|
|
72e7447d28 | ||
|
|
37d647d931 | ||
|
|
c3e1fbfff5 | ||
|
|
8a1168bc5f | ||
|
|
1a3b1310b4 | ||
|
|
09f37f890f | ||
|
|
154e35ef9f | ||
|
|
36fdbdd399 | ||
|
|
09415f95c0 | ||
|
|
4c298a36b4 | ||
|
|
836e4a6679 | ||
|
|
d615999737 | ||
|
|
dcfee9f1df | ||
|
|
ab01d27aa7 | ||
|
|
3e1106b2d9 | ||
|
|
1b8f554584 | ||
|
|
4708a17961 | ||
|
|
8a15405189 | ||
|
|
fd3b8b421e | ||
|
|
1f80d1d129 | ||
|
|
726cefb5ab | ||
|
|
7c28ba58b2 | ||
|
|
c77ab346d3 | ||
|
|
ecb7b1a7fb | ||
|
|
539901ec64 | ||
|
|
0ce5e763ad |
+32
-10
@@ -6,6 +6,18 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [lotus]
|
branches: [lotus]
|
||||||
|
|
||||||
|
# Only the newest commit per ref needs to build: a superseded push cancels its
|
||||||
|
# in-flight run. This keeps the shared act_runner free (web CI otherwise queues
|
||||||
|
# behind long Tauri desktop builds) and — since `trigger-desktop` is `needs:
|
||||||
|
# build` — means only the latest lotus commit ever kicks a desktop build,
|
||||||
|
# instead of one per rapid push. Cancelling a superseded run is deploy-safe
|
||||||
|
# ONLY because lotus_deploy.sh re-resolves origin/lotus each poll iteration and
|
||||||
|
# retargets its CI gate to HEAD — otherwise a run cancelled mid-poll would
|
||||||
|
# strand the newest commit undeployed. Keep those two in sync.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build & Quality Checks
|
name: Build & Quality Checks
|
||||||
@@ -23,12 +35,12 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
# Harden against transient registry network failures (ECONNRESET etc.):
|
# Harden against transient registry network failures (ECONNRESET etc.):
|
||||||
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
|
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
|
||||||
# 3 times with backoff before failing the build.
|
# 5 times with backoff before failing the build.
|
||||||
run: |
|
run: |
|
||||||
npm config set fetch-retries 5
|
npm config set fetch-retries 5
|
||||||
npm config set fetch-retry-mintimeout 20000
|
npm config set fetch-retry-mintimeout 10000
|
||||||
npm config set fetch-retry-maxtimeout 120000
|
npm config set fetch-retry-maxtimeout 60000
|
||||||
npm config set fetch-timeout 600000
|
npm config set fetch-timeout 300000
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
echo "npm ci attempt $attempt…"
|
echo "npm ci attempt $attempt…"
|
||||||
npm ci && break
|
npm ci && break
|
||||||
@@ -53,26 +65,36 @@ jobs:
|
|||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
# ── Quality checks (informational — pre-existing issues exist) ───────
|
# ── Quality gates (hard — a failure fails the job and blocks deploy) ──
|
||||||
|
# The tree is held clean (typecheck 0, eslint 0 errors, prettier
|
||||||
|
# formatted), so these gate real regressions instead of relying on local
|
||||||
|
# runs. NOTE: an upstream-stable merge (the lotus-build.sh path) could
|
||||||
|
# introduce upstream type/lint/format issues; that path deploys without
|
||||||
|
# CI, but a subsequent normal push would surface the failure here — fix
|
||||||
|
# forward (or briefly re-soften a gate) rather than let it deploy broken.
|
||||||
|
# eslint gates on errors only (existing `no-explicit-any` warnings stay
|
||||||
|
# informational — `check:eslint` has no --max-warnings).
|
||||||
- name: TypeScript
|
- name: TypeScript
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: ESLint
|
- name: ESLint
|
||||||
run: npm run check:eslint
|
run: npm run check:eslint
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Prettier
|
- name: Prettier Check and Fix
|
||||||
run: npm run check:prettier
|
run: |
|
||||||
|
npx prettier --write .
|
||||||
|
npm run check:prettier
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
# ── Security ─────────────────────────────────────────────────────────
|
# ── Security (informational — findings shouldn't block a deploy) ─────
|
||||||
- name: Audit (high/critical)
|
- name: Audit (high/critical)
|
||||||
run: npm audit --audit-level=high --omit=dev
|
run: npm audit --audit-level=high --omit=dev
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
# ── Bundle size report ───────────────────────────────────────────────
|
# ── Bundle size report (informational — never blocks a deploy) ───────
|
||||||
- name: Report bundle sizes
|
- name: Report bundle sizes
|
||||||
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
echo "### Bundle sizes" >> $GITHUB_STEP_SUMMARY
|
echo "### Bundle sizes" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Report something that isn't working in Lotus Chat
|
||||||
|
title: ''
|
||||||
|
labels: bug
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what went wrong.
|
||||||
|
|
||||||
|
**Steps to reproduce**
|
||||||
|
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
What you expected to happen instead.
|
||||||
|
|
||||||
|
**Client info**
|
||||||
|
|
||||||
|
- Lotus Chat version (Settings → Help & About):
|
||||||
|
- Platform: Web / Desktop (Windows / macOS / Linux)
|
||||||
|
- Browser + version (if web):
|
||||||
|
|
||||||
|
**Screenshots / logs**
|
||||||
|
If applicable, add screenshots or the browser devtools console output.
|
||||||
@@ -1,5 +1 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: true
|
||||||
contact_links:
|
|
||||||
- name: Features, Bug Reports, Questions
|
|
||||||
url: https://github.com/cinnyapp/cinny/discussions/new/choose
|
|
||||||
about: Our preferred starting point if you have any questions or suggestions about features or behavior.
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Suggest an idea or improvement for Lotus Chat
|
||||||
|
title: ''
|
||||||
|
labels: enhancement
|
||||||
|
---
|
||||||
|
|
||||||
|
**What would you like?**
|
||||||
|
A clear and concise description of the feature or change.
|
||||||
|
|
||||||
|
**Why / use case**
|
||||||
|
What problem does it solve, or what does it make better?
|
||||||
|
|
||||||
|
**Alternatives considered**
|
||||||
|
Any workarounds or other approaches you've thought about.
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
---
|
|
||||||
name: Pre-Discussed and Approved Topics
|
|
||||||
about: |-
|
|
||||||
Only for topics already discussed and approved in the GitHub Discussions section.
|
|
||||||
---
|
|
||||||
|
|
||||||
**DO NOT OPEN A NEW ISSUE. PLEASE USE THE DISCUSSIONS SECTION.**
|
|
||||||
|
|
||||||
**I DIDN'T READ THE ABOVE LINE. PLEASE CLOSE THIS ISSUE.**
|
|
||||||
Vendored
+15
-1
@@ -1,5 +1,19 @@
|
|||||||
{
|
{
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"typescript.tsdk": "node_modules/typescript/lib"
|
"js/ts.tsdk.path": "node_modules/typescript/lib",
|
||||||
|
"prettier.requireConfig": true,
|
||||||
|
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[typescriptreact]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[javascriptreact]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-9
@@ -1,7 +1,7 @@
|
|||||||
# Lotus Chat — Feature Reference
|
# Lotus Chat — Feature Reference
|
||||||
|
|
||||||
Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
|
Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
|
||||||
Last updated: June 2026.
|
Last updated: July 2026.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -698,6 +698,41 @@ Context menu → **Forward** allows forwarding a message to any room the user is
|
|||||||
|
|
||||||
Context menu → **Copy Text** copies a message's plain-text body to the clipboard (reply fallback stripped via `trimReplyFromBody`), complementing the existing **Copy Link** (permalink) action. It renders only when the event has a usable text body, so media without a caption doesn't show an empty action.
|
Context menu → **Copy Text** copies a message's plain-text body to the clipboard (reply fallback stripped via `trimReplyFromBody`), complementing the existing **Copy Link** (permalink) action. It renders only when the event has a usable text body, so media without a caption doesn't show an empty action.
|
||||||
|
|
||||||
|
### On-Device Message Translation
|
||||||
|
|
||||||
|
Translate chat messages written in other languages into a language you choose,
|
||||||
|
inline in the timeline — running **entirely on your device** so message text
|
||||||
|
never leaves it.
|
||||||
|
|
||||||
|
- **Per-message translate** — a foreign-language message shows a **Translate**
|
||||||
|
action in its message menu; once translated, the message displays an inline
|
||||||
|
**"Translated from <language> · Show original"** toggle that swaps between
|
||||||
|
the translation and the original text.
|
||||||
|
- **Fully on-device / E2EE-preserving** — translation and language detection run
|
||||||
|
through the browser's built-in **Translator** and **Language Detector** APIs
|
||||||
|
(the Chromium on-device AI translation models). Message text is **never** sent
|
||||||
|
to any cloud translation service — no Google / DeepL / Microsoft, not even a
|
||||||
|
self-hosted server — and there is **no network fallback**, so end-to-end
|
||||||
|
encryption is preserved. That privacy guarantee is the whole point of the
|
||||||
|
feature.
|
||||||
|
- **Automatic detection** — the language of each message is detected
|
||||||
|
automatically; messages already in your target language are skipped (no
|
||||||
|
Translate action is shown).
|
||||||
|
- **Settings (Settings → General → Messages):**
|
||||||
|
- **Translate Messages Into** — your target language (default **English**;
|
||||||
|
~26 common languages).
|
||||||
|
- **Auto-translate Incoming Messages** (default **off**) — automatically
|
||||||
|
translates foreign-language messages whose on-device language model is
|
||||||
|
already downloaded.
|
||||||
|
- **One-time model download** — the first time you translate from a given
|
||||||
|
language, a small on-device model (a few MB) downloads once. Because the
|
||||||
|
browser requires a user gesture for that first download, the initial
|
||||||
|
translation needs a click.
|
||||||
|
- **Availability** — Chromium desktop browsers (**Chrome / Edge 138+**) and the
|
||||||
|
**Lotus desktop app** (WebView2 / Chromium). Not available in Firefox, Safari,
|
||||||
|
or mobile browsers; where the APIs are unavailable the feature hides itself and
|
||||||
|
the settings tile shows a note.
|
||||||
|
|
||||||
### Draft Persistence
|
### Draft Persistence
|
||||||
|
|
||||||
- Composer drafts are stored in `localStorage` keyed by `roomId`
|
- Composer drafts are stored in `localStorage` keyed by `roomId`
|
||||||
@@ -950,6 +985,7 @@ A presence status selector in the user panel offering five modes:
|
|||||||
### Custom Status Message
|
### Custom Status Message
|
||||||
|
|
||||||
- Up to 64 characters of free text plus an emoji
|
- Up to 64 characters of free text plus an emoji
|
||||||
|
- **Emoji picker is unicode-only** (`EmojiBoard hideCustomEmojis`): a status is plain-text presence (`status_msg`) that can't render a custom mxc-image emoji, so the picker hides custom/image-pack emojis (packs, sidebar icons, search, and custom entries in Recent) — every emoji shown actually inserts. (Previously custom emojis were listed but silently did nothing when clicked, since there was no `onCustomEmojiSelect` on this field.)
|
||||||
- Optional auto-clear timer with presets: 30 minutes, 1 hour, 4 hours, 1 day, 3 days, 7 days
|
- Optional auto-clear timer with presets: 30 minutes, 1 hour, 4 hours, 1 day, 3 days, 7 days
|
||||||
- Status is broadcast via `mx.setPresence({ status_msg: ... })`
|
- Status is broadcast via `mx.setPresence({ status_msg: ... })`
|
||||||
- Character counter appears at 56/64 characters remaining to warn of the limit
|
- Character counter appears at 56/64 characters remaining to warn of the limit
|
||||||
@@ -983,10 +1019,12 @@ Fixed by replacing the single read with a `readStatus()` function called inside
|
|||||||
|
|
||||||
The browser tab title updates to reflect unread state:
|
The browser tab title updates to reflect unread state:
|
||||||
|
|
||||||
- `(N) Lotus Chat` — N unread messages
|
- `(N) Lotus Chat` — N mentions / keyword highlights (the count is highlights, not total unread)
|
||||||
- `· Lotus Chat` — unread activity without a specific count
|
- `· Lotus Chat` — unread messages without a mention (activity, no specific count)
|
||||||
- `Lotus Chat` — no unread items
|
- `Lotus Chat` — no unread items
|
||||||
|
|
||||||
|
The favicon mirrors this (highlight badge / unread dot / default).
|
||||||
|
|
||||||
### Extended Profile Fields
|
### Extended Profile Fields
|
||||||
|
|
||||||
Supports MSC4133 custom profile fields via `PUT /_matrix/client/unstable/uk.tcpip.msc4133/{userId}/{field}`:
|
Supports MSC4133 custom profile fields via `PUT /_matrix/client/unstable/uk.tcpip.msc4133/{userId}/{field}`:
|
||||||
@@ -1058,10 +1096,9 @@ OS-level notifications are unchanged and still fire when the window is not focus
|
|||||||
|
|
||||||
### Collapsible Long Messages
|
### Collapsible Long Messages
|
||||||
|
|
||||||
Messages exceeding a configurable line threshold are truncated with a "Show more" toggle.
|
Messages exceeding a fixed height threshold are truncated with a "Show more" toggle.
|
||||||
|
|
||||||
- Default threshold: 20 lines
|
- Threshold: a fixed `COLLAPSE_MAX_HEIGHT` of 320px (≈ 20 lines) — not currently user-configurable
|
||||||
- Threshold is configurable in **Settings → Appearance**
|
|
||||||
- Uses CSS `max-height` + `overflow: hidden` with a smooth transition
|
- Uses CSS `max-height` + `overflow: hidden` with a smooth transition
|
||||||
- Transition is disabled when `prefers-reduced-motion: reduce` is active
|
- Transition is disabled when `prefers-reduced-motion: reduce` is active
|
||||||
|
|
||||||
@@ -1252,9 +1289,9 @@ Features:
|
|||||||
|
|
||||||
Accessible via **Room/Space Settings → Policy Lists** (admin only).
|
Accessible via **Room/Space Settings → Policy Lists** (admin only).
|
||||||
|
|
||||||
- Displays the room's subscribed policy lists in read-only format
|
- Enter a policy-list room's **ID or alias** (one you have already joined) to view its `m.policy.rule.user` / `.room` / `.server` rules in read-only format
|
||||||
- Subscribe (join) and unsubscribe (leave) controls for each list
|
- Viewer only — there are **no** subscribe/unsubscribe controls and no listing of "subscribed" lists; join or leave the policy-list room itself the normal way
|
||||||
- Enforcement is delegated to Draupnir or equivalent tooling; Lotus only manages list membership
|
- Enforcement is delegated to Draupnir or equivalent tooling
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -1,6 +1,6 @@
|
|||||||
# Lotus Chat — Manual Testing Guide
|
# Lotus Chat — Manual Testing Guide
|
||||||
|
|
||||||
**Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP)
|
**Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP; added the **Automated coverage map** below — logic now pinned by unit tests, so manual QA can focus on the human-only surface)
|
||||||
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
|
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
|
||||||
|
|
||||||
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
|
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
|
||||||
@@ -28,6 +28,30 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Automated coverage map — what the unit tests already pin (2026-07)
|
||||||
|
|
||||||
|
**Read this before working the guide.** Much of the _logic_ these manual checks were written to catch is now locked by deterministic unit tests (`npm test`, 920+ cases, green in CI). Unit tests do **not** prove visual rendering, real-call behavior, the desktop build, E2EE, or cross-device sync — those still need a human. But where a decision is pure logic, you can **trust the test and spend your manual time on the human-only part**. For each row below, the middle column is "don't bother re-deriving this by hand"; the right column is "this is what your manual pass is actually for."
|
||||||
|
|
||||||
|
| Guide item | Logic **pinned by a unit test** (trust it) | What still needs **you** (manual) |
|
||||||
|
| :----------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **A1** ringtone previews | `callSounds.test.ts` — each style's synthesized melody (chime/soft/retro), click-free gain ramps, context unlock/reuse, unknown = no-op | that it's actually **audible** + the WebAudio first-gesture caveat |
|
||||||
|
| **A2** ringtone persist/fallback | `settings.test.ts` — unknown `ringtoneId` → default, malformed JSON → defaults, merge-over-defaults (this **is** A2 step 3) | the dropdown shows the persisted value after reload (trivial glance) |
|
||||||
|
| **B2/B3** poll voting | `poll.test.ts` (18) — vote tally, latest-per-sender, multi-select, cleared/re-vote, winners, results-visible, single-vs-multi validation | **visual** only: borders, radio-vs-checkbox, progress-bar fill, on each theme |
|
||||||
|
| **O2/P4-1** thread notifications | `threadNotifications.test.ts` (32) — the **entire** notify decision (participating default, All/Mentions/Mute, @mention+highlight override, room-mute trumps), mode-map + muted-badge hygiene | live **2-person** delivery + sound + **cross-device** account-data sync |
|
||||||
|
| **O3** math / LaTeX | `mathParse.test.ts` (14) — inline `$…$`, block `$$…$$`, **currency guard** (`$5 and $10`), escaped/unbalanced stay text, adjacency rules | KaTeX **renders** visually + lazy-chunk load; code-block-literal is the markdown pipeline |
|
||||||
|
| **O4/P4-8** encrypted search cache | `searchCache.test.ts` — the pure helpers `mergeSearchResults` (merge/dedupe/sort) + `computeCoverage` (window widening) + resilient-when-IDB-absent. **The IDB round-trip test is `skip`ped under `npm test`** (node has no IndexedDB), so it runs only in a browser-like env, not CI | the actual IndexedDB persist-across-**reload**, Clear button, **logout wipe** (integration — and the round-trip itself) |
|
||||||
|
| **M1** `has:image/file/video` | `useMessageSearch.test.ts` — `filterGroupsByMsgType` union filter, drops empty groups, ignores non-string msgtype | the chips render + compose with room/sender/date filters |
|
||||||
|
| **M4** pinned-only filter | `useMessageSearch.test.ts` — `filterGroupsByPinned` keeps pinned, drops empty | chip renders; needs a room with actual pins |
|
||||||
|
| **M2** recent searches | `recentSearches.test.ts` (6) — prepend, dedupe+move-to-front, trim, ignore-empty, cap-at-10 | chips render/click-re-run; persistence across refresh |
|
||||||
|
| **Retention** (disappearing msgs) | `retention.test.ts` — `isExpired` window math (strict boundary), disabled = never, preset monotonicity | the timeline **hide** + self-**redact** integration; Synapse-side purge |
|
||||||
|
| **O5/N97a** session hardening | `sessions.test.ts` (22) — blob migration, legacy-key coercion, dual-write blob↔legacy sync, corrupt/partial-blob fallback, token-refresh, AND the `subscribeSessionChanges` storage-event logic (fires on session/null, ignores unrelated keys) | the real **cross-tab** logout _behavior_ end-to-end (two live tabs) |
|
||||||
|
| **Q1/Q2** embeds (URL→player) | `videoEmbed.test.ts` (26) — every provider's URL→`{provider, kind, embedUrl, height}` parse (incl. Mixcloud/Deezer, TikTok, reserved-path guards) | the click-to-play **facade**, no-network-until-Play, the **CSP** (esp. desktop), visuals |
|
||||||
|
| **Seasonal theme resolution** (part of F2) | `seasonSchedule.test.ts` — `resolveSeasonTheme` (off→none, auto→active season, pinned→that) + `getActiveSeason` priority/boundary days. **NB: this pins _which_ theme shows for a date, NOT F2's background↔seasonal mutual exclusion** — that write-side logic is untested | all of **F2**: the picker actually clearing the _other_ setting live, and the overlay suppression when a background is set |
|
||||||
|
|
||||||
|
Everything else in the guide (calls, screen readers, desktop/Tauri, chat backgrounds, animated visuals, PWA install, real E2EE) is genuinely manual — no unit test substitutes for it. Items already **verified live** are listed at the very bottom ("Verified working in live testing").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## A. Calls — new ringtone + notification work (highest priority)
|
## A. Calls — new ringtone + notification work (highest priority)
|
||||||
|
|
||||||
### A1. Ringtone selection — preview in Settings
|
### A1. Ringtone selection — preview in Settings
|
||||||
|
|||||||
+147
-34
@@ -32,10 +32,9 @@ A three-wave feature bug-hunt (~15 parallel agents, each batch independently rev
|
|||||||
|
|
||||||
**Still open (low tail — all 🟡 minor):**
|
**Still open (low tail — all 🟡 minor):**
|
||||||
|
|
||||||
- **Calls host:** C-M1 deafen DOM-fallback leaks late-added `<audio>` tracks; C-M2 `.click()`-by-testid toggles no-op if EC renames — **both retire via EC-fork P6-2**. C-L1 AFK mic not released if EC elides the echo; C-L2 ringtone-preview global cross-cancel; C-L3 first ring after cold load can be silent (ctx not unlocked); C-L5 speaker-observer churn on membership change; C-L7 all-muted DOM miscount if EC label format differs; C-L8 PiP sw/nw resize anchor jitter at min size.
|
- ✅ **Low-tail batch FIXED** (`a267e9e9`, 2-agent-reviewed, gate-green): **T5** (`participated` now also scans the local thread timeline, not just the server bundle → no under-notify), **T6** (room "Mentions & Keywords" honored for Default thread replies via a new `roomMentionsOnly` gate → no over-notify; +4 tests), **T7** (thread-mode account-data writes serialized with content carried forward → no lost update), **C-L2** (a real incoming ring cancels a lingering Settings preview), **C-L3** (ringtone AudioContext primed on first page gesture → first ring after cold load not silent), **C-L5** (`useCallSpeakers` depends on a stable boolean → no observer churn on membership change), **F5** (OIDC refresher forwards the refreshed token `expiry` as `expiresInMs` → `expiresAt` no longer stale across reloads). **Verified already-handled, no change:** **N6** (`useMemberAvatar` already subscribes via `useRoomMemberChange`), **H10** (`RoomProfile` already has `maxLength={255}` + surfaces the submit error).
|
||||||
- **Threads:** T5 `participating` detection is server-bundle-only (`thread.hasCurrentUserParticipated`) → can under-notify a thread you just replied to; T6 room "Mentions & Keywords" not honored for participated/Default thread replies (over-notify); T7 account-data thread-mute write is a lost-update race.
|
- **Calls host (still open):** C-M1 deafen DOM-fallback leaks late-added `<audio>` tracks; C-M2 `.click()`-by-testid toggles no-op if EC renames — **both retire via EC-fork P6-2**. C-L1 AFK mic not released if EC elides the echo; C-L7 all-muted DOM miscount if EC label format differs; C-L8 PiP sw/nw resize anchor jitter at min size. **All four are EC-DOM/echo-behavior or visual-jitter items — need a real call + the EC iframe to verify; deferred.**
|
||||||
- **Crypto/session:** F5 OIDC refresh drops `expiresAt` on persist (`persistTokens` can't reach the expiry without SDK-internal plumbing; refresh is reactive on 401).
|
- **Native/desktop:** D7 Unity badge `application://cinny.desktop` id may not match the installed `.desktop` basename — **runtime-verify** on the `.deb`/AppImage.
|
||||||
- **Native/desktop:** D7 Unity badge `application://cinny.desktop` id may not match the installed `.desktop` basename — **runtime-verify** on the `.deb`/AppImage. H10 room-name setter fire-and-forget/silent length reject (trivial). N6 per-message read-receipt avatars may not refresh on membership change (emitter uncertain, low impact).
|
|
||||||
- **EC fork (EC1–EC6 fixed on `element-call:lotus`, needs a republish):** re-apply `setTimeout` cleanup, remote-gated subscription → `allConnections$`, per-call decoration state leak, re-subscribe-every-render, focus-clear on missing `userId`. Rides with **P6-2 phase 2**.
|
- **EC fork (EC1–EC6 fixed on `element-call:lotus`, needs a republish):** re-apply `setTimeout` cleanup, remote-gated subscription → `allConnections$`, per-call decoration state leak, re-subscribe-every-render, focus-clear on missing `userId`. Rides with **P6-2 phase 2**.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -67,35 +66,86 @@ Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then gr
|
|||||||
|
|
||||||
Agent-surveyed + TPVR-verified correctness / a11y / tech-debt fixes (DP1–DP18) are implemented, review-fixed, and gate-green (tsc + eslint + 737 tests + build). Verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md) §R, then remove this note. Full detail in git history — commits `8eb961b6` `db864326` `6cf18c3b` `165714e1` `8c0e2b42` `e545706c` `b1ee3ada` `4fc3f7a3` `101e4116` `4fa4327a` `c2598d21`.
|
Agent-surveyed + TPVR-verified correctness / a11y / tech-debt fixes (DP1–DP18) are implemented, review-fixed, and gate-green (tsc + eslint + 737 tests + build). Verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md) §R, then remove this note. Full detail in git history — commits `8eb961b6` `db864326` `6cf18c3b` `165714e1` `8c0e2b42` `e545706c` `b1ee3ada` `4fc3f7a3` `101e4116` `4fa4327a` `c2598d21`.
|
||||||
|
|
||||||
### 🔍 Discovery pass 2 (2026-07) — perf / security / correctness (agent-surveyed, single-pass — NOT yet TPVR'd)
|
### ✅ Discovery pass 2 (2026-07) — perf / security / correctness — DONE
|
||||||
|
|
||||||
Three agents swept fresh lenses (performance, security/privacy, correctness in under-covered subsystems); each verified its own findings against the code + SDK. `Sev`/`Eff` = S/M/L. Confirm before fixing (no independent TPVR yet).
|
Agent-surveyed findings, each **verified against the code before fixing**, then implemented and gate-green (tsc + eslint + prettier + 857 tests + build), with **two review agents on every staged diff before commit**. Verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then remove this note.
|
||||||
|
|
||||||
**Performance / rendering:**
|
- [x] **PERF-1 — presence: 3 client listeners PER avatar → one shared presence store** (3 listeners total). `8a154051`.
|
||||||
|
- [x] **PERF-2 — `#`-mention autocomplete mutated + re-sorted the shared `allRoomsAtom` every keystroke** → copy + `useMemo` (also fixed a real shared-array mutation hitting ~27 consumers). `4708a179`.
|
||||||
|
- [x] **PERF-3 — read-receipt rows: ~6 global `Members` listeners per row → one shared member-change store** (`useRoomMemberChange`). `1b8f5545`.
|
||||||
|
- [x] **PERF-4 — message-search room filter re-sorted every render** → `useMemo`. `4708a179`.
|
||||||
|
- [x] **PERF-5 — DM-preview `Decrypted` listener ran for every nav item** → gated on `direct`. `4708a179`.
|
||||||
|
- [x] **SEC-1 / SEC-2 — scheduled-message plaintext + recent searches survived logout** → `clearPlaintextCaches()` on both logout paths, extended to the whole `recent_*` family + nav-paths. `726cefb5`.
|
||||||
|
- [x] **SEC-3 — `window.open(_blank)` without `noopener`** → `noopener,noreferrer` at 5 sites (SSOStage excluded — needs the handle). `3e1106b2`.
|
||||||
|
- [x] **SEC-4 — `/acl` self-lockout footgun** → shared `serverAcl.ts` validation, no-brick allow default, fail-closed self-ban guard. `3e1106b2`.
|
||||||
|
- [x] **COR-1 — space-child UNLINK over-deleted** → targeted `UNLINK` reducer action. `fd3b8b42`.
|
||||||
|
- [x] **COR-2 — `useCallJoined` stuck true on 2nd-call embed swap** → re-seed on `[embed]`. `1f80d1d1`.
|
||||||
|
- [x] **COR-3 — incoming-call lifetime guard only corrected future clock-skew** → `Math.abs(...)` (±20s). `ab01d27a`.
|
||||||
|
- [x] **COR-4 — shared notify-dedupe slot double-notified** → key by `roomId|threadId`. `1f80d1d1`.
|
||||||
|
- [x] **COR-5 — upload cancel ignored during retry back-off** → `AbortSignal` threaded into the retry loop. `ab01d27a`.
|
||||||
|
- [x] **COR-6 — `CallControl.forceState` dropped `screenshareAudioMuted`** → passes it. `ab01d27a`.
|
||||||
|
|
||||||
- [ ] **PERF-1 — Presence: 3 global client listeners registered PER avatar (Sev High · Eff M).** `hooks/useUserPresence.ts:43-53` (via `PresenceRingAvatar.tsx:19`, `MembersDrawer.tsx:142`) — every timeline/member avatar adds `mx.on` for `Presence`/`CurrentlyActive`/`LastPresenceTs` → 100–250 global listeners; each presence event fans out across all, and fast scroll churns add/remove per Message mount. Fix: one shared presence store (context + `useSyncExternalStore` or a jotai `atomFamily`) with 3 listeners total; components select by userId.
|
**Deferred / decided (not built):**
|
||||||
- [ ] **PERF-2 — `#`-mention autocomplete re-sorts AND mutates the room list every keystroke (Sev Med · Eff S).** `components/editor/autocomplete/RoomMentionAutocomplete.tsx:83` — `useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx))` is unmemoized (O(N log N) `mx.getRoom` per compare, N=all joined rooms, per keypress) **and `.sort()` mutates the shared `allRoomsAtom` array in place.** Fix: `useMemo(() => [...rooms].sort(...), [rooms, mx])`.
|
|
||||||
- [ ] **PERF-3 — Read-receipt rows attach per-instance global `RoomStateEvent.Members` listeners (Sev Med · Eff M).** `components/read-receipt-avatars/ReadReceiptAvatars.tsx:74` + `hooks/useMemberAvatar.ts:44` — up to ~6 global `Members` listeners per receipt row; fires on any membership/name/avatar change in any room. Fix: one shared room-member-change subscription fanning out by roomId+userId (shares with PERF-1).
|
|
||||||
- [ ] **PERF-4 — Message-search room filter re-sorts full room list every render (Sev Low–Med · Eff S).** `features/message-search/SearchFilters.tsx:155` — `Array.from(...).sort(factoryRoomIdByAtoZ(mx))` outside `useMemo` (re-sorts as the user types). Fix: `useMemo`.
|
|
||||||
- [ ] **PERF-5 — DM-preview hook subscribes a global `Decrypted` listener for EVERY nav item incl. non-DMs (Sev Low–Med · Eff S).** `hooks/useRoomLatestRenderedEvent.ts:64-67` (called at `RoomNavItem.tsx:681`) — preview is only used for `direct` rooms but the hook + its `MatrixEventEvent.Decrypted` listener run for all; wasteful, and fans out across all during bulk decryption. Fix: gate the hook/subscription on `direct`.
|
|
||||||
- [ ] **PERF-6 — Avatar-decoration profile-fetch on first render of large member/timeline views (Sev Low · Eff M, likely leave).** `hooks/useAvatarDecoration.ts` — one `/profile/{userId}` per new user (well-guarded: module cache + in-flight dedupe + backoff). Network/HS-load note only; batch/skip only if it proves costly.
|
|
||||||
|
|
||||||
**Security / privacy:** _(no exploitable XSS found — `sanitize.ts` strips `javascript:`, forces mxc `<img>` + `rel=noopener` `<a>`; embeds use host-allowlist + origin-scoped `postMessage`; KaTeX `trust:false`. These are storage-hygiene / URL items.)_
|
- [DEFERRED] **PERF-6 — avatar-decoration `/profile` fetch** — well-guarded (module cache + in-flight dedupe + backoff); a network/HS-load note only. Batch/skip only if it proves costly.
|
||||||
|
- [DEFERRED] **SEC-5 — embeds' `allow-popups-to-escape-sandbox`** — informational; main-app hijack already prevented (no `allow-top-navigation`), and popups are arguably needed for "open in provider." Revisit with per-provider verification if dropped.
|
||||||
|
- **KE-1 preventive (`navigator.storage.persist()`)** is **already implemented** (`initClient` → `requestPersistentStorage()` + `src/index.tsx` boot). The rest of the KE cluster stays under **Encryption / E2EE** below (needs live capture).
|
||||||
|
|
||||||
- [ ] **SEC-1 — Scheduled-message plaintext persists in `localStorage`, survives logout (Sev Med · Eff S).** `state/scheduledMessages.ts:12-19` — full `IContent` (incl. E2EE cleartext `body`) under `cinny_scheduled_messages_v1`; normal logout (`removeFallbackSession`, N98) keeps it. Leak on shared/kiosk browser. Fix: clear on logout (add to `removeFallbackSession`) — or encrypt-at-rest with N97.
|
### 🔍 Feature bug hunt (2026-07, 5-agent, LOTUS_FEATURES surface) — open findings
|
||||||
- [ ] **SEC-2 — Recent search terms persist in `localStorage`, survive logout (Sev Low · Eff S).** `state/recentSearches.ts:4-12` — last 10 queries (often PII) under `cinny_recent_searches_v1`, survive normal logout. Fix: clear on logout.
|
|
||||||
- [ ] **SEC-3 — `window.open(url, '_blank')` without `'noopener'` → reverse tab-nabbing (Sev Low · Eff S).** `components/user-profile/UserChips.tsx:117` (+ `SSOStage.tsx:23`, `settings/devices/Verification.tsx:276`, `OtherDevices.tsx:36,49`, `OidcManageAccount.tsx:23`) — `window.open` doesn't null `window.opener`. UserChips opens a user/room-derived server URL. Fix: pass `'noopener,noreferrer'` (or set `w.opener = null`).
|
|
||||||
- [ ] **SEC-4 — `/acl` has no `*`/self-lockout guard or glob validation (Sev Low · Eff S–M).** `hooks/useCommands.ts:489-538` — `/acl -d *` writes `deny:['*']` (can brick the room); empty/whitespace globs pushed unvalidated into `m.room.server_acl`. PL-gated + matches upstream, but a destructive footgun. Fix: reject a `*`/empty deny matching the local server, validate glob syntax, confirm on `*`.
|
|
||||||
- [ ] **SEC-5 — Embeds get `allow-popups-to-escape-sandbox` (Sev Low, informational · Eff M).** `components/url-preview/UrlPreviewCard.tsx:590-591` (`EMBED_SANDBOX`) — a hijacked provider script could spawn an un-sandboxed popup (phishing). Main-app hijack already prevented (no `allow-top-navigation`); popups arguably needed for "open in provider." Fix (if desired): drop the escape flag + verify per-provider.
|
|
||||||
|
|
||||||
**Correctness (under-covered subsystems):**
|
Per-slice bug hunt (5 agents: theming · calls · messaging · threads/presence/UX · rooms/mod/notif/infra/desktop), each **verified against current code** (already-fixed items not re-flagged; the heavily-audited hot paths came back clean). Residual findings below. `[live]` / `[desktop]` = needs a real call / the desktop app to confirm.
|
||||||
|
|
||||||
- [ ] **COR-1 — Space-child UNLINK nukes a room's OTHER parents + orphans descendants (Sev Med · Eff M).** `state/room/roomToParents.ts:102-103` (DELETE reducer `:53-66`) — removing one `m.space.child` fires the same `DELETE` used for "room left," wiping ALL of the child's parents and stripping it as a parent everywhere. Remove C from space A only → C also loses parent B, and C's own children lose C, until a full resync. Fix: a targeted "remove one parent from one child" action. (Reducer inherited from upstream.)
|
**Embeds / URL previews**
|
||||||
- [ ] **COR-2 — `useCallJoined` stays `true` across a direct embed swap (answering a 2nd call) (Sev Med · Eff S).** `hooks/useCallEmbed.ts:140-158` — the reset effect only clears `joined` when `!embed`; answering call B from the banner swaps embed A→B directly, so `joined` stays true → B renders as live/joined before EC actually joins, skipping the loading/watchdog UI. Fix: `useEffect(() => setJoined(embed?.joined ?? false), [embed])`.
|
|
||||||
- [ ] **COR-3 — Incoming-call lifetime guard only corrects FUTURE clock-skew, not past (Sev Low · Eff S).** `components/CallEmbedProvider.tsx:476-477` — distrusts `sender_ts` only when >20 s ahead of `origin_server_ts`; a caller clock that's behind leaves `sender_ts` in the past → `remaining ≤ 0` → ring auto-dismisses/never shows for a fresh invite. Fix: `Math.abs(...) > 20000` or just use `event.getTs()`.
|
- [x] **[Med] Desktop (Tauri) CSP `frame-src` was missing `store.steampowered.com`, `www.mixcloud.com`, `widget.deezer.com`** → the Steam widget (shipped) + new Mixcloud/Deezer embeds were silently blocked (blank iframe) **in the desktop app**. **FIXED** (`cinny-desktop` `daba59b`): all three added to `frame-src` (no `connect-src` — these don't do a client oEmbed fetch). Web was always fine (`frame-src 'self' https:`). Needs desktop-app QA to confirm the widgets render.
|
||||||
- [ ] **COR-4 — Shared notify-dedupe slot double-notifies a re-fired main message after an interleaving thread reply (Sev Low · Eff S).** `pages/client/ClientNonUIFeatures.tsx:521,530` — `lastNotifiedEventRef` is one `Map<roomId,eventId>` slot shared by the main + thread paths; a thread reply overwrites it, so a re-emitted main message (decrypt/edit re-fire, common in E2EE) mis-dedupes and notifies again. Fix: separate dedupe keys per path (or key by `roomId|eventId`).
|
- [x] **[Low]** `searchCache.ts` encrypted-search index has no size/count cap — unbounded on-disk growth (mitigated by the manual "Clear cached index" + logout wipe). **FIXED** (`fff811cb`): per-room cap of 5000 rows, oldest-by-ts evicted on write via a self-chaining IDB cursor + pure unit-tested `evictCount`. IDB-spec correctness (cursor delete/continue, tx liveness, range bracketing) confirmed by 2 review agents since CI can't run IndexedDB.
|
||||||
- [ ] **COR-5 — Upload cancel during retry back-off is silently ignored (upload resurrects) (Sev Low · Eff M).** `utils/matrix.ts:204-238` + `state/upload.ts:125-129` — during the 1–30 s back-off the atom holds the previous (rejected) promise, so `cancelUpload` is a no-op and the loop wakes to start the next attempt. Fix: thread an abort flag into the retry loop.
|
- [x] **[Low]** `MsgTypeRenderers.tsx` `MLocation` OSM permalink uses raw `geo:` lat/lon substrings, not the validated floats — harmless (URL context, malformed input only). **FIXED** (`8a461610`): permalink uses the `parseFloat`+`isFinite` validated `lat`/`lon` (as the map iframe already did).
|
||||||
- [ ] **COR-6 — `CallControl.forceState` resets `screenshareAudioMuted` to false (Sev Low · Eff S).** `plugins/call/CallControl.ts:155-162` — `forceState` passes only 5 args so the 6th defaults false; low impact today (runs at join, no screenshare yet) but silently discards the field. Fix: pass `this.screenshareAudioMuted`.
|
|
||||||
|
**Voice / video calls**
|
||||||
|
|
||||||
|
- [x] **[Med]** `DenoiseTester.play()` (Settings → Calls A/B model test) leaks the denoise model node — calls `ctx.close()` but never `denoise.dispose()` (inconsistent with `stopLive`, which disposes) → leaks the DeepFilterNet/DTLN worker/WASM per press. **FIXED** (`c9d9d914`): `stopPlayback` now mirrors `stopLive` (dispose model + gate), and a generation token also closes the rapid-click / stop-during-load / unmount-during-load leak windows (3 review passes, all 6 interleavings traced).
|
||||||
|
- [x] **[Med] [live]** PiP auto-spotlight never released on return to the call room — the release branch sits inside the `if (!pipMode) return` guard, so screenshare→PiP→back leaves spotlight forced on and `pipAutoSpotlightRef` stuck `true`. `CallEmbedProvider.tsx:733-744`. **FIXED** (`08e19100`, code-level; still wants live QA): effect guards only on `!callEmbed`, releases whenever `pipMode && pipScreenshare` is false; + ref-reset on embed teardown + deps comment (2-agent reviewed).
|
||||||
|
- [x] **[Low]** DenoiseTester async paths (`getUserMedia`) have no mounted-guard → ctx/stream leak + setState-after-unmount if Settings closes during the mic prompt. **FIXED** (`c9d9d914`): a `mountedRef` guards `startLive`/`startRecord` after the `getUserMedia` await (and `play()` after its model load); the ref is set on mount, not only cleared on unmount, so it survives a StrictMode/Activity remount.
|
||||||
|
- [x] **[Low]** Soundboard 30s safety timeout never cleared on natural clip end (`CallSoundboard.tsx:115`); `PrescreenControls` `PermissionStatus.onchange` not removed on unmount (`PrescreenControls.tsx:22-28`). **FIXED** (`56561627`): per-play timer token cleared on end/unmount (identity-guarded so a stale clip can't disarm a newer one); permission `onchange` detached + `cancelled`-guarded setState.
|
||||||
|
- [ ] **[Low] [live]** Call-to-call switch disposes the embed without an explicit `HangupCall` → possible transient ghost RTC membership until EC's unload-leave fires.
|
||||||
|
|
||||||
|
**Theming / visuals**
|
||||||
|
|
||||||
|
- [x] **[Med]** `invalidateDecorationCache` clears the module cache but has no pub/sub → changing **your own** avatar decoration doesn't update live in already-mounted avatars (timeline/members) until remount. Add a listener set / bump counter. `useAvatarDecoration.ts:67`. **FIXED** (`29ff1654`): per-user listener set notified on invalidation (+ clears the give-up counter); concurrent re-fetches de-dupe via the existing `pending` map.
|
||||||
|
- [x] **[Med/Low]** Decoration picker grid thumbnails use the raw `DECORATION_CDN` constant instead of `decorationUrl()`, ignoring the `VITE_DECORATION_CDN` override → broken thumbnails if decorations are repointed. `ProfileDecoration.tsx:51`. **FIXED** (`29ff1654`): grid uses `decorationUrl(slug)`.
|
||||||
|
- [x] **[Low]** Seasonal "Auto" is computed once at mount (no ticker, unlike NightLight) → won't flip across a holiday-window boundary in a long-lived session. `SeasonalEffect.tsx:100`. **FIXED** (`d416c62b`): hourly re-eval ticker (auto only) + refresh on entering auto; decision extracted to pure `resolveSeasonTheme` + tested.
|
||||||
|
- [x] **[Low]** Selecting seasonal "Auto" while a chat background is set is a silent no-op (asymmetric mutual exclusion — SeasonalEffect early-returns when `chatBackground !== 'none'`). `General.tsx:550`. **FIXED** (`d416c62b`): any active seasonal mode (incl. auto) now clears the chat background; only "off" leaves it (symmetric with the bg picker).
|
||||||
|
- [x] **[Low]** Decoration settings fetch the `/{field}` sub-resource → console 404 for users with no decoration set. `ProfileDecoration.tsx:79`. **FIXED** (`29ff1654`): reads the full `/profile/{userId}` (matching `useAvatarDecoration`); PUT/save path unchanged.
|
||||||
|
|
||||||
|
**Threads / presence / UX**
|
||||||
|
|
||||||
|
- [x] **[Med]** `PresenceBadge` renders DND (`unavailable` + `status_msg:'dnd'`) as a **yellow "Idle"** badge + label, while `PresenceRingAvatar` correctly shows **red** — inconsistent. Give the badge the same `status === 'dnd' → Critical` + "Do Not Disturb" branch. `Presence.tsx:17-59`. **FIXED** (`29ff1654`): badge now matches the ring + settings picker (Critical / "Do Not Disturb", `'dnd'` sentinel line suppressed).
|
||||||
|
- [x] **[Med]** Collapsible-message threshold is hardcoded (`COLLAPSE_MAX_HEIGHT = 320`), but the docs claim it's "configurable in Settings → Appearance (default 20 lines)" — unimplemented. Add the setting + control, or fix the doc. `MsgTypeRenderers.tsx:38`. **FIXED** (doc): LOTUS_FEATURES now describes the fixed 320px (≈20-line) threshold; the full 320px is sensible and a per-user setting wasn't worth the surface — reconciled the doc rather than build a marginal setting.
|
||||||
|
- [x] **[Med/Low]** In-app toast container has no visible cap / scroll — a burst of messages across rooms while focused stacks toasts unbounded and can cover the viewport. Cap visible N or `overflow-y:auto` + max-height. `LotusToastContainer.tsx:223-247`. **FIXED** (`1963222d`): queue capped at 5 in the atom writer (drops oldest non-sticky, never the newest or a sticky action toast) + container maxHeight/overflow + scroll-to-newest; +4 tests. (3 review passes — the 2nd caught a newest-dropped edge when the cap is full of stickies.)
|
||||||
|
- [x] **[Low]** "Unread First" room sort leaves the (larger) read portion unordered — no activity fallback for the equal-unread case. `Home.tsx:213-222`. **FIXED** (`1963222d`): `factoryRoomIdByUnread` breaks ties by recent activity; relocated to `utils/sort.ts` (pure) + unit-tested.
|
||||||
|
- [x] **[Low]** Tab title "(N)" counts mentions, not unread messages (doc says unread) — reconcile doc vs. code. `ClientNonUIFeatures.tsx:120-123`. **FIXED** (doc): the mention-count + unread-dot behavior is intentional (mirrors the favicon); LOTUS_FEATURES now describes it accurately (N = highlights, `·` = other unread).
|
||||||
|
|
||||||
|
**Rooms / moderation / notifications / infra / desktop**
|
||||||
|
|
||||||
|
- [ ] **[Med] [desktop]** `useTauriFocusAssist` never queries the initial OS Focus-Assist state on mount (unlike `useTauriDnd`, which rehydrates via `get_tray_dnd`) → if Focus Assist is already ON at launch, notifications/sounds leak through until the OS state next flips. Add a `get_focus_assist` mount query (confirm whether the native poll emits an initial reading). `useTauriFocusAssist.ts:18-24`.
|
||||||
|
- [x] **[Low]** Push-rule enable toggle holds stale local `useState` after an external rule change (toggled on another device) — sync from the `pushRule.enabled` prop. `PushRuleEditor.tsx:55-79`. **FIXED** (`2c0cd0d2`): `useEffect` resyncs on `pushRule.enabled` change (prop flows from live `useAccountData(m.push_rules)`; no optimistic conflict).
|
||||||
|
- [x] **[Low]** Server-support `.well-known/matrix/support` is fetched from `mx.getHomeserverUrl()` (client-API host) instead of the MXID **server-name** host → silently missing on delegated/split-domain servers. `About.tsx:45-47`. **FIXED** (`2c0cd0d2`): fetched from `https://{mx.getDomain()}` (MSC1929-correct); identical for non-delegated, graceful catch otherwise.
|
||||||
|
- [x] **[Low]** Cleared/partial quiet-hours `time` input (`''` → window inactive) silently disables the window while the toggle still reads "on" — no feedback. `SystemNotification.tsx:364-382`. **FIXED** (`5175c095`): inline Critical hint when the toggle is on but a time field is empty.
|
||||||
|
- [~] **[Low] [desktop]** Native quick-reply swallows send errors (`.catch(() => undefined)`); the `show_rich_toast` trigger has no verified web-side caller. `useTauriToastActions.ts:35-38`. **ROOT CAUSE FOUND + web fix shipped** (`0ddf86c6`): `show_rich_toast` was dead because `showOsNotification` preferred the service worker (WebView2 has one), shadowing the injected `window.Notification` shim. Now skips the SW path under Tauri → notifications route to the rich toast, whose click navigates to the message.
|
||||||
|
|
||||||
|
### 🖥️ Desktop notification rich-toast — follow-ups (activated by `0ddf86c6`, need a Windows build)
|
||||||
|
|
||||||
|
The web-side nav fix (`0ddf86c6`) makes the native rich-toast path live for the first time. It fixes click→navigate, but exposes latent behaviors in the **cinny-desktop Rust** that need a Windows build to fix + verify:
|
||||||
|
|
||||||
|
- [ ] **[Med] [desktop]** **Tag-coalescing lost.** The web SW notification used `tag` to _replace_ prior notifications for the same room; `show_rich_toast` (`cinny-desktop/src-tauri/src/native/toast.rs`) ignores `tag` and shows a new WinRT toast every time → rapid same-room messages stack instead of collapsing. Fix: dedupe/replace by room in the toast store (`toast.rs:226-230`).
|
||||||
|
- [ ] **[Med] [desktop]** **Thread / invite quick-reply misroutes.** The reply target is the coalescing `tag` — `${roomId}:${threadId}` for thread replies, `'lotus-invites'` for invites (`ClientNonUIFeatures.tsx:471,192`) — not a real room id, so `mx.sendMessage(tag, …)` fails silently (`useTauriToastActions.ts:37`). Body-click navigation is correct (uses `path`). Fix: pass the real `roomId` separately (e.g. `data.roomId`) and have the shim (`lib.rs` `NOTIFICATION_BRIDGE`) + `toast.rs` use it for the reply target; keep `tag` for coalescing. Invite toasts should also drop the reply box (nothing to reply to).
|
||||||
|
- [ ] **[desktop QA] Windows notification checklist** (verify `0ddf86c6` + the above): (1) confirm the pre-fix symptom was focus-without-navigate; (2) message toast → click navigates to the message, quick-reply sends to the room; (3) thread toast → navigates, reply currently misroutes (until fixed above); (4) invite toast → navigates to invites; (5) rapid same-room messages → stacking until coalescing restored; (6) AUMID-missing/dev build → plain-notification fallback still shows; (7) web PWA unaffected.
|
||||||
|
- [x] **[Low]** Export-history date-range early-break can over-paginate + mislabel "truncated" in E2EE rooms (`oldestRawTs` only advances on decrypted `m.room.message`, so undecryptable old events never move it). `ExportRoomHistory.tsx:104,136`. **FIXED** (`3ff8fb8e`): boundary now advances on every event (getTs is envelope metadata), above the type/decryption filters; guarded `ts > 0` so a bogus 0-ts can't cause the opposite (silent under-pagination). 2-agent reviewed.
|
||||||
|
- [x] **[Info/doc]** `PolicyListViewer` is a manual room-ID/alias viewer with **no** subscribe/unsubscribe controls and no subscribed-lists listing — `LOTUS_FEATURES.md:1287` describes both. Docs oversell; not a runtime bug. **FIXED** (`8a461610`, doc): LOTUS_FEATURES corrected to describe the read-only room-ID/alias viewer (no subscribe controls).
|
||||||
|
|
||||||
|
### ✅ Composer autocomplete-insert crash (reported 2026-07) — FIXED (`477df4ae`)
|
||||||
|
|
||||||
|
Picking an autocomplete item (mention/emoji/command) occasionally tripped the composer error boundary ("encountered an error" → forced refresh) even though the element inserted. Root-caused (3 agents, incl. a headless slate simulation) to `moveCursor` deferring its cursor work to `setTimeout`, leaving the caret on the just-inserted inline-void's zero-width edge; slate-react's commit-phase `setBaseAndExtent(voidEdge, 1)` then threw `IndexSizeError` mid-render → boundary. **Fix:** do `Transforms.move` (escape the void) + `insertText(' ')` synchronously in the same commit as the insert, so the caret is a resolvable text point when the selection sync runs. Plus a recoverable boundary ("Reload composer" + `onReset` deselect) so any residual composer crash no longer needs a page refresh. (A first "sync insertText without move" attempt was caught in review — the void guard drops the space + traps the caret; `move` is required.)
|
||||||
|
|
||||||
### ✅ Unread/read-receipt flakiness (reported 2026-07) — FIXED (pending prod QA)
|
### ✅ Unread/read-receipt flakiness (reported 2026-07) — FIXED (pending prod QA)
|
||||||
|
|
||||||
@@ -155,6 +205,31 @@ Genuine Matrix client-spec / MSC features Lotus does **not** yet implement (audi
|
|||||||
|
|
||||||
**Server-gated / advanced (capture, don't build yet):** QR sign-in for a new device (**MSC4108** rendezvous — needs an HS-side endpoint); dehydrated devices (**MSC3814** — offline key delivery, also helps the E2EE KE cluster); E2EE history key sharing on invite (**MSC3061** `shared_history`, niche); voice broadcast (Element MSC3888, low value — skip).
|
**Server-gated / advanced (capture, don't build yet):** QR sign-in for a new device (**MSC4108** rendezvous — needs an HS-side endpoint); dehydrated devices (**MSC3814** — offline key delivery, also helps the E2EE KE cluster); E2EE history key sharing on invite (**MSC3061** `shared_history`, niche); voice broadcast (Element MSC3888, low value — skip).
|
||||||
|
|
||||||
|
### [PARKED] Matrix 2.0 call membership — MSC4354 Sticky Events (investigated 2026-07, 3 agents + live infra check)
|
||||||
|
|
||||||
|
Move MatrixRTC/Element Call call-membership from state events (MSC3401) to **sticky events** — the "Matrix 2.0" path. **Not a flag flip; a coordinated rollout. Parked deliberately.**
|
||||||
|
|
||||||
|
Findings:
|
||||||
|
|
||||||
|
- **Server (Synapse 1.157.1, LXC 151):** `msc4354_enabled` defaults `false`. Enabling is **low-risk, additive, reversible** — schema (`sticky_events` table) already ships unconditionally, no migration/backfill, all runtime paths flag-gated, residual rows self-expire ≤1h. The one historical `/sync` EDU-filter bug (#19787) was fixed in 1.155.0; SQLite guard N/A (we're Postgres).
|
||||||
|
- **The flag alone is a no-op for behavior.** Our EC fork (upstream **v0.20.1** base, `@lotusguild/element-call-embedded`, bundled into Cinny at build → fleet upgrades atomically) gates sticky mode behind BOTH server support AND a per-device **developer-settings** radio (`matrix-rtc-mode`, defaults `Legacy`). Enabling the flag only un-greys that radio; no client changes what it sends until a human toggles it.
|
||||||
|
- **Matrix-layer mixed-mode = safe:** js-sdk (v41.6.0) reads + merges sticky and state membership, so cross-mode participants see each other.
|
||||||
|
- **Open risk before any real rollout:** media layer. Sticky mode drops `livekit_alias` + uses lk-jwt-service `/get_token` (slot `m.call#ROOM`); legacy uses `/sfu/get` (`room=roomId`). Both endpoints are **live** on our lk-jwt-service, but whether they resolve to the **same LiveKit room** is unverified — must confirm with a **two-account cross-mode test call** (one device `Matrix_2_0`, one `Legacy`) before changing the default, else split-at-media.
|
||||||
|
|
||||||
|
To actually adopt (future): (1) enable `msc4354_enabled: true` + restart; (2) two-account media-interop test; (3) if unified, flip EC default mode `Legacy`→`Compatibility`/`Matrix_2_0` in the fork + redeploy; (4) keep legacy fallback during transition. **No user benefit until step 3.**
|
||||||
|
|
||||||
|
### [ ] Matrix 2.0 call membership — MSC4354 sticky events (INVESTIGATED 2026-07, deliberately NOT enabled)
|
||||||
|
|
||||||
|
3-agent investigation after the 1.157.1 upgrade (EC-fork behavior · Synapse/upstream readiness · client-fleet composition). **Conclusion: leave `msc4354_enabled` OFF for now** — enabling it is safe but delivers **zero user-visible benefit on its own**, and introduces a latent footgun.
|
||||||
|
|
||||||
|
**Why it's a no-op alone:** the EC fork's `doesServerSupportUnstableFeature(MSC4354)` probe feeds **exactly one thing** — whether the "Matrix 2.0" radio in **Developer Settings** is greyed out (`DeveloperSettingsTab.tsx:349-353`). The real switch is the per-device `matrixRTCMode` setting (`settings.ts:149-152`), which **defaults to `Legacy`** and never auto-enables. Sticky sending is gated at `LocalMember.ts:862` (`unstableSendStickyEvents: mode === Matrix_2_0`). So flipping the server flag changes nothing any client sends.
|
||||||
|
|
||||||
|
**Verified safe:** Synapse-side is **additive and cleanly reversible** — the `sticky_events` schema ships unconditionally (no migration/backfill on enable), every write/read/serialize/replication path is flag-gated, disabling stops it instantly and residual rows self-expire ≤1h. The one relevant bug (#19787 `/sync` EDU-filter) was fixed in 1.155.0; the SQLite<3.40 guard doesn't apply (we're on PG 17.10). Matrix-layer **mixed-mode visibility is safe**: js-sdk `collectMembersEvents` reads **both** sticky and state membership and merges them, so sticky-mode and legacy-mode participants see each other. Our `lk-jwt-service` already serves **both** JWT endpoints (legacy `/sfu/get` **and** the sticky-mode `/get_token` — both probed live, 400-with-validation-error = present). EC is bundled into cinny's build (`@lotusguild/element-call-embedded`), so the fleet upgrades **atomically** — the "all EC clients ≥ v0.17.0" precondition is structurally guaranteed for our own users.
|
||||||
|
|
||||||
|
**The one unresolved risk (blocks a real rollout, not the flag):** sticky mode drops `livekit_alias` and uses `/get_token` (slot `m.call#ROOM`) while legacy uses `/sfu/get` (`room=roomId`). **Whether both resolve to the same LiveKit room is a property of lk-jwt-service, not the client** — unverified. If they diverge, cross-mode participants appear in each other's member list but are **split at the media layer** (silent, no error). Requires a **two-account test call** (one device on Legacy, one on Matrix 2.0) to confirm before anyone relies on it.
|
||||||
|
|
||||||
|
**If we ever do this:** (1) run the two-account media-interop test; (2) only then consider enabling `msc4354_enabled: true` in `/etc/matrix-synapse/homeserver.yaml` (LXC 151) + restart; (3) treat a default-mode change as a separate coordinated EC rollout. MSC4354 is still **OPEN upstream** (not in FCP, `needs-implementation`), so this stays experimental regardless.
|
||||||
|
|
||||||
### Remaining spec/MSC gaps (2026-07 full-surface survey)
|
### Remaining spec/MSC gaps (2026-07 full-surface survey)
|
||||||
|
|
||||||
After Phases A–C the client spec is ~complete. What's left, flagged by **what unblocks it**:
|
After Phases A–C the client spec is ~complete. What's left, flagged by **what unblocks it**:
|
||||||
@@ -209,13 +284,25 @@ Shipped in the EC fork (DeepFilterNet3 default-capable / DTLN / RNNoise / Speex;
|
|||||||
|
|
||||||
Phase 1 shipped: `io.lotus.set_deafen` (LiveKit-source deafen/screenshare-audio-mute) replaces the brittle `<audio>.muted` iframe hack; cinny sends it join-gated alongside the transitional DOM fallback. **Phase 2 (blocked on user npm publish):** publish fork `0.20.1-lotus.2` → bump cinny pin `lotus.1`→`lotus.2` → delete the `CallControl.ts` `.muted` fallback + the EC1–EC6 fixes ship. **Deferred pieces (P6-2b):** the `useCallSpeakers` DOM-scrape is a dormant fallback behind `io.lotus.call_state`; `.click()`-by-`data-testid` UI toggles are low-value fork surface. Divergence to confirm: deafen doesn't silence soundboard/`Unknown`-source audio (setVolume type limit).
|
Phase 1 shipped: `io.lotus.set_deafen` (LiveKit-source deafen/screenshare-audio-mute) replaces the brittle `<audio>.muted` iframe hack; cinny sends it join-gated alongside the transitional DOM fallback. **Phase 2 (blocked on user npm publish):** publish fork `0.20.1-lotus.2` → bump cinny pin `lotus.1`→`lotus.2` → delete the `CallControl.ts` `.muted` fallback + the EC1–EC6 fixes ship. **Deferred pieces (P6-2b):** the `useCallSpeakers` DOM-scrape is a dormant fallback behind `io.lotus.call_state`; `.click()`-by-`data-testid` UI toggles are low-value fork surface. Divergence to confirm: deafen doesn't silence soundboard/`Unknown`-source audio (setVolume type limit).
|
||||||
|
|
||||||
### [ ] Mobile audit
|
### [~] Mobile audit — code-level pass DONE (device QA + deferred items open)
|
||||||
|
|
||||||
Comprehensive audit of all LOTUS_FEATURES.md features for mobile PWA usability + responsiveness. Method: 44px touch targets, no horizontal overflow, full-screen modals/drawers on mobile, composer not obscured by keyboard.
|
Comprehensive **code-level** responsive audit of the LOTUS_FEATURES surface (12 survey agents — 6 area slices + 6 deep per-feature dives — each finding verified, fixed in reviewed batches, then a 5-agent all-files regression+efficacy gate; gate-green tsc/eslint/857 tests/build). Shipped `lotus` commits `d6159997` `836e4a66` `4c298a36` `09415f95` `36fdbdd3` `154e35ef` `09f37f89` (M1–M6 + N1–N2): message-table/composer/call-bar/url-preview/explore overflow fixes; full-screen media/file/avatar viewers + touch-pan for zoomed images; full-screen scrollable member profile (+close btn); native SettingsSelect + tile-body volume sliders + measured GifPicker; full-screen Report/"Seen by" dialogs + full-width toasts + popover clamps; **image/video aspect-ratio (no crop/letterbox on phones)**; 44px room-row + space-rail touch targets. The app was found **structurally sound** on mobile (thread panel, dialogs, drawers, settings shells, ACL/widgets/search/QR/auth all already responsive).
|
||||||
|
|
||||||
|
Intentional desktop deltas (disclosed, non-regressive): volume sliders below labels; Report dialog 380→480px & "Seen by" modals 460→360px (sibling-modal normalization); translate select → folds SettingsSelect.
|
||||||
|
|
||||||
|
**NOT done — needs a real device / product decisions (open):**
|
||||||
|
|
||||||
|
- [ ] **Runtime mobile QA** — none of the above is validated on an actual phone (static analysis only). Needs device/devtools walk-through per LOTUS_TESTING §E.
|
||||||
|
- [x] **Element Call fork in-call mobile UI** — DONE (`element-call:lotus` `e36aef8a`, 3-agent survey + 2-agent review). Fixed the EC iframe's own phone UI: footer control row wraps so hangup can't clip (320–500px), portrait 1:1 self-PiP safe-area inset, 44px camera-flip + reaction-picker targets, settings-tab horizontal scroll, landscape spotlight filmstrip. All mobile-gated (EC is mobile-first CSS). Rides to users on the next fork republish (P6-2). Runtime on-device QA still pending (needs a phone).
|
||||||
|
- [ ] **M2 — touch discoverability** — message quick-reactions/actions are hover-gated; long-press is the fallback but is **unreliable on iOS Safari** (deep audit). A visible touch affordance is needed but the naive fix hides unread badges / clutters messages (member-profile-style redesign).
|
||||||
|
- [~] **Sub-44px touch-target sweep** — primary controls DONE via a shared `MobileTouchTarget` `@media` class (`P1`, `8a1168bc`): in-call bar ×7, call-status bar ×4, thread "N replies" chip, knock Approve/Deny, ACL remove. Secondary batch DONE (`r2`, `72e7447d`): image-viewer close/zoom±/zoom%/download, embed-player Close/Collapse/Fullscreen/View-post, read-receipt "seen by" pill. **Deferred (rationale, not built):** PiP fullscreen/resize handles — enlarging four 24px corners to 44px would swallow a ~160px mobile PiP and block "Return to call" (needs a design rethink, not a blunt bump); presence dot is a non-interactive status indicator (no target needed).
|
||||||
|
- [x] **Avatar-decoration `prefers-reduced-motion`** — DONE (`P2`, `c3e1fbff`): renders just the avatar (no animated APNG overlay) under the preference; no static-frame asset to freeze to.
|
||||||
|
- [x] **Twitch/Twitter/TikTok preview cards** — DONE (`r2`, `72e7447d`). These fragment cards render header/thumbnail beside content as direct children of the `UrlPreview` flex row; added `StackOnMobile` (mobile-only `@media (max-width:750px){ flex-direction:column }`) scoped to those variants via `cardClass`. folds `Box` has no default `direction` so the override wins uncontested; desktop unchanged (verified by 2 review agents). Pre-existing desktop quirk (header bar beside content on Twitter/TikTok at desktop width) left as-is — the fuller fix is wrapping each card body in a column `Box`; out of scope for a mobile pass.
|
||||||
|
- [ ] **M2 — message action/quick-reaction touch discoverability** — hover-gated + iOS-long-press-unreliable; a visible touch affordance collides with unread-badge placement / per-message clutter → needs a design decision + device look.
|
||||||
|
|
||||||
### [ ] Inline media embeds — remaining providers (LOW PRIORITY)
|
### [ ] Inline media embeds — remaining providers (LOW PRIORITY)
|
||||||
|
|
||||||
The inline embed system (`videoEmbed.ts`) covers 16 providers; three more were **deliberately deferred** (verified against 2026 docs by review agents):
|
The inline embed system (`videoEmbed.ts`) covers 18 providers (16 + Mixcloud/Deezer); three more were **deliberately deferred** (verified against 2026 docs by review agents):
|
||||||
|
|
||||||
- **Bandcamp** (highest-value audio add) — needs an **oEmbed** round-trip: the player URL requires numeric `album`/`track` item ids that aren't in the page URL (`bandcamp.com/oembed` is the resolver; mirror the `TikTokEmbedCard` on-click oEmbed pattern). CSP `frame-src`: `bandcamp.com`. Classify `kind: 'audio'`.
|
- **Bandcamp** (highest-value audio add) — needs an **oEmbed** round-trip: the player URL requires numeric `album`/`track` item ids that aren't in the page URL (`bandcamp.com/oembed` is the resolver; mirror the `TikTokEmbedCard` on-click oEmbed pattern). CSP `frame-src`: `bandcamp.com`. Classify `kind: 'audio'`.
|
||||||
- **SoundCloud `on.soundcloud.com` short links** — the `w.soundcloud` widget resolver does **not** follow the redirect; needs the same on-click oEmbed resolve (`soundcloud.com/oembed`, CORS-enabled) to get the canonical URL. (Canonical `soundcloud.com/{user}/{track}` links already work.)
|
- **SoundCloud `on.soundcloud.com` short links** — the `w.soundcloud` widget resolver does **not** follow the redirect; needs the same on-click oEmbed resolve (`soundcloud.com/oembed`, CORS-enabled) to get the canonical URL. (Canonical `soundcloud.com/{user}/{track}` links already work.)
|
||||||
@@ -223,6 +310,19 @@ The inline embed system (`videoEmbed.ts`) covers 16 providers; three more were *
|
|||||||
|
|
||||||
Also open (from the quality review): a real `onError`/error-state fallback for iframes that fail to load (deleted post / region lock / X login-wall) — cross-origin frames don't fire `onError` reliably, so this needs a load-timeout heuristic; the Close button + badge link are the current escape hatch.
|
Also open (from the quality review): a real `onError`/error-state fallback for iframes that fail to load (deleted post / region lock / X login-wall) — cross-origin frames don't fire `onError` reliably, so this needs a load-timeout heuristic; the Close button + badge link are the current escape hatch.
|
||||||
|
|
||||||
|
**✅ Steam detailed embed (2026-07, 2-agent review) — `ef82650c`.** `store.steampowered.com` content URLs get rich cards: **app** pages → OG capsule header + click-to-play facade → Steam's official `/widget/{id}` store iframe (live region-aware price / discount % / Buy on Steam, gated by `inlineMediaEmbeds`); **news/announcement** → banner + headline + body-preview card; **bundle/sub/dlc** → OG store card. `getSteamTarget`/`steamWidgetEmbedUrl` in `videoEmbed.ts` (+tests). Grounded in prod CSP (`frame-src https:` allows the widget with no infra change; images via homeserver `mxc`; NO client-side Steam API — `connect-src` + Steam CORS both block it, which is the honest ceiling: no review scores/genres/screenshots client-side). **Needs on-device QA:** the live widget iframe height/fit (can't render headlessly) — verify the price/Buy stay visible on desktop-wide and phone.
|
||||||
|
|
||||||
|
**✅ GIF previews now animate + Mixcloud/Deezer embeds (2026-07, 2-agent review) — `4154cae5`.** Reported live: a `media.giphy.com` link "shows the gif's image but doesn't play it." Root cause: **Synapse's `/thumbnail` endpoint flattens animated GIFs to a still first frame**, and every preview image went through it. `GifCard` (Giphy/Tenor) + the generic OG card now request the **original** via `/download` (`mxcUrlToHttp` with no width/height) when the preview is a GIF (`og:image:type === 'image/gif'` or a `.gif` pathname). Guarded: `shouldServeGifOriginal()` keeps the frozen thumbnail past a **10 MB** `matrix:image:size` cap, and the generic card's eager `<img>` gained the `loading="lazy"` it was the only preview image missing. Also added **Mixcloud + Deezer** audio embeds, and fixed Deezer podcasts (they live at `/show/<id>`, **not** `/podcast/<id>` — the latter 404s on Deezer's own oEmbed; verified against the live API). **Needs on-device QA:** confirm a large GIF still animates and doesn't stall the timeline.
|
||||||
|
|
||||||
|
**✅ Embed bug hunt (2026-07, 3 survey agents + 2-agent review) — `f2673eff`.** Core posture verified **sound** (iframe sandbox, `useIframeAutoHeight` postMessage origin+source trust, no XSS/`dangerouslySetInnerHTML`, `rel="noreferrer"` on all 21 links, oEmbed no-SSRF, the whole facade→iframe/abort/observer lifecycle). Fixed: Twitch/Kick/SoundCloud/Streamable reserved-path over-match (utility pages rendered as broken players), Vimeo hash over-capture (`[0-9a-f]{6,}`), Spotify/Steam/Discord/IMDb `og:image` now via `mxcUrlToHttp` (was a broken raw `mxc://` `<img>` + a pre-click 3p-request facade bypass), `wide` class follows the og:url-resolved embed, Twitter host alignment (`mobile.twitter.com`/`/statuses/`), URL de-dupe.
|
||||||
|
|
||||||
|
**Deferred / surfaced from the hunt (not fixed — decide before doing):**
|
||||||
|
|
||||||
|
- **Security-vs-functionality tradeoff (needs a call):** drop `allow-popups-to-escape-sandbox` and/or `clipboard-write` from `EMBED_SANDBOX`/`allow=` on embed iframes — real hardening against a _compromised_ provider (phishing popup / clipboard hijack), but risks breaking a legit provider popup/copy on the trusted major providers we embed. Low marginal value; not shipped blindly.
|
||||||
|
- **Defense-in-depth:** `encodeURIComponent` the Bluesky authority + Apple Music path/search interpolated into the embed `src` (not currently exploitable — host is fixed and value comes from `URL.pathname`; React escapes the attribute).
|
||||||
|
- **Out of embed scope (real, low-sev):** `LotusDenoiseFeature` (`ClientNonUIFeatures.tsx`) has a `window` `message` listener with **no origin/source check** → any frame/window can post `{type:'lotus-denoise-status', error}` and pop a forged **"System"** toast (text only, no XSS). Validate `event.source`.
|
||||||
|
- **Lifecycle Lows (cosmetic/latent):** a re-fetch flips a playing embed back to the spinner (latent — url is keyed); auto-height retained across close→reopen; `extractEmbedHeight` generic `.height` fallback accepts any allowed-origin message; `TweetEmbed` theme is a one-time `matchMedia` snapshot (no live theme switch); host-normalization gaps (`vt.tiktok.com` misses `StackOnMobile`, `m.instagram.com`, `www.youtu.be`).
|
||||||
|
|
||||||
### Deferred / dropped (decided — kept for context)
|
### Deferred / dropped (decided — kept for context)
|
||||||
|
|
||||||
- **[DEFERRED] P5-51** Federated "Identity Contexts" (session isolation) — multi-sprint, touches auth/crypto/storage core; smaller intermediate step = plain multi-account switch. **[DROPPED] P5-52** per-room sync governor — js-sdk can't truly per-room filter `/sync`; only a cosmetic hide. **[DEFERRED] P5-53** local scripting plugin — prefer a declarative automation-rules feature (no arbitrary code). **[DEFERRED] Audit-3** profile banner — MSC4427 open/unmerged; revisit on merge. **[WON'T FIX] P5-50** Windows HW media pipeline (WebRTC decode lives in WebView2; not injectable). **[MOVED] P5-9** LFG → LotusBot `!lfg`.
|
- **[DEFERRED] P5-51** Federated "Identity Contexts" (session isolation) — multi-sprint, touches auth/crypto/storage core; smaller intermediate step = plain multi-account switch. **[DROPPED] P5-52** per-room sync governor — js-sdk can't truly per-room filter `/sync`; only a cosmetic hide. **[DEFERRED] P5-53** local scripting plugin — prefer a declarative automation-rules feature (no arbitrary code). **[DEFERRED] Audit-3** profile banner — MSC4427 open/unmerged; revisit on merge. **[WON'T FIX] P5-50** Windows HW media pipeline (WebRTC decode lives in WebView2; not injectable). **[MOVED] P5-9** LFG → LotusBot `!lfg`.
|
||||||
@@ -231,7 +331,7 @@ Also open (from the quality review): a real `onError`/error-state fallback for i
|
|||||||
|
|
||||||
## 🚫 Blocked Features (server / upstream gated)
|
## 🚫 Blocked Features (server / upstream gated)
|
||||||
|
|
||||||
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade.
|
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
|
||||||
|
|
||||||
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
|
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
|
||||||
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
|
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
|
||||||
@@ -244,8 +344,9 @@ Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgra
|
|||||||
|
|
||||||
### Server Capabilities (as of 2026-07)
|
### Server Capabilities (as of 2026-07)
|
||||||
|
|
||||||
- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.156.0+trixie1` (upgraded 2026-07-07 from 1.155; apt package on Debian 13, LXC 151) · **Matrix spec** up to `v1.12` (Synapse still advertises v1.12; MSC features via `unstable_features`).
|
- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.157.1+trixie1` (upgraded 2026-07-23 from **1.156.0** — note the host was found on 1.156.0 while the docs claimed 1.155.0, so **always verify with `dpkg-query -W matrix-synapse-py3`**, don't trust the docs; apt package on Debian 13, LXC 151) · **Matrix spec** up to `v1.12` (Synapse still advertises v1.12; MSC features via `unstable_features`).
|
||||||
- **MSC ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (room summary live at unstable `im.nheko.summary/summary/{id}` — 200; the `/v1/rooms/{id}/summary` path is still 404) · `msc3401_matrix_rtc`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892`.
|
- **MSC ON** (re-dumped live from `/_matrix/client/versions` on 1.157.1): `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (room summary live at unstable `im.nheko.summary/summary/{id}` — 200; the `/v1/rooms/{id}/summary` path is still 404) · `msc3401_matrix_rtc` · `msc2285.stable` · `msc3827.stable` · `msc3981` · `msc4380.stable` · `msc4445` · `msc2659.stable` · `msc2666` · `msc2432` · `e2e_cross_signing` · `label_based_filtering`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892` · `msc4028` · `msc4069` · `msc4108` · `msc3391` · `msc4354` (sticky events — **deliberately off**, see the Matrix 2.0 section above) · `msc4143` (RTC foci — **not a gap**: LiveKit is discovered via `.well-known` `org.matrix.msc4143.rtc_foci`, confirmed live, not this flag).
|
||||||
|
- **Dead client code:** Synapse 1.157.0 **removed** `msc3861` (MAS auth delegation) entirely — the ~6 `msc3861`/`msc2965` references in `src/` can never activate against this homeserver (we auth via Authelia `oidc_providers`). Harmless, but cleanup material.
|
||||||
- **Live endpoints:** Report User (MSC4260) **200** ✅ · Report Room (MSC4151) ✅.
|
- **Live endpoints:** Report User (MSC4260) **200** ✅ · Report Room (MSC4151) ✅.
|
||||||
- **Homeserver access (audits):** Synapse = LXC 151 (`pct exec 151 -- bash`), config `/etc/matrix-synapse/homeserver.yaml`. Web deploy = LXC 106. Voice guard = `voice-limit-guard.py` on LXC 151.
|
- **Homeserver access (audits):** Synapse = LXC 151 (`pct exec 151 -- bash`), config `/etc/matrix-synapse/homeserver.yaml`. Web deploy = LXC 106. Voice guard = `voice-limit-guard.py` on LXC 151.
|
||||||
- **SDK notes:** no arbitrary profile-field methods (use `mx.http.authedRequest()` for MSC4133); js-sdk can't per-room filter `/sync`; sanitizer strips `<math>`/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`.
|
- **SDK notes:** no arbitrary profile-field methods (use `mx.http.authedRequest()` for MSC4133); js-sdk can't per-room filter `/sync`; sanitizer strips `<math>`/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`.
|
||||||
@@ -292,8 +393,20 @@ Also flag-gated: `lotusTransparent`/`lotusTheme`, `lotusDenoiseSource=1` (in-sou
|
|||||||
|
|
||||||
```
|
```
|
||||||
edit → commit → git push origin lotus
|
edit → commit → git push origin lotus
|
||||||
→ Gitea Actions: tsc --noEmit, eslint, prettier (~3 min)
|
→ Gitea Actions (.gitea/workflows/ci.yml): npm ci → build + npm test + tsc + eslint + prettier (ALL hard gates) → audit + bundle-size (informational)
|
||||||
→ lotus_deploy.sh on LXC 106 polls CI → npm ci && npm run build → rsync → live (~11 min)
|
→ lotus_deploy.sh on LXC 106 polls the "Build & Quality Checks" status → npm ci && npm run build → rsync → live (~11 min)
|
||||||
```
|
```
|
||||||
|
|
||||||
Before marking a feature complete: `npx tsc --noEmit` (0 errors) · `npx eslint src/` (0 new) · `npx prettier --check src/` · `npm test` (Node runner via tsx, hard CI gate — colocated `*.test.ts`) · update `README.md`/`landing/index.html` for Lotus-custom features · visually verify on `chat.lotusguild.org`.
|
Before marking a feature complete: `npx tsc --noEmit` (0 errors) · `npx eslint src/` (0 new) · `npx prettier --check src/` · `npm test` (Node runner via tsx, hard CI gate — colocated `*.test.ts`) · update `README.md`/`landing/index.html` for Lotus-custom features · visually verify on `chat.lotusguild.org`.
|
||||||
|
|
||||||
|
**CI hardening (2026-07, reviewed):**
|
||||||
|
|
||||||
|
- [x] **Concurrency** — `cancel-in-progress` on cinny `ci.yml` and cinny-desktop `release.yml` (`386a2979` / `c5461ce`): a superseded lotus push cancels its in-flight web CI and collapses queued ~30-min Tauri desktop builds to just the newest. Safe for deploys because `lotus_deploy.sh` now **follows origin/lotus HEAD** each poll iteration + resets to the gated SHA (`matrix` `c15a489`) — closes the latched-SHA freeze race.
|
||||||
|
- [x] **Hard quality gates** — typecheck/eslint/prettier promoted from `continue-on-error` to blocking (tree held clean). eslint gates on errors only; `no-explicit-any` warnings stay informational.
|
||||||
|
|
||||||
|
**CI follow-ups (open):**
|
||||||
|
|
||||||
|
- [ ] **Dedicated `desktop-linux` runner** (infra) — concurrency only collapses _burst_ stacking; a single in-flight `build-linux` (Tauri, `ubuntu-latest`) still shares the runner with web CI and can queue a web CI/deploy up to ~30 min. Fix = register a 2nd Linux act_runner labelled `desktop-linux` (root, network, RAM for a Tauri build; do NOT also label it `ubuntu-latest`) and point only `build-linux: runs-on` at it. Relabeling without a matching runner hangs the job forever.
|
||||||
|
- [ ] **Debounce the desktop trigger** — `trigger-desktop` fires a full desktop build on _every_ lotus commit; consider tag/`workflow_dispatch`/schedule-gating to decouple desktop cadence from web commits (biggest remaining runner-load source).
|
||||||
|
- [ ] **Verify Gitea ≥ 1.24** actually honors workflow `concurrency` (older silently ignores it → safe no-op, but the change is then inert — confirm on a test burst).
|
||||||
|
- [ ] **Deferred (chosen-not-now):** build-once/deploy-the-artifact (kill the CI-then-deploy double build); CI-gate the `lotus-build.sh` upstream-merge path (currently builds+deploys+then pushes, bypassing CI).
|
||||||
|
|||||||
@@ -13,7 +13,38 @@ The source code is licensed under [AGPLv3](LICENSE), the same license as the ups
|
|||||||
The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
|
The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## Development Environment Setup
|
||||||
|
#### Getting correct Node version
|
||||||
|
- Ensure you have the correct version of node installed, specified in `.node-version`
|
||||||
|
- Use this command from the terminal to install nvm
|
||||||
|
```bash
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
|
||||||
|
```
|
||||||
|
- Reload your terminal shell using (for Ubuntu):
|
||||||
|
```bash
|
||||||
|
source ~/.bashrc
|
||||||
|
```
|
||||||
|
- Install the specified Node version
|
||||||
|
```bash
|
||||||
|
NODE_VERSION="$(tr -d '[:space:]' < .node-version)"
|
||||||
|
nvm install "$NODE_VERSION"
|
||||||
|
nvm use "$NODE_VERSION"
|
||||||
|
```
|
||||||
|
- verify the correct version was installed by running
|
||||||
|
```bash
|
||||||
|
node --version
|
||||||
|
```
|
||||||
|
and comparing the output to what is listed in `.node-version`
|
||||||
|
#### Install npm packages
|
||||||
|
- To install the npm packages listed in `package.json` run:
|
||||||
|
```bash
|
||||||
|
npm i
|
||||||
|
```
|
||||||
|
### Start Development Server
|
||||||
|
```bash
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
You should now have an active development server at `localhost:8080`, where you can make changes to the code and see the UI update in real time
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Messaging
|
### Messaging
|
||||||
@@ -36,6 +67,7 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
|
|||||||
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
|
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
|
||||||
- Search messages with a date range filter
|
- Search messages with a date range filter
|
||||||
- Optional persistent search index for encrypted rooms (off by default — stores decrypted text on your device; clearable, wiped on logout)
|
- Optional persistent search index for encrypted rooms (off by default — stores decrypted text on your device; clearable, wiped on logout)
|
||||||
|
- On-device message translation — foreign-language messages show a "Translate" action, then an inline "Translated from <language> · Show original" toggle. Runs entirely on your device via the browser's built-in translation models, so message text never leaves your device or touches a cloud service (no Google/DeepL/Microsoft) — preserving end-to-end encryption. Pick your target language and optionally auto-translate incoming messages at Settings → General → Messages. Chromium desktop (Chrome/Edge 138+) and the Lotus desktop app only; hidden where unavailable (Firefox, Safari, mobile)
|
||||||
- Write math with LaTeX: `$inline$` and `$$block$$` render via KaTeX (spec `data-mx-maths` supported)
|
- Write math with LaTeX: `$inline$` and `$$block$$` render via KaTeX (spec `data-mx-maths` supported)
|
||||||
- Room topics support rich formatting (bold, links, italics)
|
- Room topics support rich formatting (bold, links, italics)
|
||||||
- Deleted messages show a placeholder instead of disappearing
|
- Deleted messages show a placeholder instead of disappearing
|
||||||
@@ -166,6 +198,23 @@ The source code lives in `/root/code/cinny`. All changes should be made on the `
|
|||||||
|
|
||||||
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
|
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
Lotus Chat is a **pure client — there is no backend of its own to run.** It talks directly to a Matrix homeserver (Synapse) over HTTPS, so the only thing you run locally is the Vite dev server; it connects to a real homeserver for all data. If you were looking for "the backend to pair with it," there isn't one — that's the homeserver.
|
||||||
|
|
||||||
|
**Prerequisites:** Node 20+ (CI builds on Node 24) and npm.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci # deps; @lotusguild/* come from our Gitea npm registry (public read — no auth/token needed)
|
||||||
|
npm start # Vite dev server → http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already in use it falls through to 8081+, so check the "Local:" URL Vite prints on startup. If it boots but the page renders blank, it's almost always a failed module/asset resolution, not a "missing backend" — open the devtools console and read the first error.
|
||||||
|
|
||||||
|
**Which homeserver / logging in:** `config.json` sets `defaultHomeserver: 0` → `matrix.lotusguild.org`, so you sign in with your normal `@you:matrix.lotusguild.org` account. That homeserver is **live production** — anything you send is real, so keep test traffic to a DM with yourself or a throwaway room. To develop fully isolated instead, point `config.json` at a throwaway `matrix.org` account (already in `homeserverList`) or a local Synapse.
|
||||||
|
|
||||||
|
- **SSO / OIDC works from localhost.** Login goes through Authelia via OIDC dynamic registration; the provider redirects back to `http://localhost:8080/…` and the client registers that redirect on the fly, so no server-side allow-listing is needed. After the callback you may see a `GET …/_matrix/media/v1/thumbnail/… 404` — that's just a missing avatar thumbnail, **not** a login failure.
|
||||||
|
|
||||||
### 🔱 Element Call fork ("Lotus Call") — LIVE
|
### 🔱 Element Call fork ("Lotus Call") — LIVE
|
||||||
|
|
||||||
Voice/video channels embed **Element Call**, which is now our **self-built fork**
|
Voice/video channels embed **Element Call**, which is now our **self-built fork**
|
||||||
|
|||||||
+42
-40
@@ -19,10 +19,11 @@
|
|||||||
*
|
*
|
||||||
* Any failure falls back to the unprocessed mic so calls never break.
|
* Any failure falls back to the unprocessed mic so calls never break.
|
||||||
*/
|
*/
|
||||||
|
// TODO: MAKE THIS A TS FILE
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var params;
|
let params;
|
||||||
try {
|
try {
|
||||||
params = new URLSearchParams(window.location.search);
|
params = new URLSearchParams(window.location.search);
|
||||||
if (params.get('lotusDenoise') !== 'ml') return;
|
if (params.get('lotusDenoise') !== 'ml') return;
|
||||||
@@ -33,31 +34,31 @@
|
|||||||
// Derive the parent origin for postMessage targetOrigin from the parentUrl
|
// Derive the parent origin for postMessage targetOrigin from the parentUrl
|
||||||
// widget param (a full URL) so denoise-status messages aren't broadcast with
|
// widget param (a full URL) so denoise-status messages aren't broadcast with
|
||||||
// '*'. Fall back to this frame's own origin if parentUrl is missing/malformed.
|
// '*'. Fall back to this frame's own origin if parentUrl is missing/malformed.
|
||||||
var targetOrigin;
|
let targetOrigin;
|
||||||
try {
|
try {
|
||||||
var parentUrl = params.get('parentUrl');
|
let parentUrl = params.get('parentUrl');
|
||||||
targetOrigin = parentUrl ? new URL(parentUrl).origin : window.location.origin;
|
targetOrigin = parentUrl ? new URL(parentUrl).origin : window.location.origin;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
targetOrigin = window.location.origin;
|
targetOrigin = window.location.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
var md = navigator.mediaDevices;
|
let md = navigator.mediaDevices;
|
||||||
if (!md || typeof md.getUserMedia !== 'function') return;
|
if (!md || typeof md.getUserMedia !== 'function') return;
|
||||||
if (typeof AudioWorkletNode === 'undefined' || typeof AudioContext === 'undefined') return;
|
if (typeof AudioWorkletNode === 'undefined' || typeof AudioContext === 'undefined') return;
|
||||||
|
|
||||||
var ASSET_BASE = './denoise/';
|
let ASSET_BASE = './denoise/';
|
||||||
|
|
||||||
var MODEL = params.get('lotusModel') || 'rnnoise';
|
let MODEL = params.get('lotusModel') || 'rnnoise';
|
||||||
// DTLN (@workadventure) targets 16 kHz and does not resample internally, so
|
// DTLN (@workadventure) targets 16 kHz and does not resample internally, so
|
||||||
// its whole graph runs in a 16 kHz context; RNNoise/Speex (sapphi) and
|
// its whole graph runs in a 16 kHz context; RNNoise/Speex (sapphi) and
|
||||||
// DeepFilterNet 3 are 48 kHz fullband. The processed MediaStreamTrack is
|
// DeepFilterNet 3 are 48 kHz fullband. The processed MediaStreamTrack is
|
||||||
// published to LiveKit either way (WebRTC/Opus resamples as needed).
|
// published to LiveKit either way (WebRTC/Opus resamples as needed).
|
||||||
var SAMPLE_RATE = MODEL === 'dtln' ? 16000 : 48000;
|
let SAMPLE_RATE = MODEL === 'dtln' ? 16000 : 48000;
|
||||||
var USE_NATIVE_NS = params.get('lotusNativeNS') === 'true';
|
let USE_NATIVE_NS = params.get('lotusNativeNS') === 'true';
|
||||||
var USE_GATE = params.get('lotusGate') === 'true';
|
let USE_GATE = params.get('lotusGate') === 'true';
|
||||||
var GATE_THRESHOLD = parseFloat(params.get('lotusGateThreshold') || '-45');
|
let GATE_THRESHOLD = parseFloat(params.get('lotusGateThreshold') || '-45');
|
||||||
|
|
||||||
var PROCESSORS = {
|
let PROCESSORS = {
|
||||||
rnnoise: {
|
rnnoise: {
|
||||||
name: '@sapphi-red/web-noise-suppressor/rnnoise',
|
name: '@sapphi-red/web-noise-suppressor/rnnoise',
|
||||||
script: 'rnnoiseWorklet.js',
|
script: 'rnnoiseWorklet.js',
|
||||||
@@ -91,9 +92,9 @@
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
var origGetUserMedia = md.getUserMedia.bind(md);
|
let origGetUserMedia = md.getUserMedia.bind(md);
|
||||||
var wasmPromises = {};
|
let wasmPromises = {};
|
||||||
var ctxPromise = null;
|
let ctxPromise = null;
|
||||||
|
|
||||||
function checkSimd() {
|
function checkSimd() {
|
||||||
try {
|
try {
|
||||||
@@ -112,12 +113,12 @@
|
|||||||
|
|
||||||
function loadWasm(modelId) {
|
function loadWasm(modelId) {
|
||||||
if (wasmPromises[modelId]) return wasmPromises[modelId];
|
if (wasmPromises[modelId]) return wasmPromises[modelId];
|
||||||
var p = PROCESSORS[modelId];
|
let p = PROCESSORS[modelId];
|
||||||
if (!p || !p.wasm) return Promise.resolve(null);
|
if (!p || !p.wasm) return Promise.resolve(null);
|
||||||
|
|
||||||
wasmPromises[modelId] = (modelId === 'rnnoise' ? checkSimd() : Promise.resolve(false)).then(
|
wasmPromises[modelId] = (modelId === 'rnnoise' ? checkSimd() : Promise.resolve(false)).then(
|
||||||
function (simd) {
|
function (simd) {
|
||||||
var file = simd && p.simdWasm ? p.simdWasm : p.wasm;
|
let file = simd && p.simdWasm ? p.simdWasm : p.wasm;
|
||||||
return fetch(ASSET_BASE + file).then(function (r) {
|
return fetch(ASSET_BASE + file).then(function (r) {
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
if (simd && p.simdWasm)
|
if (simd && p.simdWasm)
|
||||||
@@ -137,7 +138,7 @@
|
|||||||
function getContext() {
|
function getContext() {
|
||||||
if (!ctxPromise) {
|
if (!ctxPromise) {
|
||||||
ctxPromise = (function () {
|
ctxPromise = (function () {
|
||||||
var ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
let ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||||
if (ctx.sampleRate !== SAMPLE_RATE) {
|
if (ctx.sampleRate !== SAMPLE_RATE) {
|
||||||
try {
|
try {
|
||||||
ctx.close();
|
ctx.close();
|
||||||
@@ -146,7 +147,7 @@
|
|||||||
}
|
}
|
||||||
// Load worklet modules. DTLN registers its own processor via the
|
// Load worklet modules. DTLN registers its own processor via the
|
||||||
// dynamic-imported helper (see buildMlNode), so it needs nothing here.
|
// dynamic-imported helper (see buildMlNode), so it needs nothing here.
|
||||||
var scripts = [];
|
let scripts = [];
|
||||||
if (MODEL === 'rnnoise' || MODEL === 'speex') scripts.push(PROCESSORS[MODEL].script);
|
if (MODEL === 'rnnoise' || MODEL === 'speex') scripts.push(PROCESSORS[MODEL].script);
|
||||||
if (USE_GATE) scripts.push(PROCESSORS.gate.script);
|
if (USE_GATE) scripts.push(PROCESSORS.gate.script);
|
||||||
|
|
||||||
@@ -169,7 +170,7 @@
|
|||||||
return ctxPromise;
|
return ctxPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasNotifiedActive = false;
|
let hasNotifiedActive = false;
|
||||||
|
|
||||||
// Build the ML denoise AudioWorkletNode. RNNoise/Speex are flat sapphi
|
// Build the ML denoise AudioWorkletNode. RNNoise/Speex are flat sapphi
|
||||||
// worklets we instantiate directly with the fetched WASM binary. DTLN comes
|
// worklets we instantiate directly with the fetched WASM binary. DTLN comes
|
||||||
@@ -187,9 +188,9 @@
|
|||||||
if (MODEL === 'deepfilternet') {
|
if (MODEL === 'deepfilternet') {
|
||||||
// Resolve an absolute self-hosted base so the package's cdnUrl override
|
// Resolve an absolute self-hosted base so the package's cdnUrl override
|
||||||
// fetches our vendored df_bg.wasm + ONNX model (never the upstream CDN).
|
// fetches our vendored df_bg.wasm + ONNX model (never the upstream CDN).
|
||||||
var dfnBase = new URL(ASSET_BASE + 'deepfilternet', window.location.href).href;
|
let dfnBase = new URL(ASSET_BASE + 'deepfilternet', window.location.href).href;
|
||||||
return import(ASSET_BASE + PROCESSORS.deepfilternet.esm).then(function (mod) {
|
return import(ASSET_BASE + PROCESSORS.deepfilternet.esm).then(function (mod) {
|
||||||
var core = new mod.DeepFilterNet3Core({
|
let core = new mod.DeepFilterNet3Core({
|
||||||
sampleRate: SAMPLE_RATE,
|
sampleRate: SAMPLE_RATE,
|
||||||
noiseReductionLevel: 80,
|
noiseReductionLevel: 80,
|
||||||
assetConfig: { cdnUrl: dfnBase },
|
assetConfig: { cdnUrl: dfnBase },
|
||||||
@@ -212,7 +213,8 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
var node = new AudioWorkletNode(ctx, PROCESSORS[MODEL].name, {
|
|
||||||
|
let node = new AudioWorkletNode(ctx, PROCESSORS[MODEL].name, {
|
||||||
channelCount: 1,
|
channelCount: 1,
|
||||||
numberOfInputs: 1,
|
numberOfInputs: 1,
|
||||||
numberOfOutputs: 1,
|
numberOfOutputs: 1,
|
||||||
@@ -230,21 +232,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function processStream(stream) {
|
function processStream(stream) {
|
||||||
var audioTracks = stream.getAudioTracks();
|
let audioTracks = stream.getAudioTracks();
|
||||||
if (audioTracks.length === 0) return Promise.resolve(stream);
|
if (audioTracks.length === 0) return Promise.resolve(stream);
|
||||||
|
|
||||||
return Promise.all([loadWasm(MODEL), getContext()])
|
return Promise.all([loadWasm(MODEL), getContext()])
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
var wasmBinary = res[0];
|
let wasmBinary = res[0];
|
||||||
var ctx = res[1];
|
let ctx = res[1];
|
||||||
|
|
||||||
var source = ctx.createMediaStreamSource(stream);
|
let source = ctx.createMediaStreamSource(stream);
|
||||||
var dest = ctx.createMediaStreamDestination();
|
let dest = ctx.createMediaStreamDestination();
|
||||||
var head = source;
|
let head = source;
|
||||||
|
|
||||||
// 1. Optional Noise Gate
|
// 1. Optional Noise Gate
|
||||||
if (USE_GATE) {
|
if (USE_GATE) {
|
||||||
var gateNode = new AudioWorkletNode(ctx, PROCESSORS.gate.name, {
|
let gateNode = new AudioWorkletNode(ctx, PROCESSORS.gate.name, {
|
||||||
processorOptions: {
|
processorOptions: {
|
||||||
openThreshold: GATE_THRESHOLD,
|
openThreshold: GATE_THRESHOLD,
|
||||||
closeThreshold: GATE_THRESHOLD - 5,
|
closeThreshold: GATE_THRESHOLD - 5,
|
||||||
@@ -258,7 +260,7 @@
|
|||||||
|
|
||||||
// 2. ML Processor
|
// 2. ML Processor
|
||||||
return buildMlNode(ctx, wasmBinary).then(function (ml) {
|
return buildMlNode(ctx, wasmBinary).then(function (ml) {
|
||||||
var mlNode = ml.node;
|
let mlNode = ml.node;
|
||||||
head.connect(mlNode);
|
head.connect(mlNode);
|
||||||
mlNode.connect(dest);
|
mlNode.connect(dest);
|
||||||
|
|
||||||
@@ -266,15 +268,15 @@
|
|||||||
// the track handoff — audio flows via bypassUntilReady meanwhile.
|
// the track handoff — audio flows via bypassUntilReady meanwhile.
|
||||||
if (ml.ready && typeof ml.ready.then === 'function') {
|
if (ml.ready && typeof ml.ready.then === 'function') {
|
||||||
ml.ready.catch(function (err) {
|
ml.ready.catch(function (err) {
|
||||||
var m = err instanceof Error ? err.message : String(err);
|
let m = err instanceof Error ? err.message : String(err);
|
||||||
console.error('[lotus-denoise] ' + MODEL + ' init failed:', m);
|
console.error('[lotus-denoise] ' + MODEL + ' init failed:', m);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var origTrack = audioTracks[0];
|
let origTrack = audioTracks[0];
|
||||||
var processedTrack = dest.stream.getAudioTracks()[0];
|
let processedTrack = dest.stream.getAudioTracks()[0];
|
||||||
|
|
||||||
var torndown = false;
|
let torndown = false;
|
||||||
function cleanup() {
|
function cleanup() {
|
||||||
if (torndown) return;
|
if (torndown) return;
|
||||||
torndown = true;
|
torndown = true;
|
||||||
@@ -293,7 +295,7 @@
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
var rawStop = processedTrack.stop.bind(processedTrack);
|
let rawStop = processedTrack.stop.bind(processedTrack);
|
||||||
processedTrack.stop = function () {
|
processedTrack.stop = function () {
|
||||||
cleanup();
|
cleanup();
|
||||||
rawStop();
|
rawStop();
|
||||||
@@ -319,7 +321,7 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
var out = new MediaStream();
|
let out = new MediaStream();
|
||||||
out.addTrack(processedTrack);
|
out.addTrack(processedTrack);
|
||||||
stream.getVideoTracks().forEach(function (t) {
|
stream.getVideoTracks().forEach(function (t) {
|
||||||
out.addTrack(t);
|
out.addTrack(t);
|
||||||
@@ -328,7 +330,7 @@
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(function (e) {
|
.catch(function (e) {
|
||||||
var msg = e instanceof Error ? e.message : String(e);
|
let msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error('[lotus-denoise] Setup failed:', msg);
|
console.error('[lotus-denoise] Setup failed:', msg);
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
{ type: 'lotus-denoise-status', active: false, error: msg },
|
{ type: 'lotus-denoise-status', active: false, error: msg },
|
||||||
@@ -339,10 +341,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
navigator.mediaDevices.getUserMedia = function (constraints) {
|
navigator.mediaDevices.getUserMedia = function (constraints) {
|
||||||
var wantsAudio = !!(constraints && constraints.audio);
|
let wantsAudio = !!(constraints && constraints.audio);
|
||||||
var effective = constraints;
|
let effective = constraints;
|
||||||
if (wantsAudio) {
|
if (wantsAudio) {
|
||||||
var audioC =
|
let audioC =
|
||||||
typeof constraints.audio === 'object' ? Object.assign({}, constraints.audio) : {};
|
typeof constraints.audio === 'object' ? Object.assign({}, constraints.audio) : {};
|
||||||
audioC.noiseSuppression = USE_NATIVE_NS;
|
audioC.noiseSuppression = USE_NATIVE_NS;
|
||||||
audioC.channelCount = 1;
|
audioC.channelCount = 1;
|
||||||
|
|||||||
+1
-1
@@ -109,7 +109,7 @@ export default [
|
|||||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||||
],
|
],
|
||||||
'@typescript-eslint/no-shadow': 'error',
|
'@typescript-eslint/no-shadow': 'error',
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
|
||||||
// jsx-a11y — media captions not required for this app
|
// jsx-a11y — media captions not required for this app
|
||||||
'jsx-a11y/media-has-caption': 'off',
|
'jsx-a11y/media-has-caption': 'off',
|
||||||
|
|||||||
Generated
+1852
-4743
File diff suppressed because it is too large
Load Diff
+14
-22
@@ -12,13 +12,12 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "npm run check:eslint && npm run check:prettier",
|
"lint": "npm run check:eslint && npm run check:prettier",
|
||||||
"check:eslint": "eslint src/*",
|
"check:eslint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
|
||||||
"check:prettier": "prettier --check .",
|
"check:prettier": "prettier --check .",
|
||||||
"fix:prettier": "prettier --write .",
|
"fix:prettier": "prettier --write .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --import tsx --test $(find src -name '*.test.ts')",
|
"test": "node --import tsx --test $(find src -name '*.test.ts')",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"commit": "git-cz",
|
|
||||||
"postinstall": "node scripts/patch-folds.mjs",
|
"postinstall": "node scripts/patch-folds.mjs",
|
||||||
"sync:decorations": "node scripts/syncDecorations.mjs"
|
"sync:decorations": "node scripts/syncDecorations.mjs"
|
||||||
},
|
},
|
||||||
@@ -26,11 +25,6 @@
|
|||||||
"*.{ts,tsx,js,jsx}": "eslint",
|
"*.{ts,tsx,js,jsx}": "eslint",
|
||||||
"*": "prettier --ignore-unknown --write"
|
"*": "prettier --ignore-unknown --write"
|
||||||
},
|
},
|
||||||
"config": {
|
|
||||||
"commitizen": {
|
|
||||||
"path": "./node_modules/cz-conventional-changelog"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Ajay Bura",
|
"author": "Ajay Bura",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
@@ -41,24 +35,22 @@
|
|||||||
"@eslint/eslintrc": "3.3.5",
|
"@eslint/eslintrc": "3.3.5",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@fontsource-variable/inter": "5.2.8",
|
"@fontsource-variable/inter": "5.2.8",
|
||||||
"@giphy/js-fetch-api": "5.8.0",
|
|
||||||
"@giphy/js-types": "5.1.0",
|
"@giphy/js-types": "5.1.0",
|
||||||
"@giphy/js-util": "5.2.0",
|
"@giphy/js-util": "2.0.0",
|
||||||
"@giphy/react-components": "10.1.2",
|
"@giphy/react-components": "10.1.2",
|
||||||
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
||||||
"@tanstack/react-query": "5.100.13",
|
"@tanstack/react-query": "5.100.13",
|
||||||
"@tanstack/react-query-devtools": "5.100.13",
|
"@tanstack/react-query-devtools": "5.100.13",
|
||||||
"@tanstack/react-virtual": "3.13.25",
|
"@tanstack/react-virtual": "3.13.25",
|
||||||
"@workadventure/noise-suppression": "0.0.4",
|
"@workadventure/noise-suppression": "0.1.1",
|
||||||
"await-to-js": "3.0.0",
|
"await-to-js": "3.0.0",
|
||||||
"badwords-list": "2.0.1-4",
|
"badwords-list": "2.0.1-4",
|
||||||
"blurhash": "2.0.5",
|
"blurhash": "2.0.5",
|
||||||
"browser-encrypt-attachment": "0.3.0",
|
"browser-encrypt-attachment": "0.3.0",
|
||||||
"chroma-js": "3.2.0",
|
"chroma-js": "3.2.0",
|
||||||
"classnames": "2.5.1",
|
"classnames": "2.5.1",
|
||||||
"dateformat": "5.0.3",
|
|
||||||
"dayjs": "1.11.20",
|
"dayjs": "1.11.20",
|
||||||
"deepfilternet3-noise-filter": "1.2.1",
|
"deepfilternet3-noise-filter": "1.3.0",
|
||||||
"domhandler": "6.0.1",
|
"domhandler": "6.0.1",
|
||||||
"emojibase": "17.0.0",
|
"emojibase": "17.0.0",
|
||||||
"emojibase-data": "17.0.0",
|
"emojibase-data": "17.0.0",
|
||||||
@@ -75,12 +67,13 @@
|
|||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
"jsqr": "1.4.0",
|
"jsqr": "1.4.0",
|
||||||
"katex": "0.16.11",
|
"katex": "0.16.47",
|
||||||
"linkify-react": "4.3.3",
|
"linkify-react": "4.3.3",
|
||||||
"linkifyjs": "4.3.3",
|
"linkifyjs": "4.3.3",
|
||||||
"matrix-js-sdk": "41.7.0",
|
"matrix-js-sdk": "41.7.0",
|
||||||
"matrix-widget-api": "1.17.0",
|
"matrix-widget-api": "1.17.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
|
"oidc-client-ts": "3.5.0",
|
||||||
"pdfjs-dist": "5.7.284",
|
"pdfjs-dist": "5.7.284",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
"qrcode": "1.5.4",
|
"qrcode": "1.5.4",
|
||||||
@@ -94,8 +87,8 @@
|
|||||||
"react-google-recaptcha": "3.1.0",
|
"react-google-recaptcha": "3.1.0",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-range": "1.10.0",
|
"react-range": "1.10.0",
|
||||||
"react-router-dom": "7.15.1",
|
"react-router": "8.3.0",
|
||||||
"sanitize-html": "2.17.4",
|
"sanitize-html": "2.17.6",
|
||||||
"slate": "0.124.1",
|
"slate": "0.124.1",
|
||||||
"slate-dom": "0.124.1",
|
"slate-dom": "0.124.1",
|
||||||
"slate-history": "0.113.1",
|
"slate-history": "0.113.1",
|
||||||
@@ -111,7 +104,6 @@
|
|||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
"@types/file-saver": "2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/is-hotkey": "0.1.10",
|
"@types/is-hotkey": "0.1.10",
|
||||||
"@types/katex": "0.16.8",
|
|
||||||
"@types/node": "25.9.1",
|
"@types/node": "25.9.1",
|
||||||
"@types/prismjs": "1.26.6",
|
"@types/prismjs": "1.26.6",
|
||||||
"@types/qrcode": "1.5.6",
|
"@types/qrcode": "1.5.6",
|
||||||
@@ -119,28 +111,24 @@
|
|||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/react-google-recaptcha": "2.1.9",
|
"@types/react-google-recaptcha": "2.1.9",
|
||||||
"@types/sanitize-html": "2.16.1",
|
"@types/sanitize-html": "2.16.1",
|
||||||
"@types/ua-parser-js": "0.7.39",
|
|
||||||
"@typescript-eslint/eslint-plugin": "8.59.4",
|
"@typescript-eslint/eslint-plugin": "8.59.4",
|
||||||
"@typescript-eslint/parser": "8.59.4",
|
"@typescript-eslint/parser": "8.59.4",
|
||||||
"@vanilla-extract/css": "1.20.1",
|
"@vanilla-extract/css": "1.20.1",
|
||||||
"@vanilla-extract/recipes": "0.5.7",
|
"@vanilla-extract/recipes": "0.5.7",
|
||||||
"@vanilla-extract/vite-plugin": "5.2.2",
|
"@vanilla-extract/vite-plugin": "5.2.2",
|
||||||
"@vitejs/plugin-react": "6.0.2",
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
"buffer": "6.0.3",
|
|
||||||
"cz-conventional-changelog": "3.3.0",
|
|
||||||
"eslint": "9.39.4",
|
"eslint": "9.39.4",
|
||||||
"eslint-config-airbnb": "19.0.4",
|
"eslint-config-airbnb": "19.0.4",
|
||||||
|
"eslint-config-airbnb-base": "15.0.0",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-import": "2.32.0",
|
|
||||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"eslint-plugin-react-hooks": "7.1.1",
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.3",
|
||||||
"tsx": "4.22.4",
|
"tsx": "4.22.4",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.0.14",
|
"vite": "8.2.0",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
"vite-plugin-static-copy": "4.1.0"
|
"vite-plugin-static-copy": "4.1.0"
|
||||||
},
|
},
|
||||||
@@ -149,5 +137,9 @@
|
|||||||
"dompurify": ">=3.3.4"
|
"dompurify": ">=3.3.4"
|
||||||
},
|
},
|
||||||
"js-cookie": ">=3.0.6"
|
"js-cookie": ">=3.0.6"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"protobufjs": true,
|
||||||
|
"esbuild": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ReactNode, useCallback } from 'react';
|
import { ReactNode, useCallback } from 'react';
|
||||||
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
|
import { matchPath, useLocation, useNavigate } from 'react-router';
|
||||||
import {
|
import {
|
||||||
getDirectPath,
|
getDirectPath,
|
||||||
getExplorePath,
|
getExplorePath,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import { CallEmbed, useCallControlState } from '../plugins/call';
|
|||||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
import { previewRingtone, startRingtone } from '../utils/ringtones';
|
import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones';
|
||||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||||
import { useCallQuality } from '../hooks/useCallQuality';
|
import { useCallQuality } from '../hooks/useCallQuality';
|
||||||
@@ -473,8 +473,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
|
|
||||||
const sender = event.getSender();
|
const sender = event.getSender();
|
||||||
const content = event.getContent<IRTCNotificationContent>();
|
const content = event.getContent<IRTCNotificationContent>();
|
||||||
|
// Trust the caller's sender_ts only when it's within 20s of the server's
|
||||||
|
// timestamp in EITHER direction. A fast caller clock made a fresh invite
|
||||||
|
// look expired-in-the-future; a SLOW one left sender_ts in the past so
|
||||||
|
// `Date.now() >= senderTs + lifetime` hid/dismissed the ring for a
|
||||||
|
// genuinely fresh invite (COR-3). Fall back to the server ts on large skew.
|
||||||
const senderTs =
|
const senderTs =
|
||||||
content.sender_ts - event.getTs() > 20000 ? event.getTs() : content.sender_ts;
|
typeof content.sender_ts === 'number' &&
|
||||||
|
Math.abs(content.sender_ts - event.getTs()) <= 20000
|
||||||
|
? content.sender_ts
|
||||||
|
: event.getTs();
|
||||||
const lifetime = Math.min(content.lifetime, 120000);
|
const lifetime = Math.min(content.lifetime, 120000);
|
||||||
const notificationType = content.notification_type;
|
const notificationType = content.notification_type;
|
||||||
const relation =
|
const relation =
|
||||||
@@ -695,6 +703,23 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
|
|
||||||
const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control);
|
const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control);
|
||||||
|
|
||||||
|
// C-L3 — prime the ringtone AudioContext on the first user gesture of the
|
||||||
|
// session so the first incoming-call ring isn't silent (a fresh context stays
|
||||||
|
// suspended until a gesture, and an incoming ring has none of its own).
|
||||||
|
useEffect(() => {
|
||||||
|
const prime = () => {
|
||||||
|
unlockRingtoneAudio();
|
||||||
|
window.removeEventListener('pointerdown', prime);
|
||||||
|
window.removeEventListener('keydown', prime);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointerdown', prime, { once: true, passive: true });
|
||||||
|
window.addEventListener('keydown', prime, { once: true, passive: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointerdown', prime);
|
||||||
|
window.removeEventListener('keydown', prime);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Sync pip mode into CallControl so it can adjust behavior accordingly
|
// Sync pip mode into CallControl so it can adjust behavior accordingly
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!callEmbed) return;
|
if (!callEmbed) return;
|
||||||
@@ -706,8 +731,24 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
// When screenshare ends, release the spotlight we auto-enabled.
|
// When screenshare ends, release the spotlight we auto-enabled.
|
||||||
const pipAutoSpotlightRef = React.useRef(false);
|
const pipAutoSpotlightRef = React.useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pipMode || !callEmbed) return;
|
if (!callEmbed) {
|
||||||
if (pipScreenshare) {
|
// The embed (and its spotlight) is torn down with the call; drop the latch
|
||||||
|
// so a stale ref can't act on the next call's fresh embed.
|
||||||
|
pipAutoSpotlightRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Spotlight is wanted only while in pip with an active screenshare. Release
|
||||||
|
// it when EITHER ends — including leaving pip (returning to the call room).
|
||||||
|
// The release must not sit behind a `!pipMode` early-return, or a
|
||||||
|
// screenshare→pip→back sequence leaves the auto-enabled spotlight stuck on
|
||||||
|
// with pipAutoSpotlightRef latched true. The ref gates release so we only
|
||||||
|
// ever undo a spotlight we turned on (never one the user set).
|
||||||
|
// NB: `control.spotlight` is read below but deliberately NOT a dependency —
|
||||||
|
// this effect reacts to pip/screenshare *intent*, not to spotlight changes.
|
||||||
|
// Adding it as a dep would re-run on every manual spotlight toggle and fight
|
||||||
|
// the user.
|
||||||
|
const wantSpotlight = pipMode && pipScreenshare;
|
||||||
|
if (wantSpotlight) {
|
||||||
if (!callEmbed.control.spotlight) {
|
if (!callEmbed.control.spotlight) {
|
||||||
callEmbed.control.toggleSpotlight();
|
callEmbed.control.toggleSpotlight();
|
||||||
pipAutoSpotlightRef.current = true;
|
pipAutoSpotlightRef.current = true;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useRef, useState } from 'react';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
||||||
import { IGif } from '@giphy/js-types';
|
import { IGif } from '@giphy/js-types';
|
||||||
import { Box, color, config } from 'folds';
|
import { Box, color, config } from 'folds';
|
||||||
|
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { addRecentGif, RecentGif, recentGifsAtom } from '../state/recentGifs';
|
import { addRecentGif, RecentGif, recentGifsAtom } from '../state/recentGifs';
|
||||||
@@ -146,8 +147,18 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
|||||||
|
|
||||||
const showRecents = recents.length > 0 && !(term ?? '').trim();
|
const showRecents = recents.length > 0 && !(term ?? '').trim();
|
||||||
|
|
||||||
|
// The container is min(312px, 100vw-16); feed the Grid the live pixel width
|
||||||
|
// (minus the inner 8px padding on each side) so it doesn't overflow a phone
|
||||||
|
// narrower than 312px with a fixed 296px grid.
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16);
|
||||||
|
useElementSizeObserver(
|
||||||
|
useCallback(() => containerRef.current, []),
|
||||||
|
useCallback((w) => setGridWidth(Math.max(1, Math.floor(w) - 16)), []),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }}>
|
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }} ref={containerRef}>
|
||||||
{lotusTerminal && (
|
{lotusTerminal && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -178,7 +189,7 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
|||||||
<Grid
|
<Grid
|
||||||
key={searchKey}
|
key={searchKey}
|
||||||
fetchGifs={fetchGifs}
|
fetchGifs={fetchGifs}
|
||||||
width={PICKER_WIDTH - 16}
|
width={gridWidth}
|
||||||
columns={2}
|
columns={2}
|
||||||
gutter={4}
|
gutter={4}
|
||||||
onGifClick={handleClick}
|
onGifClick={handleClick}
|
||||||
|
|||||||
@@ -88,8 +88,9 @@ export function RenderMessageContent({
|
|||||||
}: RenderMessageContentProps) {
|
}: RenderMessageContentProps) {
|
||||||
const renderUrlsPreview = (urls: string[]) => {
|
const renderUrlsPreview = (urls: string[]) => {
|
||||||
// Cap previews per message so a link-dump doesn't spawn dozens of preview
|
// Cap previews per message so a link-dump doesn't spawn dozens of preview
|
||||||
// fetches + iframes at once.
|
// fetches + iframes at once. De-dupe first: a message linking the same URL
|
||||||
const filteredUrls = urls.filter((url) => !testMatrixTo(url)).slice(0, 6);
|
// twice would otherwise render sibling cards with identical React keys.
|
||||||
|
const filteredUrls = [...new Set(urls.filter((url) => !testMatrixTo(url)))].slice(0, 6);
|
||||||
if (filteredUrls.length === 0) return undefined;
|
if (filteredUrls.length === 0) return undefined;
|
||||||
return (
|
return (
|
||||||
<UrlPreviewHolder>
|
<UrlPreviewHolder>
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Menu, PopOut, toRem } from 'folds';
|
import {
|
||||||
|
Box,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Menu,
|
||||||
|
Modal,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
PopOut,
|
||||||
|
config,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile';
|
import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile';
|
||||||
import { UserRoomProfile } from './user-profile';
|
import { UserRoomProfile } from './user-profile';
|
||||||
@@ -8,6 +22,20 @@ import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
|
|||||||
import { stopPropagation } from '../utils/keyboard';
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
import { SpaceProvider } from '../hooks/useSpace';
|
import { SpaceProvider } from '../hooks/useSpace';
|
||||||
import { RoomProvider } from '../hooks/useRoom';
|
import { RoomProvider } from '../hooks/useRoom';
|
||||||
|
import { ScreenSize, useScreenSize } from '../hooks/useScreenSize';
|
||||||
|
|
||||||
|
// Matches useModalStyle's mobile branch: fill the phone screen with internal
|
||||||
|
// scroll so tall profiles (moderation actions, device list, notes) are fully
|
||||||
|
// reachable — the anchored 340px popout below can't scroll and clipped them.
|
||||||
|
const MOBILE_FULLSCREEN = {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
borderRadius: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
} as const;
|
||||||
|
|
||||||
function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) {
|
function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) {
|
||||||
const { roomId, spaceId, userId, cords, position } = state;
|
const { roomId, spaceId, userId, cords, position } = state;
|
||||||
@@ -15,32 +43,61 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState })
|
|||||||
const getRoom = useGetRoom(allJoinedRooms);
|
const getRoom = useGetRoom(allJoinedRooms);
|
||||||
const room = getRoom(roomId);
|
const room = getRoom(roomId);
|
||||||
const space = spaceId ? getRoom(spaceId) : undefined;
|
const space = spaceId ? getRoom(spaceId) : undefined;
|
||||||
|
const screenSize = useScreenSize();
|
||||||
|
|
||||||
const close = useCloseUserRoomProfile();
|
const close = useCloseUserRoomProfile();
|
||||||
|
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
|
const profile = (
|
||||||
|
<SpaceProvider value={space ?? null}>
|
||||||
|
<RoomProvider value={room}>
|
||||||
|
<UserRoomProfile userId={userId} />
|
||||||
|
</RoomProvider>
|
||||||
|
</SpaceProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const focusTrapOptions = {
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
};
|
||||||
|
|
||||||
|
// On phones, render as a full-screen scrollable modal instead of an anchored,
|
||||||
|
// fixed-width, unscrollable popout.
|
||||||
|
if (screenSize === ScreenSize.Mobile) {
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap focusTrapOptions={focusTrapOptions}>
|
||||||
|
<Modal size="500" style={MOBILE_FULLSCREEN}>
|
||||||
|
{/* Full-screen covers the backdrop (no tap-to-dismiss) and the
|
||||||
|
profile has no self-close, so provide an explicit close. */}
|
||||||
|
<Header size="600" style={{ flexShrink: 0, paddingRight: config.space.S200 }}>
|
||||||
|
<Box grow="Yes" />
|
||||||
|
<IconButton size="300" radii="300" onClick={close} aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box grow="Yes" style={{ overflow: 'hidden auto' }}>
|
||||||
|
{profile}
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopOut
|
<PopOut
|
||||||
anchor={cords}
|
anchor={cords}
|
||||||
position={position ?? 'Top'}
|
position={position ?? 'Top'}
|
||||||
align="Start"
|
align="Start"
|
||||||
content={
|
content={
|
||||||
<FocusTrap
|
<FocusTrap focusTrapOptions={focusTrapOptions}>
|
||||||
focusTrapOptions={{
|
<Menu style={{ width: toRem(340) }}>{profile}</Menu>
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: close,
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Menu style={{ width: toRem(340) }}>
|
|
||||||
<SpaceProvider value={space ?? null}>
|
|
||||||
<RoomProvider value={room}>
|
|
||||||
<UserRoomProfile userId={userId} />
|
|
||||||
</RoomProvider>
|
|
||||||
</SpaceProvider>
|
|
||||||
</Menu>
|
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import { Box, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
import { Box, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { MobileTouchTarget } from '../styles/mobile.css';
|
||||||
|
|
||||||
type RecorderState = 'idle' | 'recording' | 'paused' | 'preview';
|
type RecorderState = 'idle' | 'recording' | 'paused' | 'preview';
|
||||||
|
|
||||||
@@ -239,6 +240,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
if (state === 'idle') {
|
if (state === 'idle') {
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={startRecording}
|
onClick={startRecording}
|
||||||
aria-label="Record voice message"
|
aria-label="Record voice message"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
|
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
|
||||||
|
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||||
import { decorationUrl } from '../../features/lotus/avatarDecorations';
|
import { decorationUrl } from '../../features/lotus/avatarDecorations';
|
||||||
|
|
||||||
const DEFAULT_INSET = 8;
|
const DEFAULT_INSET = 8;
|
||||||
@@ -16,8 +17,14 @@ export function AvatarDecoration({
|
|||||||
inset = DEFAULT_INSET,
|
inset = DEFAULT_INSET,
|
||||||
}: AvatarDecorationProps) {
|
}: AvatarDecorationProps) {
|
||||||
const slug = useAvatarDecoration(userId);
|
const slug = useAvatarDecoration(userId);
|
||||||
|
const reducedMotion = useReducedMotion();
|
||||||
|
|
||||||
if (!slug) {
|
// Decorations are animated APNGs with no static asset to freeze to, so honor
|
||||||
|
// prefers-reduced-motion by not rendering the animation at all (consistent
|
||||||
|
// with the rest of the theming stack — chat backgrounds / seasonal overlays —
|
||||||
|
// which all suppress motion under this preference; also avoids dozens of live
|
||||||
|
// APNGs animating in scrolling mobile lists).
|
||||||
|
if (!slug || reducedMotion) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,23 @@ export const EditorOptions = style([
|
|||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
padding: config.space.S200,
|
padding: config.space.S200,
|
||||||
|
'@media': {
|
||||||
|
// On phones the toolbar can hold many 44px buttons; let them wrap to a
|
||||||
|
// second line instead of overflowing horizontally.
|
||||||
|
'(max-width: 750px)': { flexWrap: 'wrap' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// The composer's before | editable | after row. On phones, allow the toolbar
|
||||||
|
// (`after`) to wrap below the input instead of squeezing the editable to zero
|
||||||
|
// and pushing the Send button off-screen.
|
||||||
|
export const EditorInputRow = style({
|
||||||
|
'@media': {
|
||||||
|
'(max-width: 750px)': { flexWrap: 'wrap' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const EditorTextareaScroll = style({});
|
export const EditorTextareaScroll = style({});
|
||||||
|
|
||||||
export const EditorTextarea = style([
|
export const EditorTextarea = style([
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
<div className={css.Editor} ref={ref}>
|
<div className={css.Editor} ref={ref}>
|
||||||
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
||||||
{top}
|
{top}
|
||||||
<Box alignItems="Start">
|
<Box className={css.EditorInputRow} alignItems="Start">
|
||||||
{before && (
|
{before && (
|
||||||
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
||||||
{before}
|
{before}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect } from 'react';
|
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
|
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
|
||||||
import { JoinRule, MatrixClient } from 'matrix-js-sdk';
|
import { JoinRule, MatrixClient } from 'matrix-js-sdk';
|
||||||
@@ -80,7 +80,14 @@ export function RoomMentionAutocomplete({
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
|
||||||
const allRooms = useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx));
|
// Copy before sorting: `.sort()` mutates in place, and `allRoomsAtom`'s array
|
||||||
|
// is shared app-wide — sorting it here reorders it for every other consumer.
|
||||||
|
// Also memoize so a keystroke doesn't re-run the O(N log N) getRoom compare.
|
||||||
|
const allRoomsList = useAtomValue(allRoomsAtom);
|
||||||
|
const allRooms = useMemo(
|
||||||
|
() => [...allRoomsList].sort(factoryRoomIdByActivity(mx)),
|
||||||
|
[allRoomsList, mx],
|
||||||
|
);
|
||||||
|
|
||||||
const [result, search, resetSearch] = useAsyncSearch(
|
const [result, search, resetSearch] = useAsyncSearch(
|
||||||
allRooms,
|
allRooms,
|
||||||
|
|||||||
@@ -194,22 +194,43 @@ export const createCommandElement = (command: string): CommandElement => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
|
export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
|
||||||
Transforms.select(editor, selectRange);
|
// Wrap the whole sequence: on a stale autocomplete range (the document changed
|
||||||
Transforms.insertNodes(editor, element);
|
// between the menu opening and the pick) `insertNodes` — not `select`, which is
|
||||||
Transforms.collapse(editor, {
|
// lazy in this Slate version — can throw. This runs inside the pick's event
|
||||||
edge: 'end',
|
// handler, so an escape wouldn't hit the error boundary, but keep it contained.
|
||||||
});
|
try {
|
||||||
|
Transforms.select(editor, selectRange);
|
||||||
|
Transforms.insertNodes(editor, element);
|
||||||
|
Transforms.collapse(editor, { edge: 'end' });
|
||||||
|
} catch {
|
||||||
|
/* stale range — the pick is a no-op rather than an uncaught error */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const moveCursor = (editor: Editor, withSpace?: boolean) => {
|
export const moveCursor = (editor: Editor, withSpace?: boolean) => {
|
||||||
// Defer to the next tick so React can flush any pending void-element DOM
|
// Move the caret out of the just-inserted inline void and land it in a real
|
||||||
// updates (e.g. after inserting a mention) before Slate resolves cursor
|
// trailing text node — SYNCHRONOUSLY, in the same commit as the insert.
|
||||||
// positions via ReactEditor.toDOMNode — otherwise Slate throws
|
// `Transforms.move` escapes the void (after insertNodes+collapse the caret is
|
||||||
// "Cannot resolve a DOM node from slate node".
|
// INSIDE the void's inner text node; insertText there is a no-op, blocked by
|
||||||
|
// Slate's void guard). The space then lands in a real text node.
|
||||||
|
// Doing this in the same commit (vs the old deferred setTimeout) means the
|
||||||
|
// caret never sits on the void's zero-width edge on a racy tick — that edge's
|
||||||
|
// DOM (a U+FEFF node) isn't populated yet, so slate-react's commit-phase
|
||||||
|
// selection sync (setBaseAndExtent) threw IndexSizeError mid-render and tripped
|
||||||
|
// the composer error boundary. Both ops are pure model transforms (no DOM
|
||||||
|
// resolution), so running them synchronously is safe.
|
||||||
|
Transforms.move(editor);
|
||||||
|
if (withSpace) editor.insertText(' ');
|
||||||
|
// Re-assert focus next tick (a pick usually keeps the editor focused). Guarded
|
||||||
|
// because ReactEditor.focus resolves the DOM; with the caret now in a real text
|
||||||
|
// node this is safe, but stay defensive against a mid-flight editor.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
ReactEditor.focus(editor);
|
try {
|
||||||
Transforms.move(editor);
|
ReactEditor.focus(editor);
|
||||||
if (withSpace) editor.insertText(' ');
|
} catch {
|
||||||
|
// The editor DOM can be mid-flight (autocomplete just closed / re-render
|
||||||
|
// landed). The element is already inserted, so skip the focus nudge.
|
||||||
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ import { VirtualTile } from '../virtualizer';
|
|||||||
|
|
||||||
const RECENT_GROUP_ID = 'recent_group';
|
const RECENT_GROUP_ID = 'recent_group';
|
||||||
const SEARCH_GROUP_ID = 'search_group';
|
const SEARCH_GROUP_ID = 'search_group';
|
||||||
|
// Stable empty pack list for hideCustomEmojis (unicode-only) mode — a fresh []
|
||||||
|
// each render would needlessly re-run the memoized group/search builders.
|
||||||
|
const NO_IMAGE_PACKS: ImagePack[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazily pull in the emojibase data (see plugins/emoji `loadEmojiData`). The
|
* Lazily pull in the emojibase data (see plugins/emoji `loadEmojiData`). The
|
||||||
@@ -99,6 +102,7 @@ type StickerGroupItem = {
|
|||||||
const useGroups = (
|
const useGroups = (
|
||||||
tab: EmojiBoardTab,
|
tab: EmojiBoardTab,
|
||||||
imagePacks: ImagePack[],
|
imagePacks: ImagePack[],
|
||||||
|
hideCustomEmojis?: boolean,
|
||||||
): [EmojiGroupItem[], StickerGroupItem[]] => {
|
): [EmojiGroupItem[], StickerGroupItem[]] => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
@@ -114,7 +118,9 @@ const useGroups = (
|
|||||||
g.push({
|
g.push({
|
||||||
id: RECENT_GROUP_ID,
|
id: RECENT_GROUP_ID,
|
||||||
name: 'Recent',
|
name: 'Recent',
|
||||||
items: recentEmojis,
|
// In unicode-only mode drop recently-used CUSTOM emojis (PackImageReader);
|
||||||
|
// keep only unicode ones (IEmoji has `unicode`).
|
||||||
|
items: hideCustomEmojis ? recentEmojis.filter((e) => 'unicode' in e) : recentEmojis,
|
||||||
});
|
});
|
||||||
|
|
||||||
imagePacks.forEach((pack) => {
|
imagePacks.forEach((pack) => {
|
||||||
@@ -139,7 +145,7 @@ const useGroups = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
return g;
|
return g;
|
||||||
}, [mx, recentEmojis, labels, imagePacks, tab, loadedEmojiGroups]);
|
}, [mx, recentEmojis, labels, imagePacks, tab, loadedEmojiGroups, hideCustomEmojis]);
|
||||||
|
|
||||||
const stickerGroupItems = useMemo(() => {
|
const stickerGroupItems = useMemo(() => {
|
||||||
const g: StickerGroupItem[] = [];
|
const g: StickerGroupItem[] = [];
|
||||||
@@ -434,6 +440,11 @@ type EmojiBoardProps = {
|
|||||||
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
|
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
|
||||||
allowTextCustomEmoji?: boolean;
|
allowTextCustomEmoji?: boolean;
|
||||||
addToRecentEmoji?: boolean;
|
addToRecentEmoji?: boolean;
|
||||||
|
// Unicode-only mode: hide custom/image-pack emojis (packs, sidebar icons,
|
||||||
|
// search, and custom entries in Recent). For targets that can only hold plain
|
||||||
|
// text — e.g. the presence status message, which can't render an mxc image —
|
||||||
|
// so users only see emojis that actually insert.
|
||||||
|
hideCustomEmojis?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EmojiBoard({
|
export function EmojiBoard({
|
||||||
@@ -447,6 +458,7 @@ export function EmojiBoard({
|
|||||||
onStickerSelect,
|
onStickerSelect,
|
||||||
allowTextCustomEmoji,
|
allowTextCustomEmoji,
|
||||||
addToRecentEmoji = true,
|
addToRecentEmoji = true,
|
||||||
|
hideCustomEmojis,
|
||||||
}: EmojiBoardProps) {
|
}: EmojiBoardProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
@@ -460,8 +472,11 @@ export function EmojiBoard({
|
|||||||
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
|
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
|
||||||
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
|
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
|
||||||
const setRecentStickers = useSetAtom(recentStickersAtom);
|
const setRecentStickers = useSetAtom(recentStickersAtom);
|
||||||
const imagePacks = useRelevantImagePacks(usage, imagePackRooms);
|
const relevantImagePacks = useRelevantImagePacks(usage, imagePackRooms);
|
||||||
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
|
// Unicode-only mode: drop all custom/image packs so the sidebar, group list,
|
||||||
|
// and search show only insertable unicode emojis.
|
||||||
|
const imagePacks = hideCustomEmojis ? NO_IMAGE_PACKS : relevantImagePacks;
|
||||||
|
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks, hideCustomEmojis);
|
||||||
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
|
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
|
||||||
const renderItem = useItemRenderer(tab);
|
const renderItem = useItemRenderer(tab);
|
||||||
const { emojis: loadedEmojis } = useEmojiData();
|
const { emojis: loadedEmojis } = useEmojiData();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as css from './ImageViewer.css';
|
|||||||
import { useZoom } from '../../hooks/useZoom';
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
import { usePan } from '../../hooks/usePan';
|
import { usePan } from '../../hooks/usePan';
|
||||||
import { downloadMedia } from '../../utils/matrix';
|
import { downloadMedia } from '../../utils/matrix';
|
||||||
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
|
|
||||||
export type ImageViewerProps = {
|
export type ImageViewerProps = {
|
||||||
alt: string;
|
alt: string;
|
||||||
@@ -19,7 +20,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const saveFile = useSaveFile();
|
const saveFile = useSaveFile();
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
const { pan, cursor, onMouseDown, onTouchStart } = usePan(zoom !== 1);
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
const fileContent = await downloadMedia(src);
|
const fileContent = await downloadMedia(src);
|
||||||
@@ -35,7 +36,13 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
>
|
>
|
||||||
<Header className={css.ImageViewerHeader} size="400">
|
<Header className={css.ImageViewerHeader} size="400">
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
<IconButton size="300" radii="300" onClick={requestClose} aria-label="Close">
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
className={MobileTouchTarget}
|
||||||
|
onClick={requestClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
<Icon size="50" src={Icons.ArrowLeft} />
|
<Icon size="50" src={Icons.ArrowLeft} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Text size="T300" truncate>
|
<Text size="T300" truncate>
|
||||||
@@ -48,12 +55,18 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
outlined={zoom < 1}
|
outlined={zoom < 1}
|
||||||
size="300"
|
size="300"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={zoomOut}
|
onClick={zoomOut}
|
||||||
aria-label="Zoom Out"
|
aria-label="Zoom Out"
|
||||||
>
|
>
|
||||||
<Icon size="50" src={Icons.Minus} />
|
<Icon size="50" src={Icons.Minus} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
className={MobileTouchTarget}
|
||||||
|
onClick={() => setZoom(zoom === 1 ? 2 : 1)}
|
||||||
|
>
|
||||||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -61,6 +74,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
outlined={zoom > 1}
|
outlined={zoom > 1}
|
||||||
size="300"
|
size="300"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={zoomIn}
|
onClick={zoomIn}
|
||||||
aria-label="Zoom In"
|
aria-label="Zoom In"
|
||||||
>
|
>
|
||||||
@@ -70,6 +84,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
variant="Primary"
|
variant="Primary"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
radii="300"
|
radii="300"
|
||||||
|
className={MobileTouchTarget}
|
||||||
before={<Icon size="50" src={Icons.Download} />}
|
before={<Icon size="50" src={Icons.Download} />}
|
||||||
>
|
>
|
||||||
<Text size="B300">{t('Organisms.ImageViewer.download')}</Text>
|
<Text size="B300">{t('Organisms.ImageViewer.download')}</Text>
|
||||||
@@ -100,6 +115,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
|
|||||||
return (
|
return (
|
||||||
<Wrapper
|
<Wrapper
|
||||||
// KaTeX output is generated by our own render call (trusted-safe).
|
// KaTeX output is generated by our own render call (trusted-safe).
|
||||||
// eslint-disable-next-line react/no-danger
|
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const MediaControl = as<'div', MediaControlProps>(
|
|||||||
({ before, after, leftControl, rightControl, children, ...props }, ref) => (
|
({ before, after, leftControl, rightControl, children, ...props }, ref) => (
|
||||||
<Box grow="Yes" direction="Column" gap="300" {...props} ref={ref}>
|
<Box grow="Yes" direction="Column" gap="300" {...props} ref={ref}>
|
||||||
{before && <Box direction="Column">{before}</Box>}
|
{before && <Box direction="Column">{before}</Box>}
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200" wrap="Wrap">
|
||||||
<Box alignItems="Center" grow="Yes" gap="Inherit">
|
<Box alignItems="Center" grow="Yes" gap="Inherit">
|
||||||
{leftControl}
|
{leftControl}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import {
|
|||||||
MessageBrokenContent,
|
MessageBrokenContent,
|
||||||
MessageDeletedContent,
|
MessageDeletedContent,
|
||||||
MessageEditedContent,
|
MessageEditedContent,
|
||||||
|
MessageTranslatedContent,
|
||||||
MessageUnsupportedContent,
|
MessageUnsupportedContent,
|
||||||
MessageVerificationRequestContent,
|
MessageVerificationRequestContent,
|
||||||
} from './content';
|
} from './content';
|
||||||
|
import { useMessageTranslation } from '../../hooks/useMessageTranslation';
|
||||||
|
import { languageName } from '../../utils/translation/langUtils';
|
||||||
import {
|
import {
|
||||||
IAudioContent,
|
IAudioContent,
|
||||||
IAudioInfo,
|
IAudioInfo,
|
||||||
@@ -162,6 +165,76 @@ type RenderBodyProps = {
|
|||||||
body: string;
|
body: string;
|
||||||
customBody?: string;
|
customBody?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Shared body renderer for m.text / m.emote / m.notice. Handles the on-device
|
||||||
|
// translation swap: when translation is active and done, the translated text is
|
||||||
|
// rendered through the plain-text path (linkify + emoji, no stored HTML) inside
|
||||||
|
// a dir="auto" span for RTL, with a "Translated from … · Show original" chip.
|
||||||
|
type TranslatableBodyProps = {
|
||||||
|
variant: 'text' | 'emote' | 'notice';
|
||||||
|
displayName?: string;
|
||||||
|
eventId: string;
|
||||||
|
trimmedBody: string;
|
||||||
|
customBody?: string;
|
||||||
|
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||||
|
edited?: boolean;
|
||||||
|
onEditHistoryClick?: () => void;
|
||||||
|
style?: CSSProperties;
|
||||||
|
};
|
||||||
|
function TranslatableBody({
|
||||||
|
variant,
|
||||||
|
displayName,
|
||||||
|
eventId,
|
||||||
|
trimmedBody,
|
||||||
|
customBody,
|
||||||
|
renderBody,
|
||||||
|
edited,
|
||||||
|
onEditHistoryClick,
|
||||||
|
style,
|
||||||
|
}: TranslatableBodyProps) {
|
||||||
|
const translation = useMessageTranslation(eventId, trimmedBody);
|
||||||
|
const showTranslated =
|
||||||
|
translation.active && translation.status === 'done' && !!translation.translated;
|
||||||
|
const shownBody = showTranslated ? translation.translated! : trimmedBody;
|
||||||
|
|
||||||
|
const chipStatus = translation.status === 'detecting' ? 'translating' : translation.status;
|
||||||
|
const showChip =
|
||||||
|
translation.active &&
|
||||||
|
(chipStatus === 'downloading' ||
|
||||||
|
chipStatus === 'translating' ||
|
||||||
|
chipStatus === 'done' ||
|
||||||
|
chipStatus === 'error');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageTextBody
|
||||||
|
emote={variant === 'emote'}
|
||||||
|
notice={variant === 'notice'}
|
||||||
|
preWrap={showTranslated || typeof customBody !== 'string'}
|
||||||
|
jumboEmoji={JUMBO_EMOJI_REG.test(shownBody)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{variant === 'emote' && <b>{`${displayName} `}</b>}
|
||||||
|
{showTranslated ? (
|
||||||
|
<span dir="auto">{renderBody({ body: shownBody, customBody: undefined })}</span>
|
||||||
|
) : (
|
||||||
|
renderBody({
|
||||||
|
body: trimmedBody,
|
||||||
|
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||||
|
{showChip && (
|
||||||
|
<MessageTranslatedContent
|
||||||
|
fromLangName={languageName(translation.fromLang ?? '')}
|
||||||
|
status={chipStatus as 'downloading' | 'translating' | 'done' | 'error'}
|
||||||
|
downloadProgress={translation.downloadProgress}
|
||||||
|
onToggle={translation.toggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</MessageTextBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type MTextProps = {
|
type MTextProps = {
|
||||||
edited?: boolean;
|
edited?: boolean;
|
||||||
onEditHistoryClick?: () => void;
|
onEditHistoryClick?: () => void;
|
||||||
@@ -190,17 +263,30 @@ export function MText({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CollapsibleBody eventId={eventId}>
|
<CollapsibleBody eventId={eventId}>
|
||||||
<MessageTextBody
|
{eventId ? (
|
||||||
preWrap={typeof customBody !== 'string'}
|
<TranslatableBody
|
||||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
variant="text"
|
||||||
style={style}
|
eventId={eventId}
|
||||||
>
|
trimmedBody={trimmedBody}
|
||||||
{renderBody({
|
customBody={typeof customBody === 'string' ? customBody : undefined}
|
||||||
body: trimmedBody,
|
renderBody={renderBody}
|
||||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
edited={edited}
|
||||||
})}
|
onEditHistoryClick={onEditHistoryClick}
|
||||||
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
style={style}
|
||||||
</MessageTextBody>
|
/>
|
||||||
|
) : (
|
||||||
|
<MessageTextBody
|
||||||
|
preWrap={typeof customBody !== 'string'}
|
||||||
|
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{renderBody({
|
||||||
|
body: trimmedBody,
|
||||||
|
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||||
|
})}
|
||||||
|
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||||
|
</MessageTextBody>
|
||||||
|
)}
|
||||||
</CollapsibleBody>
|
</CollapsibleBody>
|
||||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||||
</>
|
</>
|
||||||
@@ -235,18 +321,31 @@ export function MEmote({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CollapsibleBody eventId={eventId}>
|
<CollapsibleBody eventId={eventId}>
|
||||||
<MessageTextBody
|
{eventId ? (
|
||||||
emote
|
<TranslatableBody
|
||||||
preWrap={typeof customBody !== 'string'}
|
variant="emote"
|
||||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
displayName={displayName}
|
||||||
>
|
eventId={eventId}
|
||||||
<b>{`${displayName} `}</b>
|
trimmedBody={trimmedBody}
|
||||||
{renderBody({
|
customBody={typeof customBody === 'string' ? customBody : undefined}
|
||||||
body: trimmedBody,
|
renderBody={renderBody}
|
||||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
edited={edited}
|
||||||
})}
|
onEditHistoryClick={onEditHistoryClick}
|
||||||
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
/>
|
||||||
</MessageTextBody>
|
) : (
|
||||||
|
<MessageTextBody
|
||||||
|
emote
|
||||||
|
preWrap={typeof customBody !== 'string'}
|
||||||
|
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||||
|
>
|
||||||
|
<b>{`${displayName} `}</b>
|
||||||
|
{renderBody({
|
||||||
|
body: trimmedBody,
|
||||||
|
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||||
|
})}
|
||||||
|
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||||
|
</MessageTextBody>
|
||||||
|
)}
|
||||||
</CollapsibleBody>
|
</CollapsibleBody>
|
||||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||||
</>
|
</>
|
||||||
@@ -279,17 +378,29 @@ export function MNotice({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CollapsibleBody eventId={eventId}>
|
<CollapsibleBody eventId={eventId}>
|
||||||
<MessageTextBody
|
{eventId ? (
|
||||||
notice
|
<TranslatableBody
|
||||||
preWrap={typeof customBody !== 'string'}
|
variant="notice"
|
||||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
eventId={eventId}
|
||||||
>
|
trimmedBody={trimmedBody}
|
||||||
{renderBody({
|
customBody={typeof customBody === 'string' ? customBody : undefined}
|
||||||
body: trimmedBody,
|
renderBody={renderBody}
|
||||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
edited={edited}
|
||||||
})}
|
onEditHistoryClick={onEditHistoryClick}
|
||||||
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
/>
|
||||||
</MessageTextBody>
|
) : (
|
||||||
|
<MessageTextBody
|
||||||
|
notice
|
||||||
|
preWrap={typeof customBody !== 'string'}
|
||||||
|
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||||
|
>
|
||||||
|
{renderBody({
|
||||||
|
body: trimmedBody,
|
||||||
|
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||||
|
})}
|
||||||
|
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||||
|
</MessageTextBody>
|
||||||
|
)}
|
||||||
</CollapsibleBody>
|
</CollapsibleBody>
|
||||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||||
</>
|
</>
|
||||||
@@ -306,6 +417,22 @@ type RenderImageContentProps = {
|
|||||||
markedAsSpoiler?: boolean;
|
markedAsSpoiler?: boolean;
|
||||||
spoilerReason?: string;
|
spoilerReason?: string;
|
||||||
};
|
};
|
||||||
|
// Media frame sizing. When intrinsic width/height are known, drive the box by
|
||||||
|
// aspect-ratio so its height tracks the responsive (maxWidth:100%) width — a
|
||||||
|
// fixed pixel height computed for a 400px-wide layout otherwise crops (images,
|
||||||
|
// object-fit:cover) or letterboxes (videos, object-fit:contain) on phones where
|
||||||
|
// the box narrows below 400px. On desktop the box stays 400px wide, so the
|
||||||
|
// aspect-ratio yields the identical height. Falls back to the fixed height when
|
||||||
|
// dimensions are unknown.
|
||||||
|
const attachmentMediaStyle = (
|
||||||
|
w: number | undefined,
|
||||||
|
h: number | undefined,
|
||||||
|
fallbackHeight: number,
|
||||||
|
): CSSProperties =>
|
||||||
|
w && h
|
||||||
|
? { aspectRatio: `${w} / ${h}`, minHeight: toRem(48) }
|
||||||
|
: { height: toRem(fallbackHeight < 48 ? 48 : fallbackHeight) };
|
||||||
|
|
||||||
type MImageProps = {
|
type MImageProps = {
|
||||||
content: IImageContent;
|
content: IImageContent;
|
||||||
renderImageContent: (props: RenderImageContentProps) => ReactNode;
|
renderImageContent: (props: RenderImageContentProps) => ReactNode;
|
||||||
@@ -321,11 +448,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Attachment outlined={outlined}>
|
<Attachment outlined={outlined}>
|
||||||
<AttachmentBox
|
<AttachmentBox style={attachmentMediaStyle(imgInfo?.w, imgInfo?.h, height)}>
|
||||||
style={{
|
|
||||||
height: toRem(height < 48 ? 48 : height),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderImageContent({
|
{renderImageContent({
|
||||||
body: content.body || 'Image',
|
body: content.body || 'Image',
|
||||||
info: imgInfo,
|
info: imgInfo,
|
||||||
@@ -387,11 +510,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</AttachmentHeader>
|
</AttachmentHeader>
|
||||||
<AttachmentBox
|
<AttachmentBox style={attachmentMediaStyle(videoInfo.w, videoInfo.h, height)}>
|
||||||
style={{
|
|
||||||
height: toRem(height < 48 ? 48 : height),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderVideoContent({
|
{renderVideoContent({
|
||||||
body: content.body || 'Video',
|
body: content.body || 'Video',
|
||||||
info: videoInfo,
|
info: videoInfo,
|
||||||
@@ -566,7 +685,7 @@ export function MLocation({ content }: MLocationProps) {
|
|||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
size="400"
|
size="400"
|
||||||
href={`https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`}
|
href={`https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
|
|||||||
@@ -89,3 +89,47 @@ export const MessageEditedContent = as<
|
|||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type TranslatedStatus = 'downloading' | 'translating' | 'done' | 'error';
|
||||||
|
|
||||||
|
const translatedLabel = (
|
||||||
|
status: TranslatedStatus,
|
||||||
|
fromLangName: string,
|
||||||
|
downloadProgress?: number,
|
||||||
|
): string => {
|
||||||
|
if (status === 'downloading') {
|
||||||
|
const pct =
|
||||||
|
typeof downloadProgress === 'number' ? ` ${Math.round(downloadProgress * 100)}%` : '';
|
||||||
|
return `Downloading translation model…${pct}`;
|
||||||
|
}
|
||||||
|
if (status === 'translating') return 'Translating…';
|
||||||
|
if (status === 'error') return 'Translation failed — show original';
|
||||||
|
return `Translated from ${fromLangName} · Show original`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline chip shown beside a translated message body. Clicking it toggles back
|
||||||
|
// to the original text (the same per-event toggle the message menu drives).
|
||||||
|
export const MessageTranslatedContent = as<
|
||||||
|
'span',
|
||||||
|
{
|
||||||
|
children?: never;
|
||||||
|
fromLangName: string;
|
||||||
|
status: TranslatedStatus;
|
||||||
|
downloadProgress?: number;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
>(({ fromLangName, status, downloadProgress, onToggle, ...props }, ref) => (
|
||||||
|
<span ref={ref} {...(props as React.HTMLAttributes<HTMLSpanElement>)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-pressed={status === 'done'}
|
||||||
|
aria-label="Toggle message translation"
|
||||||
|
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
|
||||||
|
>
|
||||||
|
<Text as="span" size="T200" priority="300">
|
||||||
|
{` · ${translatedLabel(status, fromLangName, downloadProgress)}`}
|
||||||
|
</Text>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
|
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, { ComponentProps, forwardRef } from 'react';
|
import React, { ComponentProps, forwardRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router';
|
||||||
import { as } from 'folds';
|
import { as } from 'folds';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ const NavItemBase = style({
|
|||||||
color: OnContainer,
|
color: OnContainer,
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
minHeight: toRem(36),
|
minHeight: toRem(36),
|
||||||
|
'@media': {
|
||||||
|
// The room/nav row is the app's primary tap target; give it a 44px touch
|
||||||
|
// area on phones (desktop stays the denser 36px).
|
||||||
|
'(max-width: 750px)': { minHeight: toRem(44) },
|
||||||
|
},
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:hover, &:focus-visible': {
|
'&:hover, &:focus-visible': {
|
||||||
|
|||||||
@@ -27,7 +27,14 @@ type PresenceBadgeProps = {
|
|||||||
};
|
};
|
||||||
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||||
const label = usePresenceLabel();
|
const label = usePresenceLabel();
|
||||||
const ariaLabel = status ? `${label[presence]} — ${status}` : label[presence];
|
// DND is encoded as unavailable + status_msg 'dnd'; render it red/"Do Not
|
||||||
|
// Disturb" to match PresenceRingAvatar and the settings picker (which both
|
||||||
|
// special-case 'dnd' → Critical) — the badge was the lone outlier showing a
|
||||||
|
// yellow "Idle". The 'dnd' sentinel isn't surfaced as a status line.
|
||||||
|
const isDnd = presence === Presence.Unavailable && status === 'dnd';
|
||||||
|
const displayLabel = isDnd ? 'Do Not Disturb' : label[presence];
|
||||||
|
const displayStatus = isDnd ? undefined : status;
|
||||||
|
const ariaLabel = displayStatus ? `${displayLabel} — ${displayStatus}` : displayLabel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
@@ -38,9 +45,9 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
|||||||
tooltip={
|
tooltip={
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
|
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
|
||||||
<Text size="L400">{label[presence]}</Text>
|
<Text size="L400">{displayLabel}</Text>
|
||||||
{status && <Text size="T200">•</Text>}
|
{displayStatus && <Text size="T200">•</Text>}
|
||||||
{status && <Text size="T200">{status}</Text>}
|
{displayStatus && <Text size="T200">{displayStatus}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
@@ -50,7 +57,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
size={size}
|
size={size}
|
||||||
variant={PresenceToColor[presence]}
|
variant={isDnd ? 'Critical' : PresenceToColor[presence]}
|
||||||
fill={presence === Presence.Offline ? 'Soft' : 'Solid'}
|
fill={presence === Presence.Offline ? 'Soft' : 'Solid'}
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Room, RoomStateEvent, RoomStateEventHandlerMap } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import {
|
import {
|
||||||
Icon,
|
Icon,
|
||||||
Icons,
|
Icons,
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { getMemberName } from '../../utils/room';
|
import { getMemberName } from '../../utils/room';
|
||||||
@@ -21,8 +20,9 @@ import { StackedAvatar } from '../stacked-avatar';
|
|||||||
import { EventReaders } from '../event-readers';
|
import { EventReaders } from '../event-readers';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
|
||||||
import { useMemberAvatar } from '../../hooks/useMemberAvatar';
|
import { useMemberAvatar } from '../../hooks/useMemberAvatar';
|
||||||
|
import { useRoomMembersChange } from '../../hooks/useRoomMemberChange';
|
||||||
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
import * as css from './ReadReceiptAvatars.css';
|
import * as css from './ReadReceiptAvatars.css';
|
||||||
|
|
||||||
const MAX_DISPLAY = 5;
|
const MAX_DISPLAY = 5;
|
||||||
@@ -50,32 +50,15 @@ export function ReadReceiptAvatars({
|
|||||||
eventId: string;
|
eventId: string;
|
||||||
userIds: string[];
|
userIds: string[];
|
||||||
}) {
|
}) {
|
||||||
const mx = useMatrixClient();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
const modalStyle = useModalStyle(360);
|
const modalStyle = useModalStyle(360);
|
||||||
const [, forceUpdate] = useForceUpdate();
|
|
||||||
|
|
||||||
// The avatars + names below are read from room member state at render time, so
|
// The tooltip names below are read from room member state at render time.
|
||||||
// they only refresh when this component re-renders (which the timeline does on a
|
// Re-render on a member-state change of any displayed reader so a display-name
|
||||||
// receipt change). Re-render on a member-state change of any displayed reader so
|
// update shows live (each avatar handles its own via useMemberAvatar). Uses the
|
||||||
// an avatar/display-name update shows live. RoomStateEvent.Members fires for name,
|
// shared member-change store — one global listener for the whole app (PERF-3).
|
||||||
// avatar AND membership changes (RoomMemberEvent has no Avatar signal).
|
useRoomMembersChange(room.roomId, userIds);
|
||||||
useEffect(() => {
|
|
||||||
const handleMembers: RoomStateEventHandlerMap[RoomStateEvent.Members] = (
|
|
||||||
event,
|
|
||||||
_state,
|
|
||||||
member,
|
|
||||||
) => {
|
|
||||||
if (event.getRoomId() === room.roomId && userIds.includes(member.userId)) {
|
|
||||||
forceUpdate();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
mx.on(RoomStateEvent.Members, handleMembers);
|
|
||||||
return () => {
|
|
||||||
mx.removeListener(RoomStateEvent.Members, handleMembers);
|
|
||||||
};
|
|
||||||
}, [mx, room.roomId, userIds, forceUpdate]);
|
|
||||||
|
|
||||||
if (userIds.length === 0) return null;
|
if (userIds.length === 0) return null;
|
||||||
|
|
||||||
@@ -110,7 +93,7 @@ export function ReadReceiptAvatars({
|
|||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
title={tooltipNames}
|
title={tooltipNames}
|
||||||
aria-label={tooltipNames}
|
aria-label={tooltipNames}
|
||||||
className={css.ReceiptTrigger}
|
className={`${css.ReceiptTrigger} ${MobileTouchTarget}`}
|
||||||
>
|
>
|
||||||
{/* Pill wrapper ensures visibility on any wallpaper/background */}
|
{/* Pill wrapper ensures visibility on any wallpaper/background */}
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export const CardGrid = style({
|
|||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
gap: config.space.S400,
|
gap: config.space.S400,
|
||||||
|
'@media': {
|
||||||
|
// Cards squish/overflow below ~360px each; drop to a single column on phones
|
||||||
|
// (mirrors the 750px breakpoint the nav uses).
|
||||||
|
'(max-width: 750px)': { gridTemplateColumns: '1fr' },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const RoomCardBase = style([
|
export const RoomCardBase = style([
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||||
import { zIndices } from '../../styles/zIndex';
|
import { zIndices } from '../../styles/zIndex';
|
||||||
import { SeasonTheme } from './types';
|
import { SeasonTheme } from './types';
|
||||||
import { getActiveSeason } from './seasonSchedule';
|
import { resolveSeasonTheme } from './seasonSchedule';
|
||||||
import { HalloweenOverlay } from './themes/Halloween';
|
import { HalloweenOverlay } from './themes/Halloween';
|
||||||
import { ChristmasOverlay } from './themes/Christmas';
|
import { ChristmasOverlay } from './themes/Christmas';
|
||||||
import { NewYearOverlay } from './themes/NewYear';
|
import { NewYearOverlay } from './themes/NewYear';
|
||||||
@@ -96,13 +96,25 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
|
|||||||
export function SeasonalEffect() {
|
export function SeasonalEffect() {
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const reduced = useReducedMotion();
|
const reduced = useReducedMotion();
|
||||||
|
const override = settings.seasonalThemeOverride ?? 'auto';
|
||||||
|
|
||||||
const theme = useMemo<SeasonTheme | null>(() => {
|
// In auto mode, re-evaluate hourly so a long-lived session crosses a
|
||||||
const override = settings.seasonalThemeOverride ?? 'auto';
|
// season/holiday-window boundary (e.g. into a new day) without a reload —
|
||||||
if (override === 'off') return null;
|
// otherwise the active season is frozen at the value it had on mount.
|
||||||
if (override === 'auto') return getActiveSeason(new Date());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
return override as SeasonTheme;
|
useEffect(() => {
|
||||||
}, [settings.seasonalThemeOverride]);
|
if (override !== 'auto') return undefined;
|
||||||
|
// Refresh on entering auto too: `now` may be a stale mount-time value if we
|
||||||
|
// were previously in a pinned/off mode (the interval only runs while auto).
|
||||||
|
setNow(Date.now());
|
||||||
|
const id = window.setInterval(() => setNow(Date.now()), 60 * 60 * 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [override]);
|
||||||
|
|
||||||
|
const theme = useMemo<SeasonTheme | null>(
|
||||||
|
() => resolveSeasonTheme(override, now),
|
||||||
|
[override, now],
|
||||||
|
);
|
||||||
|
|
||||||
if (!theme) return null;
|
if (!theme) return null;
|
||||||
// Suppress seasonal overlay when a chat background is active — both running simultaneously
|
// Suppress seasonal overlay when a chat background is active — both running simultaneously
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import { getActiveSeason, SEASON_SCHEDULE, SEASON_DATE_RANGES } from './seasonSchedule';
|
import {
|
||||||
|
getActiveSeason,
|
||||||
|
resolveSeasonTheme,
|
||||||
|
SEASON_SCHEDULE,
|
||||||
|
SEASON_DATE_RANGES,
|
||||||
|
} from './seasonSchedule';
|
||||||
import { SeasonTheme } from './types';
|
import { SeasonTheme } from './types';
|
||||||
|
|
||||||
// Date(year, monthIndex0, day)
|
// Date(year, monthIndex0, day)
|
||||||
@@ -52,6 +57,27 @@ test('window boundaries are inclusive at both ends', () => {
|
|||||||
assert.equal(getActiveSeason(on(1, 16)), null); // Feb 16 just after
|
assert.equal(getActiveSeason(on(1, 16)), null); // Feb 16 just after
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveSeasonTheme: off → null, pinned → that theme, auto → active season', () => {
|
||||||
|
const halloweenTs = on(9, 20).getTime(); // Oct 20 → halloween season
|
||||||
|
const offSeasonTs = on(5, 15).getTime(); // Jun 15 → no season
|
||||||
|
// 'off' never renders, regardless of date.
|
||||||
|
assert.equal(resolveSeasonTheme('off', halloweenTs), null);
|
||||||
|
// A pinned theme renders regardless of date (even off-season).
|
||||||
|
assert.equal(resolveSeasonTheme('christmas', offSeasonTs), 'christmas');
|
||||||
|
// 'auto' tracks the active season for the given instant.
|
||||||
|
assert.equal(resolveSeasonTheme('auto', halloweenTs), 'halloween');
|
||||||
|
assert.equal(resolveSeasonTheme('auto', offSeasonTs), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveSeasonTheme: auto re-evaluates as `now` advances across a boundary', () => {
|
||||||
|
// The same 'auto' override yields different themes at different instants — this
|
||||||
|
// is what the SeasonalEffect ticker relies on (incl. the switch-into-auto case
|
||||||
|
// where `now` must be current, not a stale mount value).
|
||||||
|
assert.equal(resolveSeasonTheme('auto', on(9, 20).getTime()), 'halloween'); // Oct 20
|
||||||
|
assert.equal(resolveSeasonTheme('auto', on(11, 15).getTime()), 'christmas'); // Dec 15
|
||||||
|
assert.equal(resolveSeasonTheme('auto', on(6, 4).getTime()), null); // Jul 4
|
||||||
|
});
|
||||||
|
|
||||||
test('SEASON_DATE_RANGES has a label for every scheduled theme', () => {
|
test('SEASON_DATE_RANGES has a label for every scheduled theme', () => {
|
||||||
assert.equal(SEASON_SCHEDULE.length, 11);
|
assert.equal(SEASON_SCHEDULE.length, 11);
|
||||||
const themes = SEASON_SCHEDULE.map((e) => e.theme);
|
const themes = SEASON_SCHEDULE.map((e) => e.theme);
|
||||||
|
|||||||
@@ -93,3 +93,18 @@ export function getActiveSeason(now: Date): SeasonTheme | null {
|
|||||||
const day = now.getDate();
|
const day = now.getDate();
|
||||||
return SEASON_SCHEDULE.find((entry) => entry.matches(month, day))?.theme ?? null;
|
return SEASON_SCHEDULE.find((entry) => entry.matches(month, day))?.theme ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A seasonal-theme setting value: the active season, a pinned theme, or off. */
|
||||||
|
export type SeasonalOverride = SeasonTheme | 'auto' | 'off';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The theme to render for a `seasonalThemeOverride` at time `now` (epoch ms):
|
||||||
|
* 'off' → none, 'auto' → the active season for that instant, else the pinned
|
||||||
|
* theme. Kept pure (and unit-tested) so the decision is verifiable without
|
||||||
|
* mounting the React overlay.
|
||||||
|
*/
|
||||||
|
export function resolveSeasonTheme(override: SeasonalOverride, now: number): SeasonTheme | null {
|
||||||
|
if (override === 'off') return null;
|
||||||
|
if (override === 'auto') return getActiveSeason(new Date(now));
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export const SidebarItem = recipe({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
|
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
|
||||||
|
'@media': {
|
||||||
|
// Space-rail buttons to a 44px touch target on phones (2px larger).
|
||||||
|
'(max-width: 750px)': { minWidth: toRem(44), minHeight: toRem(44) },
|
||||||
|
},
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
key={key}
|
key={key}
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
gap="200"
|
gap="200"
|
||||||
|
wrap="Wrap"
|
||||||
style={{
|
style={{
|
||||||
padding: config.space.S200,
|
padding: config.space.S200,
|
||||||
borderRadius: config.radii.R400,
|
borderRadius: config.radii.R400,
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { DefaultReset, color, config, toRem } from 'folds';
|
|||||||
export const UrlPreview = style([
|
export const UrlPreview = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
width: toRem(400),
|
// 25rem (=400px) on desktop, but shrink to fit narrow phones so a single
|
||||||
|
// card doesn't exceed the viewport (mirrors UrlPreviewWide's min()).
|
||||||
|
width: 'min(25rem, 92vw)',
|
||||||
minHeight: toRem(102),
|
minHeight: toRem(102),
|
||||||
backgroundColor: color.SurfaceVariant.Container,
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
color: color.SurfaceVariant.OnContainer,
|
color: color.SurfaceVariant.OnContainer,
|
||||||
@@ -21,6 +23,19 @@ export const UrlPreviewWide = style({
|
|||||||
width: 'min(38rem, 94vw)',
|
width: 'min(38rem, 94vw)',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The Twitch/Twitter/TikTok-fallback cards lay their header/thumbnail out BESIDE
|
||||||
|
// the content as direct children of the UrlPreview flex row; on a phone that
|
||||||
|
// squeezes both. Stack them vertically on narrow viewports only. `UrlPreview`'s
|
||||||
|
// Box has no explicit direction (browser default row), so this override wins
|
||||||
|
// with nothing to compete against, and desktop (>750px) is unchanged.
|
||||||
|
export const StackOnMobile = style({
|
||||||
|
'@media': {
|
||||||
|
'(max-width: 750px)': {
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const UrlPreviewImg = style([
|
export const UrlPreviewImg = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
@@ -404,6 +419,56 @@ export const BadgeSteam = style({
|
|||||||
color: '#c7d5e0',
|
color: '#c7d5e0',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Steam card — full-width header/banner image + official store widget iframe
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const SteamBannerWrapper = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
// Steam header capsules are 460×215 (~2.14:1); news banners vary but crop
|
||||||
|
// fine to the same ratio.
|
||||||
|
aspectRatio: '460 / 215',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: '#0e1520',
|
||||||
|
cursor: 'pointer',
|
||||||
|
|
||||||
|
':hover': {
|
||||||
|
filter: 'brightness(0.9)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const SteamBannerImg = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'center',
|
||||||
|
display: 'block',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The official Steam store widget is a compact banner (~646×190). Full-width,
|
||||||
|
// fixed height so the iframe doesn't collapse to its intrinsic size.
|
||||||
|
export const SteamWidget = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: '100%',
|
||||||
|
height: toRem(190),
|
||||||
|
border: 0,
|
||||||
|
display: 'block',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
backgroundColor: '#1b2838',
|
||||||
|
marginTop: config.space.S100,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const BadgeWikipedia = style({
|
export const BadgeWikipedia = style({
|
||||||
backgroundColor: color.SurfaceVariant.ContainerLine,
|
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||||
color: color.SurfaceVariant.OnContainer,
|
color: color.SurfaceVariant.OnContainer,
|
||||||
@@ -484,6 +549,16 @@ export const BadgeTidal = style({
|
|||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const BadgeMixcloud = style({
|
||||||
|
backgroundColor: '#52aad8',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeDeezer = style({
|
||||||
|
backgroundColor: '#a238ff',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Twitch LIVE badge
|
// Twitch LIVE badge
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { ImageOverlay } from '../ImageOverlay';
|
import { ImageOverlay } from '../ImageOverlay';
|
||||||
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
|
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
|
||||||
@@ -33,11 +34,13 @@ import { useSetting } from '../../state/hooks/settings';
|
|||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import {
|
import {
|
||||||
extractEmbedHeight,
|
extractEmbedHeight,
|
||||||
|
getSteamTarget,
|
||||||
getTikTokVideoId,
|
getTikTokVideoId,
|
||||||
getTweetId,
|
getTweetId,
|
||||||
isTikTokLink,
|
isTikTokLink,
|
||||||
MediaEmbed,
|
MediaEmbed,
|
||||||
parseMediaEmbed,
|
parseMediaEmbed,
|
||||||
|
steamWidgetEmbedUrl,
|
||||||
tiktokIdFromOembed,
|
tiktokIdFromOembed,
|
||||||
tiktokOembedUrl,
|
tiktokOembedUrl,
|
||||||
tiktokPlayerEmbedUrl,
|
tiktokPlayerEmbedUrl,
|
||||||
@@ -115,11 +118,15 @@ function isGitHubRepo(url: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep these hosts + the /status(es) pattern in sync with getTweetId
|
||||||
|
// (videoEmbed.ts): otherwise a mobile.twitter.com / legacy /statuses/ tweet has
|
||||||
|
// an extractable id but never routes to the Twitter card or "View post" embed.
|
||||||
|
const TWITTER_HOSTS = new Set(['twitter.com', 'x.com', 'mobile.twitter.com']);
|
||||||
|
|
||||||
function isTwitter(url: string): boolean {
|
function isTwitter(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const { hostname } = new URL(url);
|
const { hostname } = new URL(url);
|
||||||
const h = hostname.replace(/^www\./, '');
|
return TWITTER_HOSTS.has(hostname.replace(/^www\./, ''));
|
||||||
return h === 'twitter.com' || h === 'x.com';
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -128,9 +135,8 @@ function isTwitter(url: string): boolean {
|
|||||||
function isTwitterTweet(url: string): boolean {
|
function isTwitterTweet(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
const h = hostname.replace(/^www\./, '');
|
if (!TWITTER_HOSTS.has(hostname.replace(/^www\./, ''))) return false;
|
||||||
if (h !== 'twitter.com' && h !== 'x.com') return false;
|
return /\/status(?:es)?\/\d+/.test(pathname);
|
||||||
return /\/status\/\d+/.test(pathname);
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -219,17 +225,6 @@ function isTwitch(url: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSteamApp(url: string): boolean {
|
|
||||||
try {
|
|
||||||
const { hostname, pathname } = new URL(url);
|
|
||||||
const h = hostname.replace(/^www\./, '');
|
|
||||||
if (h !== 'store.steampowered.com') return false;
|
|
||||||
return pathname.startsWith('/app/');
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWikipedia(url: string): boolean {
|
function isWikipedia(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const { hostname, pathname } = new URL(url);
|
const { hostname, pathname } = new URL(url);
|
||||||
@@ -310,6 +305,32 @@ function isTenor(url: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Synapse's thumbnailer flattens animated images to a single still frame, so a
|
||||||
|
// GIF served from /thumbnail renders but never plays. Detect GIF previews so the
|
||||||
|
// card can point at /download (the original) instead.
|
||||||
|
function isGifPreview(url: string, prev: IPreviewUrlResponse): boolean {
|
||||||
|
if (prev['og:image:type'] === 'image/gif') return true;
|
||||||
|
try {
|
||||||
|
return new URL(url).pathname.toLowerCase().endsWith('.gif');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ceiling on the /download upgrade below: a self-hosted GIF can be hundreds of
|
||||||
|
// MB, and unlike a thumbnail it is served unscaled. Past the cap we keep the
|
||||||
|
// (frozen) thumbnail — the card still links out, so the GIF is one click away.
|
||||||
|
const GIF_ORIGINAL_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Should this preview's image be fetched whole (so it animates) rather than
|
||||||
|
// thumbnailed? Size is advisory: Synapse usually reports it, and when it's
|
||||||
|
// absent we prefer a working animation over a hypothetical huge file.
|
||||||
|
function shouldServeGifOriginal(url: string, prev: IPreviewUrlResponse): boolean {
|
||||||
|
if (!isGifPreview(url, prev)) return false;
|
||||||
|
const size = prev['matrix:image:size'];
|
||||||
|
return typeof size !== 'number' || size <= GIF_ORIGINAL_MAX_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
function getCardVariant(url: string): CardVariant {
|
function getCardVariant(url: string): CardVariant {
|
||||||
// NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled
|
// NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled
|
||||||
// upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the
|
// upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the
|
||||||
@@ -320,7 +341,7 @@ function getCardVariant(url: string): CardVariant {
|
|||||||
if (getRedditSubreddit(url) !== null) return 'reddit';
|
if (getRedditSubreddit(url) !== null) return 'reddit';
|
||||||
if (getSpotifyType(url) !== null) return 'spotify';
|
if (getSpotifyType(url) !== null) return 'spotify';
|
||||||
if (isTwitch(url)) return 'twitch';
|
if (isTwitch(url)) return 'twitch';
|
||||||
if (isSteamApp(url)) return 'steam';
|
if (getSteamTarget(url)) return 'steam';
|
||||||
if (isWikipedia(url)) return 'wikipedia';
|
if (isWikipedia(url)) return 'wikipedia';
|
||||||
if (isDiscordInvite(url)) return 'discord';
|
if (isDiscordInvite(url)) return 'discord';
|
||||||
if (isNpm(url)) return 'npm';
|
if (isNpm(url)) return 'npm';
|
||||||
@@ -726,6 +747,7 @@ function TwitterCard({
|
|||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={() => setExpanded(false)}
|
onClick={() => setExpanded(false)}
|
||||||
aria-label="Collapse post"
|
aria-label="Collapse post"
|
||||||
>
|
>
|
||||||
@@ -780,6 +802,7 @@ function TwitterCard({
|
|||||||
<Chip
|
<Chip
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={() => setExpanded(true)}
|
onClick={() => setExpanded(true)}
|
||||||
before={<Icon size="50" src={Icons.Play} />}
|
before={<Icon size="50" src={Icons.Play} />}
|
||||||
>
|
>
|
||||||
@@ -1080,6 +1103,8 @@ const EMBED_BADGE: Record<string, { label: string; class: string }> = {
|
|||||||
bluesky: { label: 'Bluesky', class: previewCss.BadgeBluesky },
|
bluesky: { label: 'Bluesky', class: previewCss.BadgeBluesky },
|
||||||
loom: { label: 'Loom', class: previewCss.BadgeLoom },
|
loom: { label: 'Loom', class: previewCss.BadgeLoom },
|
||||||
kick: { label: 'Kick', class: previewCss.BadgeKick },
|
kick: { label: 'Kick', class: previewCss.BadgeKick },
|
||||||
|
mixcloud: { label: 'Mixcloud', class: previewCss.BadgeMixcloud },
|
||||||
|
deezer: { label: 'Deezer', class: previewCss.BadgeDeezer },
|
||||||
};
|
};
|
||||||
|
|
||||||
// The homeserver preview for some sites (notably Reddit) comes back as a bot-check
|
// The homeserver preview for some sites (notably Reddit) comes back as a bot-check
|
||||||
@@ -1249,6 +1274,7 @@ function MediaEmbedCard({
|
|||||||
<Chip
|
<Chip
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={enterFullscreen}
|
onClick={enterFullscreen}
|
||||||
aria-label="Fullscreen"
|
aria-label="Fullscreen"
|
||||||
>
|
>
|
||||||
@@ -1259,6 +1285,7 @@ function MediaEmbedCard({
|
|||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={() => setPlaying(false)}
|
onClick={() => setPlaying(false)}
|
||||||
aria-label="Close player"
|
aria-label="Close player"
|
||||||
>
|
>
|
||||||
@@ -1407,6 +1434,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
|||||||
<Chip
|
<Chip
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={enterFullscreen}
|
onClick={enterFullscreen}
|
||||||
aria-label="Fullscreen"
|
aria-label="Fullscreen"
|
||||||
>
|
>
|
||||||
@@ -1416,6 +1444,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
|||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={() => setPlaying(false)}
|
onClick={() => setPlaying(false)}
|
||||||
aria-label="Close player"
|
aria-label="Close player"
|
||||||
>
|
>
|
||||||
@@ -1492,9 +1521,17 @@ function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
const description = prev['og:description'] ?? '';
|
||||||
const artworkUrl = (prev['og:image'] as string | undefined) ?? '';
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
|
// Route through the homeserver like every other card — a raw og:image would
|
||||||
|
// be an mxc:// URI (broken <img>) on a standard HS, or an off-HS request that
|
||||||
|
// defeats the click-to-play facade on a nonstandard one.
|
||||||
|
const artworkUrl = mxcImage
|
||||||
|
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false)
|
||||||
|
: null;
|
||||||
const spotifyType = getSpotifyType(url) ?? 'track';
|
const spotifyType = getSpotifyType(url) ?? 'track';
|
||||||
const typeLabel = spotifyType.charAt(0).toUpperCase() + spotifyType.slice(1);
|
const typeLabel = spotifyType.charAt(0).toUpperCase() + spotifyType.slice(1);
|
||||||
|
|
||||||
@@ -1544,10 +1581,16 @@ function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
// Steam bundle/sub/dlc or other store page — OG card (no per-app widget).
|
||||||
|
function SteamStoreCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
const description = prev['og:description'] ?? '';
|
||||||
const thumbnailUrl = (prev['og:image'] as string | undefined) ?? '';
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
|
const thumbnailUrl = mxcImage
|
||||||
|
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 480, 270, 'scale', false)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1608,6 +1651,167 @@ function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Steam news / announcement post — rich OG card (Steam has no official embed
|
||||||
|
// widget for announcements): banner + headline + body preview.
|
||||||
|
function SteamNewsCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const title = prev['og:title'] ?? '';
|
||||||
|
const description = prev['og:description'] ?? '';
|
||||||
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
|
const bannerUrl = mxcImage
|
||||||
|
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 460, 215, 'scale', false)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" style={{ width: '100%' }}>
|
||||||
|
{bannerUrl && (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={previewCss.SteamBannerWrapper}
|
||||||
|
aria-label={`View on Steam: ${title}`}
|
||||||
|
>
|
||||||
|
<img className={previewCss.SteamBannerImg} src={bannerUrl} alt={title} loading="lazy" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<Box alignItems="Center" gap="100" wrap="Wrap">
|
||||||
|
<SiteBadge label="Steam" colorClass={previewCss.BadgeSteam} />
|
||||||
|
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
|
||||||
|
Announcement
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{title && (
|
||||||
|
<Text
|
||||||
|
priority="400"
|
||||||
|
style={{
|
||||||
|
fontWeight: 700,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
View on Steam
|
||||||
|
</Text>
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steam store app page — OG header + a click-to-play facade that loads Steam's
|
||||||
|
// official store-widget iframe (live, region-aware price / discount / Buy on
|
||||||
|
// Steam). Nothing loads from Steam until the user presses "Show price & store".
|
||||||
|
function SteamAppCard({
|
||||||
|
url,
|
||||||
|
prev,
|
||||||
|
appId,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
prev: IPreviewUrlResponse;
|
||||||
|
appId: string;
|
||||||
|
}) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
||||||
|
const [showWidget, setShowWidget] = useState(false);
|
||||||
|
const title = prev['og:title'] ?? '';
|
||||||
|
const description = prev['og:description'] ?? '';
|
||||||
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
|
const capsuleUrl = mxcImage
|
||||||
|
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 460, 215, 'scale', false)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" style={{ width: '100%' }}>
|
||||||
|
{capsuleUrl && (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={previewCss.SteamBannerWrapper}
|
||||||
|
aria-label={`View on Steam: ${title}`}
|
||||||
|
>
|
||||||
|
<img className={previewCss.SteamBannerImg} src={capsuleUrl} alt={title} loading="lazy" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<UrlPreviewContent>
|
||||||
|
<SiteBadge label="Steam" colorClass={previewCss.BadgeSteam} />
|
||||||
|
{title && (
|
||||||
|
<Text truncate priority="400">
|
||||||
|
<b>{title}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{showWidget ? (
|
||||||
|
<iframe
|
||||||
|
className={previewCss.SteamWidget}
|
||||||
|
src={steamWidgetEmbedUrl(appId)}
|
||||||
|
title={title ? `Steam store: ${title}` : 'Steam store widget'}
|
||||||
|
sandbox={EMBED_SANDBOX}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
|
||||||
|
<Text
|
||||||
|
style={linkStyles}
|
||||||
|
truncate
|
||||||
|
as="a"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
>
|
||||||
|
store.steampowered.com
|
||||||
|
</Text>
|
||||||
|
{inlineMediaEmbeds && (
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={() => setShowWidget(true)}
|
||||||
|
before={<Icon size="50" src={Icons.Setting} />}
|
||||||
|
>
|
||||||
|
<Text size="T200">Show price & store</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</UrlPreviewContent>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const target = getSteamTarget(url);
|
||||||
|
if (target?.kind === 'news') return <SteamNewsCard url={url} prev={prev} />;
|
||||||
|
if (target?.kind === 'app') return <SteamAppCard url={url} prev={prev} appId={target.appId} />;
|
||||||
|
return <SteamStoreCard url={url} prev={prev} />;
|
||||||
|
}
|
||||||
|
|
||||||
function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const rawDescription = prev['og:description'] ?? '';
|
const rawDescription = prev['og:description'] ?? '';
|
||||||
@@ -1664,9 +1868,14 @@ function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }
|
|||||||
|
|
||||||
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
const description = prev['og:description'] ?? '';
|
||||||
const iconUrl = (prev['og:image'] as string | undefined) ?? '';
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
|
const iconUrl = mxcImage
|
||||||
|
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1827,9 +2036,14 @@ function StackOverflowCard({ url, prev }: { url: string; prev: IPreviewUrlRespon
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
const title = prev['og:title'] ?? '';
|
const title = prev['og:title'] ?? '';
|
||||||
const description = prev['og:description'] ?? '';
|
const description = prev['og:description'] ?? '';
|
||||||
const posterUrl = (prev['og:image'] as string | undefined) ?? '';
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
|
const posterUrl = mxcImage
|
||||||
|
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 120, 180, 'scale', false)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1895,8 +2109,13 @@ function GifCard({
|
|||||||
const title = (prev['og:title'] as string | undefined) ?? '';
|
const title = (prev['og:title'] as string | undefined) ?? '';
|
||||||
const mxcImage = prev['og:image'] as string | undefined;
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
|
|
||||||
|
// A GIF card exists to show a moving GIF, so request the original rather than
|
||||||
|
// a thumbnail — the thumbnail endpoint would return a frozen first frame.
|
||||||
|
// `loading="lazy"` below keeps it off the wire until it's near the viewport.
|
||||||
const thumbSrc = mxcImage
|
const thumbSrc = mxcImage
|
||||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
|
? shouldServeGifOriginal(url, prev)
|
||||||
|
? mxcUrlToHttp(mx, mxcImage, useAuthentication)
|
||||||
|
: mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// If there's no image, fall back to a generic-style layout
|
// If there's no image, fall back to a generic-style layout
|
||||||
@@ -1994,6 +2213,7 @@ function GenericCard({
|
|||||||
src={displayThumb}
|
src={displayThumb}
|
||||||
alt={prev['og:title']}
|
alt={prev['og:title']}
|
||||||
title={prev['og:title']}
|
title={prev['og:title']}
|
||||||
|
loading="lazy"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
|
onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
|
||||||
onClick={onOpenViewer}
|
onClick={onOpenViewer}
|
||||||
@@ -2068,21 +2288,37 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
// Interactive embeds (players, tweets) render in a wider, responsive card so
|
// Interactive embeds (players, tweets) render in a wider, responsive card so
|
||||||
// player chrome / tweet content isn't cramped or clipped.
|
// player chrome / tweet content isn't cramped or clipped.
|
||||||
const embed = parseMediaEmbed(url, window.location.hostname);
|
const embed = parseMediaEmbed(url, window.location.hostname);
|
||||||
const wide = !!embed || isTwitterTweet(url);
|
// Short "copy-link" links carry no id, so the embed is only resolvable from
|
||||||
const cardClass = wide ? previewCss.UrlPreviewWide : undefined;
|
// the homeserver's canonical og:url. Resolve it here so `wide` reflects the
|
||||||
|
// ACTUALLY rendered card — an og:url-resolved MediaEmbedCard must still get
|
||||||
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
|
// the wide layout, not the cramped narrow one.
|
||||||
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
|
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
|
||||||
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play tile.
|
if (embed) return embed;
|
||||||
// Short "copy-link" share URLs (e.g. vm.tiktok.com, tiktok.com/t/…, youtu.be
|
|
||||||
// redirects) don't carry the id, so fall back to the canonical og:url that
|
|
||||||
// the homeserver already resolved when fetching the preview.
|
|
||||||
const ogUrl = prev['og:url'];
|
const ogUrl = prev['og:url'];
|
||||||
const resolvedEmbed =
|
return typeof ogUrl === 'string' && ogUrl !== url
|
||||||
embed ??
|
? parseMediaEmbed(ogUrl, window.location.hostname)
|
||||||
(typeof ogUrl === 'string' && ogUrl !== url
|
: null;
|
||||||
? parseMediaEmbed(ogUrl, window.location.hostname)
|
};
|
||||||
: null);
|
// A Steam app page renders the official ~646px store-widget iframe, so it
|
||||||
|
// needs the wide card too.
|
||||||
|
const steamAppWide = getSteamTarget(url)?.kind === 'app';
|
||||||
|
// Twitter/Twitch/TikTok(fallback) cards render header/thumbnail beside content
|
||||||
|
// in the card flex row; stack them on phones (no-op for the single-column
|
||||||
|
// embed cards). Desktop keeps the row layout.
|
||||||
|
const stackOnMobile = isTwitter(url) || isTwitch(url) || isTikTok(url);
|
||||||
|
const buildCardClass = (wide: boolean): string | undefined =>
|
||||||
|
[wide && previewCss.UrlPreviewWide, stackOnMobile && previewCss.StackOnMobile]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ') || undefined;
|
||||||
|
|
||||||
|
const renderContent = (
|
||||||
|
prev: IPreviewUrlResponse,
|
||||||
|
resolvedEmbed: MediaEmbed | null,
|
||||||
|
): React.ReactNode => {
|
||||||
|
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
|
||||||
|
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play
|
||||||
|
// tile. `resolvedEmbed` (computed by the caller via resolveEmbed) already
|
||||||
|
// folds in the og:url fallback for short "copy-link" share URLs.
|
||||||
if (resolvedEmbed) {
|
if (resolvedEmbed) {
|
||||||
return <MediaEmbedCard url={url} prev={prev} embed={resolvedEmbed} />;
|
return <MediaEmbedCard url={url} prev={prev} embed={resolvedEmbed} />;
|
||||||
}
|
}
|
||||||
@@ -2147,16 +2383,11 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
// Generic fallback — skip empty cards
|
// Generic fallback — skip empty cards
|
||||||
if (!prev['og:title'] && !prev['og:description']) return null;
|
if (!prev['og:title'] && !prev['og:description']) return null;
|
||||||
|
|
||||||
const thumbUrl = mxcUrlToHttp(
|
|
||||||
mx,
|
|
||||||
prev['og:image'] || '',
|
|
||||||
useAuthentication,
|
|
||||||
256,
|
|
||||||
256,
|
|
||||||
'scale',
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
|
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
|
||||||
|
// Show the original for GIFs so they animate; thumbnailing freezes them.
|
||||||
|
const thumbUrl = shouldServeGifOriginal(url, prev)
|
||||||
|
? imgUrl
|
||||||
|
: mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication, 256, 256, 'scale', false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GenericCard
|
<GenericCard
|
||||||
@@ -2175,17 +2406,27 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
|
|
||||||
// Don't render the card wrapper when content is empty (loaded but nothing to show)
|
// Don't render the card wrapper when content is empty (loaded but nothing to show)
|
||||||
if (previewStatus.status === AsyncStatus.Success) {
|
if (previewStatus.status === AsyncStatus.Success) {
|
||||||
const content = renderContent(previewStatus.data);
|
const prev = previewStatus.data;
|
||||||
|
const resolvedEmbed = resolveEmbed(prev);
|
||||||
|
const content = renderContent(prev, resolvedEmbed);
|
||||||
if (content === null) return null;
|
if (content === null) return null;
|
||||||
|
// `wide` follows the resolved embed (incl. the og:url fallback), so a short
|
||||||
|
// link that resolves to a player still gets the wide layout.
|
||||||
|
const wide = !!resolvedEmbed || isTwitterTweet(url) || steamAppWide;
|
||||||
return (
|
return (
|
||||||
<UrlPreview {...props} ref={ref} className={cardClass}>
|
<UrlPreview {...props} ref={ref} className={buildCardClass(wide)}>
|
||||||
{content}
|
{content}
|
||||||
</UrlPreview>
|
</UrlPreview>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loading/idle: no preview data yet, so base `wide` on the url-only embed.
|
||||||
return (
|
return (
|
||||||
<UrlPreview {...props} ref={ref} className={cardClass}>
|
<UrlPreview
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
className={buildCardClass(!!embed || isTwitterTweet(url) || steamAppWide)}
|
||||||
|
>
|
||||||
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
||||||
<Spinner variant="Secondary" size="400" />
|
<Spinner variant="Secondary" size="400" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
@@ -114,7 +114,7 @@ export function ServerChip({ server }: { server: string }) {
|
|||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.open(`https://${server}`, '_blank');
|
window.open(`https://${server}`, '_blank', 'noopener,noreferrer');
|
||||||
close();
|
close();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { UserAvatar } from '../user-avatar';
|
|||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css';
|
import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css';
|
||||||
|
import { ModalMobileFull } from '../../styles/Modal.css';
|
||||||
import { UserPresence } from '../../hooks/useUserPresence';
|
import { UserPresence } from '../../hooks/useUserPresence';
|
||||||
import { AvatarPresence, PresenceBadge } from '../presence';
|
import { AvatarPresence, PresenceBadge } from '../presence';
|
||||||
import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration';
|
import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration';
|
||||||
@@ -83,7 +84,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal size="500" onContextMenu={(evt: any) => evt.stopPropagation()}>
|
<Modal
|
||||||
|
size="500"
|
||||||
|
className={ModalMobileFull}
|
||||||
|
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||||
|
>
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
src={viewAvatar}
|
src={viewAvatar}
|
||||||
alt={userId}
|
alt={userId}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Box, Button, color, config, Icon, IconButton, Icons, Spinner, Text, toRem } from 'folds';
|
import { Box, Button, color, config, Icon, IconButton, Icons, Spinner, Text, toRem } from 'folds';
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
import { VerificationRequest } from 'matrix-js-sdk/lib/crypto-api';
|
import { VerificationRequest } from 'matrix-js-sdk/lib/crypto-api';
|
||||||
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
|
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
|
||||||
import { VerificationStatus } from '../../hooks/useDeviceVerificationStatus';
|
import { VerificationStatus } from '../../hooks/useDeviceVerificationStatus';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { StatusDivider } from './components';
|
|||||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { callEmbedAtom } from '../../state/callEmbed';
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
|
|
||||||
type MicrophoneButtonProps = {
|
type MicrophoneButtonProps = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -31,6 +32,7 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
|
|||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="300"
|
radii="300"
|
||||||
size="300"
|
size="300"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={toggleMic}
|
onClick={toggleMic}
|
||||||
outlined
|
outlined
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
@@ -66,6 +68,7 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
|
|||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="300"
|
radii="300"
|
||||||
size="300"
|
size="300"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={() => onToggle()}
|
onClick={() => onToggle()}
|
||||||
aria-label={enabled ? 'Deafen' : 'Undeafen'}
|
aria-label={enabled ? 'Deafen' : 'Undeafen'}
|
||||||
aria-pressed={enabled}
|
aria-pressed={enabled}
|
||||||
@@ -108,6 +111,7 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
|||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="300"
|
radii="300"
|
||||||
size="300"
|
size="300"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={toggleVideo}
|
onClick={toggleVideo}
|
||||||
aria-label={enabled ? 'Stop Video' : 'Start Video'}
|
aria-label={enabled ? 'Stop Video' : 'Start Video'}
|
||||||
aria-pressed={enabled}
|
aria-pressed={enabled}
|
||||||
@@ -147,6 +151,7 @@ function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonPro
|
|||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="300"
|
radii="300"
|
||||||
size="300"
|
size="300"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
|
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
|
||||||
aria-pressed={enabled}
|
aria-pressed={enabled}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { useSetting } from '../../state/hooks/settings';
|
|||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { callEmbedAtom } from '../../state/callEmbed';
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||||
|
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||||
@@ -51,18 +52,25 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
const controlRef = useRef<HTMLDivElement>(null);
|
const controlRef = useRef<HTMLDivElement>(null);
|
||||||
const callEmbedRef = useCallEmbedRef();
|
const callEmbedRef = useCallEmbedRef();
|
||||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
const [compact, setCompact] = useState(document.body.clientWidth < 500);
|
const screenSize = useScreenSize();
|
||||||
|
const [narrowContainer, setNarrowContainer] = useState(document.body.clientWidth < 500);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
useResizeObserver(
|
useResizeObserver(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
const element = controlRef.current;
|
const element = controlRef.current;
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
setCompact(element.clientWidth < 500);
|
setNarrowContainer(element.clientWidth < 500);
|
||||||
}, []),
|
}, []),
|
||||||
useCallback(() => controlRef.current, []),
|
useCallback(() => controlRef.current, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Collapse to the stacked/compact layout whenever the bar's own container is
|
||||||
|
// narrow (a small desktop call window) OR the viewport is a phone. The old
|
||||||
|
// element-only `< 500` check left the ~11-control row overflowing off-screen
|
||||||
|
// in the 500–750px band (landscape phones / small tablets).
|
||||||
|
const compact = narrowContainer || screenSize === ScreenSize.Mobile;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
|
const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
document.addEventListener('fullscreenchange', onFullscreenChange);
|
document.addEventListener('fullscreenchange', onFullscreenChange);
|
||||||
@@ -330,6 +338,9 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
padding: '1rem 1.25rem',
|
padding: '1rem 1.25rem',
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
minWidth: '260px',
|
minWidth: '260px',
|
||||||
|
// Don't run past the screen edges on a narrow phone (centered via
|
||||||
|
// translateX(-50%)); clamp to the viewport minus a small margin.
|
||||||
|
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
|
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -376,6 +387,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
radii="500"
|
radii="500"
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
justifyContent="SpaceBetween"
|
justifyContent="SpaceBetween"
|
||||||
|
wrap="Wrap"
|
||||||
>
|
>
|
||||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
|
|||||||
@@ -67,9 +67,11 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
// C-L6: the play() flow schedules a 30s safety timeout that clears playingKey;
|
// C-L6: the play() flow schedules a 30s safety timeout that clears playingKey;
|
||||||
// guard those setState calls against the component unmounting first.
|
// guard those setState calls against the component unmounting first.
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
|
const safetyTimerRef = useRef<number | undefined>(undefined);
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
|
if (safetyTimerRef.current !== undefined) window.clearTimeout(safetyTimerRef.current);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -96,7 +98,17 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
if (playingKey) return; // one at a time (fork also enforces this)
|
if (playingKey) return; // one at a time (fork also enforces this)
|
||||||
setPlayingKey(flat.key);
|
setPlayingKey(flat.key);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
// Per-play timer token: `done` clears its OWN timer by identity, so a
|
||||||
|
// stale done() from a prior clip can't disarm a newer clip's safety timer
|
||||||
|
// (which — since a rejected audio.play() fires neither ended nor error —
|
||||||
|
// is sometimes the only thing that unsticks the playingKey guard).
|
||||||
|
let myTimer: number | undefined;
|
||||||
const done = () => {
|
const done = () => {
|
||||||
|
if (myTimer !== undefined) {
|
||||||
|
window.clearTimeout(myTimer);
|
||||||
|
if (safetyTimerRef.current === myTimer) safetyTimerRef.current = undefined;
|
||||||
|
myTimer = undefined;
|
||||||
|
}
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
setPlayingKey((k) => (k === flat.key ? undefined : k));
|
setPlayingKey((k) => (k === flat.key ? undefined : k));
|
||||||
};
|
};
|
||||||
@@ -108,11 +120,12 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
if (audio) {
|
if (audio) {
|
||||||
audio.addEventListener('ended', done, { once: true });
|
audio.addEventListener('ended', done, { once: true });
|
||||||
audio.addEventListener('error', done, { once: true });
|
audio.addEventListener('error', done, { once: true });
|
||||||
|
// Safety: clear the guard even if the audio never signals end.
|
||||||
|
myTimer = window.setTimeout(done, 30_000);
|
||||||
|
safetyTimerRef.current = myTimer;
|
||||||
} else {
|
} else {
|
||||||
done();
|
done();
|
||||||
}
|
}
|
||||||
// Safety: clear the guard even if the audio never signals end.
|
|
||||||
window.setTimeout(done, 30_000);
|
|
||||||
} catch {
|
} catch {
|
||||||
setError('Could not play that clip.');
|
setError('Could not play that clip.');
|
||||||
done();
|
done();
|
||||||
@@ -135,7 +148,12 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu style={{ maxWidth: manage ? toRem(420) : toRem(340), maxHeight: '70vh' }}>
|
<Menu
|
||||||
|
style={{
|
||||||
|
maxWidth: `min(${manage ? toRem(420) : toRem(340)}, calc(100vw - 2 * ${config.space.S400}))`,
|
||||||
|
maxHeight: '70vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Box direction="Column" style={{ maxHeight: '70vh' }}>
|
<Box direction="Column" style={{ maxHeight: '70vh' }}>
|
||||||
<Box
|
<Box
|
||||||
shrink="No"
|
shrink="No"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds';
|
import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
import { callChatAtom } from '../../state/callEmbed';
|
import { callChatAtom } from '../../state/callEmbed';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
|
|||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="400"
|
radii="400"
|
||||||
size="400"
|
size="400"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={toggleMic}
|
onClick={toggleMic}
|
||||||
aria-label={enabled ? 'Turn Off Microphone' : 'Turn On Microphone'}
|
aria-label={enabled ? 'Turn Off Microphone' : 'Turn On Microphone'}
|
||||||
outlined
|
outlined
|
||||||
@@ -70,6 +72,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
|
|||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="400"
|
radii="400"
|
||||||
size="400"
|
size="400"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={() => onToggle()}
|
onClick={() => onToggle()}
|
||||||
aria-label={enabled ? 'Undeafen' : 'Deafen'}
|
aria-label={enabled ? 'Undeafen' : 'Deafen'}
|
||||||
outlined
|
outlined
|
||||||
@@ -113,6 +116,7 @@ export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
|||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="400"
|
radii="400"
|
||||||
size="400"
|
size="400"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={toggleVideo}
|
onClick={toggleVideo}
|
||||||
outlined
|
outlined
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
@@ -155,6 +159,7 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps)
|
|||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="400"
|
radii="400"
|
||||||
size="400"
|
size="400"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={() => onToggle()}
|
onClick={() => onToggle()}
|
||||||
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
|
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
|
||||||
outlined
|
outlined
|
||||||
@@ -200,6 +205,7 @@ export function FullscreenButton({ isFullscreen, onToggle }: FullscreenButtonPro
|
|||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="400"
|
radii="400"
|
||||||
size="400"
|
size="400"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
aria-label={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
aria-label={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
||||||
aria-pressed={isFullscreen}
|
aria-pressed={isFullscreen}
|
||||||
@@ -234,6 +240,7 @@ export function ScreenshareAudioButton({ muted, onToggle }: ScreenshareAudioButt
|
|||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="400"
|
radii="400"
|
||||||
size="400"
|
size="400"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
aria-label={muted ? 'Unmute Screenshare Audio' : 'Mute Screenshare Audio'}
|
aria-label={muted ? 'Unmute Screenshare Audio' : 'Mute Screenshare Audio'}
|
||||||
aria-pressed={muted}
|
aria-pressed={muted}
|
||||||
@@ -266,6 +273,7 @@ export function ChatButton() {
|
|||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="400"
|
radii="400"
|
||||||
size="400"
|
size="400"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={() => setChat(!chat)}
|
onClick={() => setChat(!chat)}
|
||||||
aria-label={chat ? 'Close Chat' : 'Open Chat'}
|
aria-label={chat ? 'Close Chat' : 'Open Chat'}
|
||||||
aria-pressed={chat}
|
aria-pressed={chat}
|
||||||
|
|||||||
@@ -17,15 +17,29 @@ function useMediaPermissions(): MediaPermState {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!navigator.permissions) {
|
if (!navigator.permissions) {
|
||||||
setState('unknown');
|
setState('unknown');
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
let permStatus: PermissionStatus | undefined;
|
||||||
navigator.permissions
|
navigator.permissions
|
||||||
.query({ name: 'microphone' as unknown as PermissionDescriptor['name'] })
|
.query({ name: 'microphone' as unknown as PermissionDescriptor['name'] })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
permStatus = result;
|
||||||
setState(result.state as MediaPermState);
|
setState(result.state as MediaPermState);
|
||||||
result.onchange = () => setState(result.state as MediaPermState);
|
result.onchange = () => {
|
||||||
|
if (!cancelled) setState(result.state as MediaPermState);
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.catch(() => setState('unknown'));
|
.catch(() => {
|
||||||
|
if (!cancelled) setState('unknown');
|
||||||
|
});
|
||||||
|
// Detach the onchange handler on unmount so it can't setState afterward (and
|
||||||
|
// so the PermissionStatus doesn't retain the callback).
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (permStatus) permStatus.onchange = null;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
|||||||
|
|
||||||
const submitAccountData: AccountDataSubmitCallback = useCallback(
|
const submitAccountData: AccountDataSubmitCallback = useCallback(
|
||||||
async (type, content) => {
|
async (type, content) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
await mx.setRoomAccountData(room.roomId, type as any, content);
|
await mx.setRoomAccountData(room.roomId, type as any, content);
|
||||||
},
|
},
|
||||||
[mx, room.roomId],
|
[mx, room.roomId],
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ export function RoomQuality({ permissions }: RoomQualityProps) {
|
|||||||
const [submitState, submit] = useAsyncCallback(
|
const [submitState, submit] = useAsyncCallback(
|
||||||
useCallback(
|
useCallback(
|
||||||
async (next: RoomQualityContent) => {
|
async (next: RoomQualityContent) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
await sendStateEvent(mx, room.roomId, StateEvent.LotusRoomQuality, next);
|
await sendStateEvent(mx, room.roomId, StateEvent.LotusRoomQuality, next);
|
||||||
},
|
},
|
||||||
[mx, room.roomId],
|
[mx, room.roomId],
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export function RoomRetention({ permissions }: RoomRetentionProps) {
|
|||||||
const content: RetentionContent = ms > 0 ? { max_lifetime: ms } : {};
|
const content: RetentionContent = ms > 0 ? { max_lifetime: ms } : {};
|
||||||
// Lotus custom-state convention: cast the type key (RoomRetention isn't a
|
// Lotus custom-state convention: cast the type key (RoomRetention isn't a
|
||||||
// typed key in the SDK's StateEvents map).
|
// 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);
|
await sendStateEvent(mx, room.roomId, StateEvent.RoomRetention, content);
|
||||||
},
|
},
|
||||||
[mx, room.roomId],
|
[mx, room.roomId],
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
|
|||||||
return (
|
return (
|
||||||
<Box onSubmit={handleSubmit} as="form" direction="Column" gap="400">
|
<Box onSubmit={handleSubmit} as="form" direction="Column" gap="400">
|
||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
<Box gap="200">
|
<Box gap="200" wrap="Wrap">
|
||||||
<Box shrink="No" direction="Column" gap="100">
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
<Text size="L400">Color</Text>
|
<Text size="L400">Color</Text>
|
||||||
<Box gap="200">
|
<Box gap="200">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Box, Button, color, config, Icon, Icons, Input, Spinner, Switch, Text } from 'folds';
|
import { Box, Button, color, config, Icon, Icons, Input, Spinner, Switch, Text } from 'folds';
|
||||||
import React, { FormEventHandler, useCallback, useState } from 'react';
|
import React, { FormEventHandler, useCallback, useState } from 'react';
|
||||||
import { ICreateRoomStateEvent, MatrixError, Preset, Visibility } from 'matrix-js-sdk';
|
import { ICreateRoomStateEvent, MatrixError, Preset, Visibility } from 'matrix-js-sdk';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
import { SettingTile } from '../../components/setting-tile';
|
import { SettingTile } from '../../components/setting-tile';
|
||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import { addRoomIdToMDirect, isUserId } from '../../utils/matrix';
|
import { addRoomIdToMDirect, isUserId } from '../../utils/matrix';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from
|
|||||||
import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config } from 'folds';
|
import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config } from 'folds';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
|
import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
|
||||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
|
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router';
|
||||||
import { EventTimeline, EventType, Room, SearchOrderBy } from 'matrix-js-sdk';
|
import { EventTimeline, EventType, Room, SearchOrderBy } from 'matrix-js-sdk';
|
||||||
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
import { PageHero, PageHeroEmpty, PageHeroSection } from '../../components/page';
|
import { PageHero, PageHeroEmpty, PageHeroSection } from '../../components/page';
|
||||||
|
|||||||
@@ -152,7 +152,10 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
|||||||
getRoomNameStr,
|
getRoomNameStr,
|
||||||
SEARCH_OPTS,
|
SEARCH_OPTS,
|
||||||
);
|
);
|
||||||
const rooms = Array.from(searchResult?.items ?? roomList).sort(factoryRoomIdByAtoZ(mx));
|
const rooms = useMemo(
|
||||||
|
() => Array.from(searchResult?.items ?? roomList).sort(factoryRoomIdByAtoZ(mx)),
|
||||||
|
[searchResult, roomList, mx],
|
||||||
|
);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: rooms.length,
|
count: rooms.length,
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ const extractText = (event: MatrixEvent): ExtractedText | null => {
|
|||||||
const content = event.getContent();
|
const content = event.getContent();
|
||||||
|
|
||||||
if (POLL_START_TYPES.includes(evType)) {
|
if (POLL_START_TYPES.includes(evType)) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as any;
|
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as any;
|
||||||
if (!poll) return null;
|
if (!poll) return null;
|
||||||
const qBody =
|
const qBody =
|
||||||
@@ -57,7 +56,6 @@ const extractText = (event: MatrixEvent): ExtractedText | null => {
|
|||||||
.map(
|
.map(
|
||||||
(a) =>
|
(a) =>
|
||||||
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
|
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
|
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
|
||||||
'') as string,
|
'') as string,
|
||||||
)
|
)
|
||||||
@@ -104,7 +102,6 @@ const rowToResultItem = (row: SearchCacheRow): ResultItem => {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
rank: 0,
|
rank: 0,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
event: syntheticEvent as any,
|
event: syntheticEvent as any,
|
||||||
context: { events_before: [], events_after: [], profile_info: {} },
|
context: { events_before: [], events_after: [], profile_info: {} },
|
||||||
};
|
};
|
||||||
@@ -227,7 +224,6 @@ export const useLocalMessageSearch = () => {
|
|||||||
};
|
};
|
||||||
memoryItems.push({
|
memoryItems.push({
|
||||||
rank: 0,
|
rank: 0,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
event: syntheticEvent as any,
|
event: syntheticEvent as any,
|
||||||
context: { events_before: [], events_after: [], profile_info: {} },
|
context: { events_before: [], events_after: [], profile_info: {} },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -146,7 +146,6 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
|||||||
...(fromTs !== undefined && { from_ts: fromTs }),
|
...(fromTs !== undefined && { from_ts: fromTs }),
|
||||||
...(toTs !== undefined && { to_ts: toTs }),
|
...(toTs !== undefined && { to_ts: toTs }),
|
||||||
...(containsUrl !== undefined && { contains_url: containsUrl }),
|
...(containsUrl !== undefined && { contains_url: containsUrl }),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any,
|
} as any,
|
||||||
include_state: false,
|
include_state: false,
|
||||||
order_by: order as SearchOrderBy.Recent,
|
order_by: order as SearchOrderBy.Recent,
|
||||||
|
|||||||
@@ -698,7 +698,9 @@ function RoomNavItem_({
|
|||||||
);
|
);
|
||||||
const hasDraft = useAtomValue(hasDraftAtom);
|
const hasDraft = useAtomValue(hasDraftAtom);
|
||||||
|
|
||||||
const latestEvent = useRoomLatestRenderedEvent(room);
|
// Only DM rows render this preview — pass `direct` so non-DM nav items don't
|
||||||
|
// register the global decryption listener (PERF-5).
|
||||||
|
const latestEvent = useRoomLatestRenderedEvent(room, !!direct);
|
||||||
const dmPreview = (() => {
|
const dmPreview = (() => {
|
||||||
if (!direct || !latestEvent) return null;
|
if (!direct || !latestEvent) return null;
|
||||||
const type = latestEvent.getType();
|
const type = latestEvent.getType();
|
||||||
|
|||||||
@@ -92,6 +92,16 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
const evId = ev.getId();
|
const evId = ev.getId();
|
||||||
if (!evId || seen.has(evId)) continue;
|
if (!evId || seen.has(evId)) continue;
|
||||||
seen.add(evId);
|
seen.add(evId);
|
||||||
|
// Advance the raw pagination boundary for EVERY event (any type,
|
||||||
|
// decrypted or not) — getTs() is unencrypted metadata. Gating this on
|
||||||
|
// a decrypted m.room.message let undecryptable/non-message old events
|
||||||
|
// stall oldestRawTs, so the fromTs break never fired → over-paginate
|
||||||
|
// and a false "truncated".
|
||||||
|
const ts = ev.getTs();
|
||||||
|
// Require a positive ts: an event with a bogus 0/negative
|
||||||
|
// origin_server_ts must not collapse the boundary and trigger an early
|
||||||
|
// break (silent under-pagination in the export).
|
||||||
|
if (ts > 0 && ts < oldestRawTs) oldestRawTs = ts;
|
||||||
// Attempt decryption for events that haven't been decrypted yet
|
// Attempt decryption for events that haven't been decrypted yet
|
||||||
// (paginateEventTimeline may fetch events before the SDK decrypts them)
|
// (paginateEventTimeline may fetch events before the SDK decrypts them)
|
||||||
if (ev.isEncrypted() && !ev.getClearContent()) {
|
if (ev.isEncrypted() && !ev.getClearContent()) {
|
||||||
@@ -100,8 +110,6 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
}
|
}
|
||||||
if (ev.getType() !== EventType.RoomMessage) continue;
|
if (ev.getType() !== EventType.RoomMessage) continue;
|
||||||
if (ev.isDecryptionFailure()) continue;
|
if (ev.isDecryptionFailure()) continue;
|
||||||
const ts = ev.getTs();
|
|
||||||
if (ts < oldestRawTs) oldestRawTs = ts;
|
|
||||||
if (fromTs !== null && ts < fromTs) continue;
|
if (fromTs !== null && ts < fromTs) continue;
|
||||||
if (toTs !== null && ts > toTs) continue;
|
if (toTs !== null && ts > toTs) continue;
|
||||||
const content = ev.getContent();
|
const content = ev.getContent();
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
|
|||||||
gap="300"
|
gap="300"
|
||||||
>
|
>
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Box gap="200">
|
<Box gap="200" wrap="Wrap">
|
||||||
<TabButton
|
<TabButton
|
||||||
label="Users"
|
label="Users"
|
||||||
count={userEntries.length}
|
count={userEntries.length}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { isValidServerPattern, matchesAnyGlob } from '../../utils/serverAcl';
|
||||||
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -48,57 +50,6 @@ const DEFAULT_ACL: ServerAclContent = {
|
|||||||
allow_ip_literals: false,
|
allow_ip_literals: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Validation ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a server-name glob for an ACL entry.
|
|
||||||
*
|
|
||||||
* Matrix ACL `allow`/`deny` entries are globs where `*` (any run of chars) and
|
|
||||||
* `?` (single char) may appear ANYWHERE — e.g. `*`, `*.example.com`,
|
|
||||||
* `1.2.3.*`, `10.0.0.?`, `*.evil.*`, `*bad*`. We therefore validate the *glob*
|
|
||||||
* rather than a concrete hostname:
|
|
||||||
* - reject empty / whitespace-only
|
|
||||||
* - allow only hostname/IP chars plus the wildcards `*` and `?`
|
|
||||||
* (letters, digits, dots, hyphens, colons for ports/IPv6 — NO underscore)
|
|
||||||
* - reject consecutive/leading/trailing dots (`...`, `.foo`, `foo.`)
|
|
||||||
* - reject entries with no alphanumeric or wildcard char (bare `-`, lone `:`)
|
|
||||||
*/
|
|
||||||
function isValidServerPattern(value: string): boolean {
|
|
||||||
const v = value.trim();
|
|
||||||
if (!v) return false;
|
|
||||||
// Only hostname/IP glob chars — wildcards may appear at any position.
|
|
||||||
if (!/^[A-Za-z0-9.:*?-]+$/.test(v)) return false;
|
|
||||||
// Structural rules for the dotted parts.
|
|
||||||
if (v.startsWith('.') || v.endsWith('.') || v.includes('..')) return false;
|
|
||||||
// Must carry actual signal — reject pure punctuation like `-`, `:` or `-.-`.
|
|
||||||
if (!/[A-Za-z0-9*?]/.test(v)) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert an ACL glob (`*` = any run, `?` = single char) to an anchored RegExp,
|
|
||||||
* escaping every other regex metacharacter. Used only for local self-ban
|
|
||||||
* detection — never sent to the server.
|
|
||||||
*/
|
|
||||||
function globToRegExp(glob: string): RegExp {
|
|
||||||
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
||||||
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
||||||
// Case-INsensitive: Synapse's glob_to_regex uses IGNORECASE and hostnames are
|
|
||||||
// case-insensitive, so a deny like `MATRIX.foo.org` must still be detected as
|
|
||||||
// self-banning `matrix.foo.org` (otherwise the warning is a false negative).
|
|
||||||
return new RegExp(`^${pattern}$`, 'i');
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesAnyGlob(domain: string, globs: string[]): boolean {
|
|
||||||
return globs.some((glob) => {
|
|
||||||
try {
|
|
||||||
return globToRegExp(glob).test(domain);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Server list sub-component ─────────────────────────────────────────────────
|
// ── Server list sub-component ─────────────────────────────────────────────────
|
||||||
|
|
||||||
type ServerListProps = {
|
type ServerListProps = {
|
||||||
@@ -198,6 +149,7 @@ function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProp
|
|||||||
size="300"
|
size="300"
|
||||||
variant="Background"
|
variant="Background"
|
||||||
radii="300"
|
radii="300"
|
||||||
|
className={MobileTouchTarget}
|
||||||
aria-label={`Remove ${entry}`}
|
aria-label={`Remove ${entry}`}
|
||||||
onClick={() => onRemove(i)}
|
onClick={() => onRemove(i)}
|
||||||
style={{ flexShrink: 0 }}
|
style={{ flexShrink: 0 }}
|
||||||
@@ -389,7 +341,6 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
|||||||
variant="Primary"
|
variant="Primary"
|
||||||
/>
|
/>
|
||||||
<Box direction="Column" gap="0">
|
<Box direction="Column" gap="0">
|
||||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
|
||||||
<label
|
<label
|
||||||
htmlFor="allow-ip-literals"
|
htmlFor="allow-ip-literals"
|
||||||
style={{ cursor: canEdit ? 'pointer' : 'default' }}
|
style={{ cursor: canEdit ? 'pointer' : 'default' }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router';
|
||||||
import { Box, Text, TooltipProvider, Tooltip, Icon, Icons, IconButton, toRem } from 'folds';
|
import { Box, Text, TooltipProvider, Tooltip, Icon, Icons, IconButton, toRem } from 'folds';
|
||||||
import { Page, PageHeader } from '../../components/page';
|
import { Page, PageHeader } from '../../components/page';
|
||||||
import { callChatAtom } from '../../state/callEmbed';
|
import { callChatAtom } from '../../state/callEmbed';
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ function LightboxMedia({
|
|||||||
pan,
|
pan,
|
||||||
cursor,
|
cursor,
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
|
onTouchStart,
|
||||||
onImageDoubleClick,
|
onImageDoubleClick,
|
||||||
}: {
|
}: {
|
||||||
item: LightboxItem;
|
item: LightboxItem;
|
||||||
@@ -198,6 +199,7 @@ function LightboxMedia({
|
|||||||
pan: Pan;
|
pan: Pan;
|
||||||
cursor: string;
|
cursor: string;
|
||||||
onMouseDown: React.MouseEventHandler<HTMLElement>;
|
onMouseDown: React.MouseEventHandler<HTMLElement>;
|
||||||
|
onTouchStart: React.TouchEventHandler<HTMLElement>;
|
||||||
onImageDoubleClick: () => void;
|
onImageDoubleClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -253,6 +255,7 @@ function LightboxMedia({
|
|||||||
alt={item.body}
|
alt={item.body}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
onDoubleClick={onImageDoubleClick}
|
onDoubleClick={onImageDoubleClick}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
@@ -297,7 +300,7 @@ function Lightbox({
|
|||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||||
// Pan is only active for a zoomed-in image; usePan resets its offset when this
|
// Pan is only active for a zoomed-in image; usePan resets its offset when this
|
||||||
// flips false (i.e. back to 1x, on navigation, or on a video).
|
// flips false (i.e. back to 1x, on navigation, or on a video).
|
||||||
const { pan, cursor, onMouseDown } = usePan(isImage && zoom !== 1);
|
const { pan, cursor, onMouseDown, onTouchStart } = usePan(isImage && zoom !== 1);
|
||||||
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
|
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
|
||||||
|
|
||||||
// Reset zoom when navigating to another item (and thus pan, via usePan).
|
// Reset zoom when navigating to another item (and thus pan, via usePan).
|
||||||
@@ -512,6 +515,7 @@ function Lightbox({
|
|||||||
pan={pan}
|
pan={pan}
|
||||||
cursor={cursor}
|
cursor={cursor}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
onImageDoubleClick={toggleZoom}
|
onImageDoubleClick={toggleZoom}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
import { Membership } from '../../../types/matrix/room';
|
import { Membership } from '../../../types/matrix/room';
|
||||||
|
|
||||||
import * as css from './MembersDrawer.css';
|
import * as css from './MembersDrawer.css';
|
||||||
@@ -460,6 +461,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||||||
variant="Success"
|
variant="Success"
|
||||||
radii="300"
|
radii="300"
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={() => mx.invite(room.roomId, knockMember.userId)}
|
onClick={() => mx.invite(room.roomId, knockMember.userId)}
|
||||||
>
|
>
|
||||||
<Text size="B300">Approve</Text>
|
<Text size="B300">Approve</Text>
|
||||||
@@ -469,6 +471,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||||||
variant="Critical"
|
variant="Critical"
|
||||||
radii="300"
|
radii="300"
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={() => mx.kick(room.roomId, knockMember.userId)}
|
onClick={() => mx.kick(room.roomId, knockMember.userId)}
|
||||||
>
|
>
|
||||||
<Text size="B300">Deny</Text>
|
<Text size="B300">Deny</Text>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
import { Box, Line } from 'folds';
|
import { Box, Line } from 'folds';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { RoomView } from './RoomView';
|
import { RoomView } from './RoomView';
|
||||||
|
|||||||
@@ -230,7 +230,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
|
|
||||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||||
const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
|
const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
|
||||||
const touchTarget = mobileOrTablet() ? { minWidth: '44px', minHeight: '44px' } : undefined;
|
const isMobile = mobileOrTablet();
|
||||||
|
// On phones the composer's secondary action buttons (attach, GIF, poll,
|
||||||
|
// location, voice, formatting, schedule) collapse behind a "+" toggle so the
|
||||||
|
// input stays one compact row instead of wrapping into a tall stack. Emoji +
|
||||||
|
// Send remain inline. Desktop keeps everything inline (isMobile === false).
|
||||||
|
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
|
||||||
|
const touchTarget = isMobile ? { minWidth: '44px', minHeight: '44px' } : undefined;
|
||||||
const showFormat = composerToolbarButtons?.showFormat ?? true;
|
const showFormat = composerToolbarButtons?.showFormat ?? true;
|
||||||
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
||||||
const showSticker = composerToolbarButtons?.showSticker ?? true;
|
const showSticker = composerToolbarButtons?.showSticker ?? true;
|
||||||
@@ -876,6 +882,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile "+" overflow: the `after` builder stashes the collapsed secondary
|
||||||
|
// buttons here and the `bottom` slot renders them when the toggle is open.
|
||||||
|
// React evaluates JSX props in source order (before → after → bottom), so
|
||||||
|
// `after` assigns this before `bottom` reads it within the same render.
|
||||||
|
let composerOverflow: ReactNode = null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
{selectedFiles.length > 0 && (
|
{selectedFiles.length > 0 && (
|
||||||
@@ -1035,16 +1047,31 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
before={
|
before={
|
||||||
<IconButton
|
isMobile ? (
|
||||||
onClick={() => pickFile('*')}
|
<IconButton
|
||||||
aria-label="Attach file"
|
onClick={() => setMobileToolsOpen((open) => !open)}
|
||||||
variant="SurfaceVariant"
|
aria-label="More actions"
|
||||||
size="300"
|
aria-expanded={mobileToolsOpen}
|
||||||
radii="300"
|
aria-controls="composer-more-actions"
|
||||||
style={touchTarget}
|
variant="SurfaceVariant"
|
||||||
>
|
size="300"
|
||||||
<Icon src={Icons.PlusCircle} />
|
radii="300"
|
||||||
</IconButton>
|
style={touchTarget}
|
||||||
|
>
|
||||||
|
<Icon src={mobileToolsOpen ? Icons.Cross : Icons.Plus} />
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => pickFile('*')}
|
||||||
|
aria-label="Attach file"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
style={touchTarget}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.PlusCircle} />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
after={(() => {
|
after={(() => {
|
||||||
const formatButton = showFormat ? (
|
const formatButton = showFormat ? (
|
||||||
@@ -1306,9 +1333,37 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mobile: keep only emoji/sticker inline beside Send; the rest move
|
||||||
|
// into the "+" overflow row (rendered via `bottom`), led by the attach
|
||||||
|
// button that `before` gives up on mobile. Desktop renders all inline.
|
||||||
|
const emojiInline = orderedButtons.filter(
|
||||||
|
(node) => React.isValidElement(node) && node.key === 'showEmojiSticker',
|
||||||
|
);
|
||||||
|
const overflowButtons = orderedButtons.filter(
|
||||||
|
(node) => !(React.isValidElement(node) && node.key === 'showEmojiSticker'),
|
||||||
|
);
|
||||||
|
if (isMobile) {
|
||||||
|
composerOverflow = (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
key="showAttach"
|
||||||
|
onClick={() => pickFile('*')}
|
||||||
|
aria-label="Attach file"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
style={touchTarget}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.PlusCircle} />
|
||||||
|
</IconButton>
|
||||||
|
{overflowButtons}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{orderedButtons}
|
{isMobile ? emojiInline : orderedButtons}
|
||||||
{gifError && (
|
{gifError && (
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
@@ -1365,12 +1420,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
bottom={
|
bottom={
|
||||||
toolbar && (
|
<>
|
||||||
<div>
|
{isMobile && mobileToolsOpen && composerOverflow && (
|
||||||
<Line variant="SurfaceVariant" size="300" />
|
<div>
|
||||||
<Toolbar />
|
<Line variant="SurfaceVariant" size="300" />
|
||||||
</div>
|
<Box
|
||||||
)
|
id="composer-more-actions"
|
||||||
|
role="group"
|
||||||
|
aria-label="More actions"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
wrap="Wrap"
|
||||||
|
style={{ padding: config.space.S200 }}
|
||||||
|
>
|
||||||
|
{composerOverflow}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{toolbar && (
|
||||||
|
<div>
|
||||||
|
<Line variant="SurfaceVariant" size="300" />
|
||||||
|
<Toolbar />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
|
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useCallback, useMemo, useRef } from 'react';
|
import React, { useCallback, useMemo, useRef } from 'react';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import { Box, Text, config } from 'folds';
|
import { Box, Button, Text, config } from 'folds';
|
||||||
import { EventType } from 'matrix-js-sdk';
|
import { EventType } from 'matrix-js-sdk';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
|
import { Transforms } from 'slate';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
@@ -152,17 +153,38 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||||||
<>
|
<>
|
||||||
{canMessage && (
|
{canMessage && (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={
|
onReset={() => {
|
||||||
|
// The composer crash is a transient bad-selection render
|
||||||
|
// (e.g. after an autocomplete insert); the draft content is
|
||||||
|
// intact. Clear the selection so the remounted composer can
|
||||||
|
// render — the user clicks in to continue, no page refresh.
|
||||||
|
try {
|
||||||
|
Transforms.deselect(editor);
|
||||||
|
} catch {
|
||||||
|
/* editor already in a safe state */
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fallbackRender={({ resetErrorBoundary }) => (
|
||||||
<RoomInputPlaceholder
|
<RoomInputPlaceholder
|
||||||
|
role="alert"
|
||||||
style={{ padding: config.space.S200 }}
|
style={{ padding: config.space.S200 }}
|
||||||
|
direction="Column"
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
justifyContent="Center"
|
justifyContent="Center"
|
||||||
|
gap="200"
|
||||||
>
|
>
|
||||||
<Text align="Center">
|
<Text align="Center">The message composer hit a snag.</Text>
|
||||||
Message composer encountered an error. Try refreshing.
|
<Button
|
||||||
</Text>
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
onClick={resetErrorBoundary}
|
||||||
|
>
|
||||||
|
<Text size="B300">Reload composer</Text>
|
||||||
|
</Button>
|
||||||
</RoomInputPlaceholder>
|
</RoomInputPlaceholder>
|
||||||
}
|
)}
|
||||||
>
|
>
|
||||||
<RoomInput
|
<RoomInput
|
||||||
room={room}
|
room={room}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
|
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
|
||||||
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
|
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
|
||||||
import { EventReaders } from '../../components/event-readers';
|
import { EventReaders } from '../../components/event-readers';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
export function RoomViewFollowingPlaceholder() {
|
export function RoomViewFollowingPlaceholder() {
|
||||||
@@ -34,6 +35,7 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
|
|||||||
({ className, room, ...props }, ref) => {
|
({ className, room, ...props }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const modalStyle = useModalStyle(360);
|
||||||
const latestEvent = useRoomLatestRenderedEvent(room);
|
const latestEvent = useRoomLatestRenderedEvent(room);
|
||||||
const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId());
|
const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId());
|
||||||
const names = latestEventReaders
|
const names = latestEventReaders
|
||||||
@@ -55,7 +57,7 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal variant="Surface" size="300">
|
<Modal variant="Surface" size="300" style={modalStyle}>
|
||||||
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
|
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
import { PageHeader } from '../../components/page';
|
import { PageHeader } from '../../components/page';
|
||||||
|
|||||||
@@ -318,7 +318,6 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
ids.map((id) => {
|
ids.map((id) => {
|
||||||
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
// 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, fwdContent);
|
||||||
// Send the optional comment first so it reads as a note above the
|
// Send the optional comment first so it reads as a note above the
|
||||||
// forwarded content. The room counts as failed if either send rejects.
|
// forwarded content. The room counts as failed if either send rejects.
|
||||||
@@ -327,7 +326,6 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
const needsComment = commentBody && !commentSentRef.current.has(id);
|
const needsComment = commentBody && !commentSentRef.current.has(id);
|
||||||
const step = needsComment
|
const step = needsComment
|
||||||
? mx
|
? mx
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
.sendMessage(id, null, { msgtype: MsgType.Text, body: commentBody } as any)
|
.sendMessage(id, null, { msgtype: MsgType.Text, body: commentBody } as any)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
commentSentRef.current.add(id);
|
commentSentRef.current.add(id);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { useHover, useFocusWithin } from 'react-aria';
|
|||||||
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
|
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
|
||||||
import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
import {
|
import {
|
||||||
AvatarBase,
|
AvatarBase,
|
||||||
@@ -61,7 +62,10 @@ import {
|
|||||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { messageAriaLabel } from '../../../utils/a11y';
|
import { messageAriaLabel } from '../../../utils/a11y';
|
||||||
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||||
|
import { msgTranslationActiveAtomFamily } from '../../../state/translation';
|
||||||
|
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { MsgAppearClass, SendingSpinClass } from '../../../styles/Animations.css';
|
import { MsgAppearClass, SendingSpinClass } from '../../../styles/Animations.css';
|
||||||
@@ -257,6 +261,7 @@ export const MessageReadReceiptItem = as<
|
|||||||
}
|
}
|
||||||
>(({ room, eventId, onClose, ...props }, ref) => {
|
>(({ room, eventId, onClose, ...props }, ref) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const modalStyle = useModalStyle(360);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -275,7 +280,7 @@ export const MessageReadReceiptItem = as<
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal variant="Surface" size="300">
|
<Modal variant="Surface" size="300" style={modalStyle}>
|
||||||
<EventReaders room={room} eventId={eventId} requestClose={handleClose} />
|
<EventReaders room={room} eventId={eventId} requestClose={handleClose} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
@@ -453,6 +458,47 @@ export const MessageCopyTextItem = as<
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const MessageTranslateItem = as<
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
mEvent: MatrixEvent;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
>(({ mEvent, onClose, ...props }, ref) => {
|
||||||
|
const content = mEvent.getContent();
|
||||||
|
const msgtype = content.msgtype;
|
||||||
|
const isTextual = msgtype === 'm.text' || msgtype === 'm.emote' || msgtype === 'm.notice';
|
||||||
|
const rawBody = typeof content.body === 'string' ? content.body : '';
|
||||||
|
const body = trimReplyFromBody(rawBody).trim();
|
||||||
|
const eventId = mEvent.getId() ?? '';
|
||||||
|
|
||||||
|
const [active, setActive] = useAtom(msgTranslationActiveAtomFamily(eventId));
|
||||||
|
|
||||||
|
// On-device translation is Chromium-desktop only; hide the action entirely
|
||||||
|
// where the engine can't run, and for non-textual/empty messages.
|
||||||
|
if (!chromeTranslationEngine.isSupported() || !isTextual || !body || !eventId) return null;
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
setActive((a) => !a);
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.Globe} />}
|
||||||
|
radii="300"
|
||||||
|
onClick={handleToggle}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||||
|
{active ? 'Show Original' : 'Translate'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export const MessagePinItem = as<
|
export const MessagePinItem = as<
|
||||||
'button',
|
'button',
|
||||||
{
|
{
|
||||||
@@ -630,6 +676,7 @@ export const MessageReportItem = as<
|
|||||||
>(({ room, mEvent, onClose, ...props }, ref) => {
|
>(({ room, mEvent, onClose, ...props }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
const [reportState, reportMessage] = useAsyncCallback(
|
const [reportState, reportMessage] = useAsyncCallback(
|
||||||
useCallback(
|
useCallback(
|
||||||
(eventId: string, score: number, reason: string) =>
|
(eventId: string, score: number, reason: string) =>
|
||||||
@@ -671,7 +718,7 @@ export const MessageReportItem = as<
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog variant="Surface">
|
<Dialog variant="Surface" style={modalStyle}>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
@@ -1327,6 +1374,7 @@ export const Message = React.memo(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
|
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
|
||||||
|
<MessageTranslateItem mEvent={mEvent} onClose={closeMenu} />
|
||||||
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
{canPinEvent && (
|
{canPinEvent && (
|
||||||
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
@@ -1342,7 +1390,6 @@ export const Message = React.memo(
|
|||||||
after={<Icon size="100" src={Icons.Send} />}
|
after={<Icon size="100" src={Icons.Send} />}
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(mx as any).resendEvent(mEvent, room);
|
(mx as any).resendEvent(mEvent, room);
|
||||||
closeMenu();
|
closeMenu();
|
||||||
}}
|
}}
|
||||||
@@ -1361,7 +1408,6 @@ export const Message = React.memo(
|
|||||||
after={<Icon size="100" src={Icons.Cross} />}
|
after={<Icon size="100" src={Icons.Cross} />}
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(mx as any).cancelPendingEvent(mEvent);
|
(mx as any).cancelPendingEvent(mEvent);
|
||||||
closeMenu();
|
closeMenu();
|
||||||
}}
|
}}
|
||||||
@@ -1557,6 +1603,7 @@ export const Event = React.memo(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
|
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
|
||||||
|
<MessageTranslateItem mEvent={mEvent} onClose={closeMenu} />
|
||||||
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
</Box>
|
</Box>
|
||||||
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
|
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
|
||||||
|
|||||||
@@ -187,7 +187,6 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
rel_type: RelationType.Replace,
|
rel_type: RelationType.Replace,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return mx.sendMessage(roomId, content as any);
|
return mx.sendMessage(roomId, content as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +235,6 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return mx.sendMessage(roomId, content as any);
|
return mx.sendMessage(roomId, content as any);
|
||||||
}, [
|
}, [
|
||||||
mx,
|
mx,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Badge, Box, Chip, Icon, Icons, Text, config } from 'folds';
|
import { Badge, Box, Chip, Icon, Icons, Text, config } from 'folds';
|
||||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||||
|
import { MobileTouchTarget } from '../../../styles/mobile.css';
|
||||||
import { useThreadSummary } from '../../../hooks/useThreadSummary';
|
import { useThreadSummary } from '../../../hooks/useThreadSummary';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
@@ -31,6 +32,7 @@ export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
|
|||||||
<Chip
|
<Chip
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
radii="300"
|
radii="300"
|
||||||
|
className={MobileTouchTarget}
|
||||||
before={<Icon size="50" src={Icons.Thread} />}
|
before={<Icon size="50" src={Icons.Thread} />}
|
||||||
after={
|
after={
|
||||||
unread > 0 ? <Badge variant="Success" fill="Solid" radii="Pill" size="200" /> : undefined
|
unread > 0 ? <Badge variant="Success" fill="Solid" radii="Pill" size="200" /> : undefined
|
||||||
|
|||||||
@@ -564,7 +564,6 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
|||||||
mx.sendEvent(
|
mx.sendEvent(
|
||||||
room.roomId,
|
room.roomId,
|
||||||
thread.id,
|
thread.id,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
MessageEvent.Reaction as any,
|
MessageEvent.Reaction as any,
|
||||||
getReactionContent(targetEventId, key, rShortcode),
|
getReactionContent(targetEventId, key, rShortcode),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
|||||||
clientApi.stop();
|
clientApi.stop();
|
||||||
iframe.remove();
|
iframe.remove();
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [mx, room.roomId, widget.id, widget.templateUrl]);
|
}, [mx, room.roomId, widget.id, widget.templateUrl]);
|
||||||
|
|
||||||
if (blocked) {
|
if (blocked) {
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
|||||||
data: {},
|
data: {},
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
await sendStateEvent(mx, room.roomId, StateEvent.Widget, content, id);
|
await sendStateEvent(mx, room.roomId, StateEvent.Widget, content, id);
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -96,7 +95,6 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
|||||||
|
|
||||||
const handleRemove = (id: string) => {
|
const handleRemove = (id: string) => {
|
||||||
if (viewingId === id) setViewingId(null);
|
if (viewingId === id) setViewingId(null);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
sendStateEvent(mx, room.roomId, StateEvent.Widget, {}, id).catch(() => undefined);
|
sendStateEvent(mx, room.roomId, StateEvent.Widget, {}, id).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
|
|||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import LotusLogo from '../../../../../public/res/Lotus.png';
|
import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
|
||||||
import pkg from '../../../../../package.json';
|
import pkg from '../../../../../package.json';
|
||||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
|
||||||
|
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
|
||||||
|
|
||||||
type MSC1929Contact = {
|
type MSC1929Contact = {
|
||||||
matrix_id?: string;
|
matrix_id?: string;
|
||||||
email_address?: string;
|
email_address?: string;
|
||||||
@@ -42,9 +44,16 @@ function useServerSupport(): { support: MSC1929Support | null; loading: boolean
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const baseUrl = mx.getHomeserverUrl();
|
// MSC1929 support info is served from the MXID server-name host (like
|
||||||
|
// /.well-known/matrix/client), which on delegated/split-domain servers is
|
||||||
|
// NOT the client-API URL. Derive it from the user's domain.
|
||||||
|
const serverName = mx.getDomain();
|
||||||
|
if (!serverName) {
|
||||||
|
setLoading(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetch(`${baseUrl}/.well-known/matrix/support`, { signal: controller.signal })
|
fetch(`https://${serverName}/.well-known/matrix/support`, { signal: controller.signal })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ export function OidcManageAccount() {
|
|||||||
const open = useCallback(() => {
|
const open = useCallback(() => {
|
||||||
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
|
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
|
||||||
if (!authUrl) return;
|
if (!authUrl) return;
|
||||||
window.open(withSearchParam(authUrl, { action: accountManagementActions.profile }), '_blank');
|
window.open(
|
||||||
|
withSearchParam(authUrl, { action: accountManagementActions.profile }),
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer',
|
||||||
|
);
|
||||||
}, [authMetadata, accountManagementActions]);
|
}, [authMetadata, accountManagementActions]);
|
||||||
|
|
||||||
if (!authMetadata) return null;
|
if (!authMetadata) return null;
|
||||||
|
|||||||
@@ -648,6 +648,11 @@ function ProfileStatus() {
|
|||||||
imagePackRooms={[]}
|
imagePackRooms={[]}
|
||||||
returnFocusOnDeactivate={false}
|
returnFocusOnDeactivate={false}
|
||||||
onEmojiSelect={handleEmojiSelect}
|
onEmojiSelect={handleEmojiSelect}
|
||||||
|
// A status message is plain-text presence (`status_msg`), so it
|
||||||
|
// can't hold a custom mxc-image emoji — show unicode only, so every
|
||||||
|
// emoji in the picker actually inserts (custom ones silently no-op'd
|
||||||
|
// because there's no onCustomEmojiSelect here).
|
||||||
|
hideCustomEmojis
|
||||||
requestClose={() => setEmojiAnchor(undefined)}
|
requestClose={() => setEmojiAnchor(undefined)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import { Method } from 'matrix-js-sdk';
|
|||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import {
|
import { DECORATION_CATEGORIES, decorationUrl } from '../../lotus/avatarDecorations';
|
||||||
DECORATION_CATEGORIES,
|
|
||||||
DECORATION_CDN,
|
|
||||||
decorationUrl,
|
|
||||||
} from '../../lotus/avatarDecorations';
|
|
||||||
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
||||||
|
|
||||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||||
@@ -48,7 +44,7 @@ function DecorationPreviewCell({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${DECORATION_CDN}/${slug}.png`}
|
src={decorationUrl(slug)}
|
||||||
alt={name}
|
alt={name}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
@@ -73,11 +69,11 @@ export function ProfileDecoration() {
|
|||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Fetch the whole profile, not the `/{field}` sub-resource: an unset field
|
||||||
|
// 404s (a console error for anyone without a decoration). The full profile
|
||||||
|
// returns 200 with all fields incl. custom MSC4133 ones — read it out.
|
||||||
mx.http
|
mx.http
|
||||||
.authedRequest<Record<string, string>>(
|
.authedRequest<Record<string, string>>(Method.Get, `/profile/${encodeURIComponent(userId)}`)
|
||||||
Method.Get,
|
|
||||||
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
|
|
||||||
)
|
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||||
setCurrent(val);
|
setCurrent(val);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
|||||||
action: accountManagementActions.sessionsList,
|
action: accountManagementActions.sessionsList,
|
||||||
}),
|
}),
|
||||||
'_blank',
|
'_blank',
|
||||||
|
'noopener,noreferrer',
|
||||||
);
|
);
|
||||||
}, [authMetadata, accountManagementActions]);
|
}, [authMetadata, accountManagementActions]);
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
|||||||
device_id: deviceId,
|
device_id: deviceId,
|
||||||
}),
|
}),
|
||||||
'_blank',
|
'_blank',
|
||||||
|
'noopener,noreferrer',
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[authMetadata, accountManagementActions],
|
[authMetadata, accountManagementActions],
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ export function DeviceVerificationOptions() {
|
|||||||
action: accountManagementActions.crossSigningReset,
|
action: accountManagementActions.crossSigningReset,
|
||||||
}),
|
}),
|
||||||
'_blank',
|
'_blank',
|
||||||
|
'noopener,noreferrer',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
try {
|
try {
|
||||||
const ctx = new AudioContext({ sampleRate: sampleRateFor(model) });
|
const ctx = new AudioContext({ sampleRate: sampleRateFor(model) });
|
||||||
const stream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS(nativeNS));
|
const stream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS(nativeNS));
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
ctx.close().catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const source = ctx.createMediaStreamSource(stream);
|
const source = ctx.createMediaStreamSource(stream);
|
||||||
const inAnalyser = ctx.createAnalyser();
|
const inAnalyser = ctx.createAnalyser();
|
||||||
inAnalyser.fftSize = 1024;
|
inAnalyser.fftSize = 1024;
|
||||||
@@ -182,7 +187,12 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
timer: number;
|
timer: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const clipRef = useRef<AudioBuffer | null>(null);
|
const clipRef = useRef<AudioBuffer | null>(null);
|
||||||
const playRef = useRef<{ ctx: AudioContext; source: AudioBufferSourceNode } | null>(null);
|
const playRef = useRef<{
|
||||||
|
ctx: AudioContext;
|
||||||
|
source: AudioBufferSourceNode;
|
||||||
|
model: DenoiseNode | null;
|
||||||
|
gate: AudioWorkletNode | null;
|
||||||
|
} | null>(null);
|
||||||
const [recording, setRecording] = useState(false);
|
const [recording, setRecording] = useState(false);
|
||||||
const [recDb, setRecDb] = useState(-100);
|
const [recDb, setRecDb] = useState(-100);
|
||||||
const [hasClip, setHasClip] = useState(false);
|
const [hasClip, setHasClip] = useState(false);
|
||||||
@@ -208,6 +218,10 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
const startRecord = async () => {
|
const startRecord = async () => {
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia(RAW_CONSTRAINTS);
|
const stream = await navigator.mediaDevices.getUserMedia(RAW_CONSTRAINTS);
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
const source = ctx.createMediaStreamSource(stream);
|
const source = ctx.createMediaStreamSource(stream);
|
||||||
const analyser = ctx.createAnalyser();
|
const analyser = ctx.createAnalyser();
|
||||||
@@ -249,7 +263,15 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bumped whenever a playback starts or stops. An in-flight `play()` compares
|
||||||
|
// the generation it claimed against this after its awaits; if it no longer
|
||||||
|
// matches (a newer play, a Stop, or unmount happened during model load) it
|
||||||
|
// discards what it built instead of orphaning it — closes the rapid-click /
|
||||||
|
// unmount-during-load leak.
|
||||||
|
const playGenRef = useRef(0);
|
||||||
|
|
||||||
const stopPlayback = useCallback(() => {
|
const stopPlayback = useCallback(() => {
|
||||||
|
playGenRef.current += 1;
|
||||||
const p = playRef.current;
|
const p = playRef.current;
|
||||||
playRef.current = null;
|
playRef.current = null;
|
||||||
if (p) {
|
if (p) {
|
||||||
@@ -259,6 +281,15 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
} catch {
|
} catch {
|
||||||
/* noop */
|
/* noop */
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
// Mirror stopLive: dispose the model node (worker/WASM) + gate, else each
|
||||||
|
// A/B playback through a model leaks a DeepFilterNet/DTLN worker.
|
||||||
|
p.gate?.disconnect();
|
||||||
|
p.model?.dispose();
|
||||||
|
p.model?.node.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
p.ctx.close().catch(() => undefined);
|
p.ctx.close().catch(() => undefined);
|
||||||
}
|
}
|
||||||
setPlaying(null);
|
setPlaying(null);
|
||||||
@@ -268,37 +299,73 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
stopPlayback();
|
stopPlayback();
|
||||||
const clip = clipRef.current;
|
const clip = clipRef.current;
|
||||||
if (!clip) return;
|
if (!clip) return;
|
||||||
|
// Claim this generation AFTER stopPlayback's bump; a later play/stop/unmount
|
||||||
|
// moves it past `gen`, signalling us to discard what we built below.
|
||||||
|
const gen = playGenRef.current;
|
||||||
try {
|
try {
|
||||||
// bufferSource auto-resamples the 48 kHz clip to the context rate, so DTLN
|
// bufferSource auto-resamples the 48 kHz clip to the context rate, so DTLN
|
||||||
// gets the 16 kHz it needs while raw/RNNoise/Speex stay at 48 kHz.
|
// gets the 16 kHz it needs while raw/RNNoise/Speex stay at 48 kHz.
|
||||||
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
|
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
|
||||||
const source = ctx.createBufferSource();
|
const source = ctx.createBufferSource();
|
||||||
source.buffer = clip;
|
source.buffer = clip;
|
||||||
|
let playGate: AudioWorkletNode | null = null;
|
||||||
|
let playModelNode: DenoiseNode | null = null;
|
||||||
if (playModel) {
|
if (playModel) {
|
||||||
let head: AudioNode = source;
|
let head: AudioNode = source;
|
||||||
if (useGate) {
|
if (useGate) {
|
||||||
const gate = await buildGateNode(ctx, gateThreshold);
|
playGate = await buildGateNode(ctx, gateThreshold);
|
||||||
head.connect(gate);
|
head.connect(playGate);
|
||||||
head = gate;
|
head = playGate;
|
||||||
}
|
}
|
||||||
const denoise = await buildModelNode(ctx, playModel);
|
playModelNode = await buildModelNode(ctx, playModel);
|
||||||
head.connect(denoise.node);
|
head.connect(playModelNode.node);
|
||||||
denoise.node.connect(ctx.destination);
|
playModelNode.node.connect(ctx.destination);
|
||||||
} else {
|
} else {
|
||||||
source.connect(ctx.destination);
|
source.connect(ctx.destination);
|
||||||
}
|
}
|
||||||
|
// Superseded while the WASM/worklet loaded (another Play, a Stop, or the
|
||||||
|
// panel unmounted)? Tear down this now-orphaned graph instead of storing
|
||||||
|
// it — otherwise its worker/WASM + ctx would leak and its audio would play
|
||||||
|
// over the winner.
|
||||||
|
if (playGenRef.current !== gen || !mountedRef.current) {
|
||||||
|
try {
|
||||||
|
playGate?.disconnect();
|
||||||
|
playModelNode?.dispose();
|
||||||
|
playModelNode?.node.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
ctx.close().catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
source.onended = () => {
|
source.onended = () => {
|
||||||
if (playRef.current?.ctx === ctx) stopPlayback();
|
if (playRef.current?.ctx === ctx) stopPlayback();
|
||||||
};
|
};
|
||||||
playRef.current = { ctx, source };
|
playRef.current = { ctx, source, model: playModelNode, gate: playGate };
|
||||||
source.start();
|
source.start();
|
||||||
setPlaying(label);
|
setPlaying(label);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[denoise-tester] playback failed', e);
|
console.error('[denoise-tester] playback failed', e);
|
||||||
stopPlayback();
|
// Only tear down if we're still the current playback — a superseded
|
||||||
|
// invocation must not stop the winner that replaced it.
|
||||||
|
if (playGenRef.current === gen) stopPlayback();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Guards the async getUserMedia paths: if Settings closes while the mic
|
||||||
|
// permission prompt is open, the resolved stream/ctx would otherwise be
|
||||||
|
// created after the unmount cleanup already ran, leaking + setState-after-
|
||||||
|
// unmount. Own [] effect so it only flips on real unmount.
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
// Set on mount (not just cleared on unmount) so a setup→cleanup→setup
|
||||||
|
// remount of the same fiber (StrictMode/Activity) leaves it true.
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
stopLive();
|
stopLive();
|
||||||
|
|||||||
@@ -101,6 +101,12 @@ import { BG_OPTIONS, getChatBg } from '../../lotus/chatBackground';
|
|||||||
import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot';
|
import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot';
|
||||||
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
||||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||||
|
import {
|
||||||
|
TRANSLATE_TARGET_LANGUAGES,
|
||||||
|
normalizeLang,
|
||||||
|
isSupportedTargetLang,
|
||||||
|
} from '../../../utils/translation/langUtils';
|
||||||
|
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||||
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
||||||
@@ -543,7 +549,11 @@ function Appearance() {
|
|||||||
value={seasonalThemeOverride ?? 'auto'}
|
value={seasonalThemeOverride ?? 'auto'}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setSeasonalThemeOverride(v);
|
setSeasonalThemeOverride(v);
|
||||||
if (v !== 'auto' && v !== 'off') setChatBackground('none');
|
// Any active seasonal mode (incl. "auto") is mutually exclusive
|
||||||
|
// with a chat background — else picking it is a silent no-op, since
|
||||||
|
// SeasonalEffect suppresses the overlay while a background is set.
|
||||||
|
// Only "off" leaves the background alone.
|
||||||
|
if (v !== 'off') setChatBackground('none');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1892,27 +1902,27 @@ function Calls() {
|
|||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile title="Ringtone Volume" description="Volume of the incoming call ringtone." />
|
||||||
title="Ringtone Volume"
|
<Box
|
||||||
description="Volume of the incoming call ringtone."
|
direction="Row"
|
||||||
after={
|
alignItems="Center"
|
||||||
<Box direction="Row" alignItems="Center" gap="200" style={{ minWidth: '160px' }}>
|
gap="200"
|
||||||
<input
|
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||||||
type="range"
|
>
|
||||||
min="0"
|
<input
|
||||||
max="100"
|
type="range"
|
||||||
step="5"
|
min="0"
|
||||||
value={ringtoneVolume}
|
max="100"
|
||||||
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
|
step="5"
|
||||||
aria-label="Ringtone volume"
|
value={ringtoneVolume}
|
||||||
style={{ flex: 1, accentColor: color.Primary.Main }}
|
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
|
||||||
/>
|
aria-label="Ringtone volume"
|
||||||
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
|
style={{ flex: 1, accentColor: color.Primary.Main }}
|
||||||
{ringtoneVolume}%
|
/>
|
||||||
</Text>
|
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
|
||||||
</Box>
|
{ringtoneVolume}%
|
||||||
}
|
</Text>
|
||||||
/>
|
</Box>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
@@ -1984,26 +1994,28 @@ function Calls() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{soundboardEnabled && (
|
{soundboardEnabled && (
|
||||||
<SettingTile
|
<>
|
||||||
title="Soundboard Volume"
|
<SettingTile title="Soundboard Volume" />
|
||||||
after={
|
<Box
|
||||||
<Box alignItems="Center" gap="200" style={{ minWidth: toRem(180) }}>
|
alignItems="Center"
|
||||||
<input
|
gap="200"
|
||||||
type="range"
|
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||||||
min={0}
|
>
|
||||||
max={100}
|
<input
|
||||||
step={5}
|
type="range"
|
||||||
value={soundboardVolume}
|
min={0}
|
||||||
onChange={(e) => setSoundboardVolume(parseInt(e.target.value, 10))}
|
max={100}
|
||||||
style={{ flexGrow: 1 }}
|
step={5}
|
||||||
aria-label="Soundboard volume"
|
value={soundboardVolume}
|
||||||
/>
|
onChange={(e) => setSoundboardVolume(parseInt(e.target.value, 10))}
|
||||||
<Text size="T200" style={{ minWidth: toRem(36), textAlign: 'right' }}>
|
style={{ flexGrow: 1 }}
|
||||||
{soundboardVolume}%
|
aria-label="Soundboard volume"
|
||||||
</Text>
|
/>
|
||||||
</Box>
|
<Text size="T200" style={{ minWidth: toRem(36), textAlign: 'right' }}>
|
||||||
}
|
{soundboardVolume}%
|
||||||
/>
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -2311,6 +2323,15 @@ function Messages() {
|
|||||||
settingsAtom,
|
settingsAtom,
|
||||||
'enforceRetentionLocally',
|
'enforceRetentionLocally',
|
||||||
);
|
);
|
||||||
|
const [translateTargetLang, setTranslateTargetLang] = useSetting(
|
||||||
|
settingsAtom,
|
||||||
|
'translateTargetLang',
|
||||||
|
);
|
||||||
|
const [autoTranslate, setAutoTranslate] = useSetting(settingsAtom, 'autoTranslate');
|
||||||
|
const translationSupported = chromeTranslationEngine.isSupported();
|
||||||
|
const selectedTargetLang = isSupportedTargetLang(translateTargetLang)
|
||||||
|
? normalizeLang(translateTargetLang)
|
||||||
|
: 'en';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
@@ -2430,6 +2451,32 @@ function Messages() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Translate Messages Into"
|
||||||
|
description={
|
||||||
|
translationSupported
|
||||||
|
? 'Translate messages written in other languages into this one. Runs entirely on your device — message text never leaves your computer or touches a cloud service.'
|
||||||
|
: 'On-device translation isn’t available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
|
||||||
|
}
|
||||||
|
after={
|
||||||
|
<SettingsSelect
|
||||||
|
value={selectedTargetLang}
|
||||||
|
onChange={(v) => setTranslateTargetLang(v)}
|
||||||
|
disabled={!translationSupported}
|
||||||
|
aria-label="Translate messages into"
|
||||||
|
options={TRANSLATE_TARGET_LANGUAGES.map((l) => ({ value: l.code, label: l.name }))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{translationSupported && (
|
||||||
|
<SettingTile
|
||||||
|
title="Auto-translate Incoming Messages"
|
||||||
|
description="Automatically translate messages that aren’t already in your language. When off, translate individual messages from their menu. The first time a language is used, its model downloads once (a few MB)."
|
||||||
|
after={<Switch variant="Primary" value={autoTranslate} onChange={setAutoTranslate} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
|
import React, {
|
||||||
|
ChangeEventHandler,
|
||||||
|
FormEventHandler,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
|
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
|
||||||
import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds';
|
import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds';
|
||||||
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||||
@@ -56,6 +63,13 @@ function RuleEnableToggle({ kind, pushRule }: RuleEnableToggleProps) {
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [enabled, setEnabled] = useState(pushRule.enabled !== false);
|
const [enabled, setEnabled] = useState(pushRule.enabled !== false);
|
||||||
|
|
||||||
|
// Re-sync when the rule changes externally (e.g. toggled on another device →
|
||||||
|
// account-data sync). The useState initializer only runs once, so without
|
||||||
|
// this the Switch would show a stale value.
|
||||||
|
useEffect(() => {
|
||||||
|
setEnabled(pushRule.enabled !== false);
|
||||||
|
}, [pushRule.enabled]);
|
||||||
|
|
||||||
const [toggleState, toggle] = useAsyncCallback(
|
const [toggleState, toggle] = useAsyncCallback(
|
||||||
useCallback(
|
useCallback(
|
||||||
async (value: boolean) => {
|
async (value: boolean) => {
|
||||||
|
|||||||
@@ -381,6 +381,11 @@ export function SystemNotification() {
|
|||||||
style={selectStyle}
|
style={selectStyle}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
{(!quietHoursStart || !quietHoursEnd) && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
Set both a start and end time — quiet hours stay inactive until both are filled in.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useRef, CSSProperties } from 'react';
|
import React, { useEffect, useRef, CSSProperties } from 'react';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { color, config, Icon, IconButton, Icons } from 'folds';
|
import { color, config, Icon, IconButton, Icons } from 'folds';
|
||||||
|
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||||
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
|
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
@@ -37,6 +38,7 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
// folds tokens so toasts render correctly on stock Cinny themes (the --lt-*
|
// folds tokens so toasts render correctly on stock Cinny themes (the --lt-*
|
||||||
// vars only exist while Terminal mode is active).
|
// vars only exist while Terminal mode is active).
|
||||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
const isMobile = useScreenSize() === ScreenSize.Mobile;
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -80,8 +82,11 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
}`,
|
}`,
|
||||||
borderRadius: config.radii.R400,
|
borderRadius: config.radii.R400,
|
||||||
padding: `${config.space.S300} ${config.space.S400}`,
|
padding: `${config.space.S300} ${config.space.S400}`,
|
||||||
minWidth: '280px',
|
// Full-width on phones (the container spans the viewport there); a fixed
|
||||||
maxWidth: '340px',
|
// 280-340px card would otherwise overflow a narrow screen.
|
||||||
|
minWidth: isMobile ? 0 : '280px',
|
||||||
|
maxWidth: isMobile ? 'none' : '340px',
|
||||||
|
width: isMobile ? '100%' : undefined,
|
||||||
boxShadow: lotusTerminal
|
boxShadow: lotusTerminal
|
||||||
? toast.sticky
|
? toast.sticky
|
||||||
? 'var(--lt-box-glow-cyan)'
|
? 'var(--lt-box-glow-cyan)'
|
||||||
@@ -216,22 +221,39 @@ export function LotusToastContainer() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toasts = useAtomValue(toastQueueAtom);
|
const toasts = useAtomValue(toastQueueAtom);
|
||||||
|
const isMobile = useScreenSize() === ScreenSize.Mobile;
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// The newest toast is the last (bottom) child; if the stack ever overflows its
|
||||||
|
// max-height (many sticky action toasts), keep that newest one in view instead
|
||||||
|
// of leaving it scrolled below the fold.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = listRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [toasts.length]);
|
||||||
|
|
||||||
if (toasts.length === 0) return null;
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
const containerStyle: CSSProperties = {
|
const containerStyle: CSSProperties = {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: '1.5rem',
|
// Span the width just inside the screen edges on a phone (so full-width
|
||||||
right: '1.5rem',
|
// cards fit); float bottom-right on desktop.
|
||||||
|
bottom: isMobile ? config.space.S200 : '1.5rem',
|
||||||
|
right: isMobile ? config.space.S200 : '1.5rem',
|
||||||
|
left: isMobile ? config.space.S200 : undefined,
|
||||||
zIndex: zIndices.toast,
|
zIndex: zIndices.toast,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: config.space.S200,
|
gap: config.space.S200,
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
|
// Safety net beyond the queue cap: if many sticky action toasts pile up they
|
||||||
|
// scroll within a bounded height instead of covering the whole screen.
|
||||||
|
maxHeight: isMobile ? '70vh' : '80vh',
|
||||||
|
overflowY: 'auto',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle} aria-live="polite" aria-label="Notifications">
|
<div ref={listRef} style={containerStyle} aria-live="polite" aria-label="Notifications">
|
||||||
{toasts.map((toast) => (
|
{toasts.map((toast) => (
|
||||||
<ToastCard key={toast.id} toast={toast} />
|
<ToastCard key={toast.id} toast={toast} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router';
|
||||||
import { getCreatePath } from '../../pages/pathUtils';
|
import { getCreatePath } from '../../pages/pathUtils';
|
||||||
|
|
||||||
export const useCreateSelected = (): boolean => {
|
export const useCreateSelected = (): boolean => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router';
|
||||||
import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
|
import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
|
||||||
|
|
||||||
export const useDirectSelected = (): boolean => {
|
export const useDirectSelected = (): boolean => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMatch, useParams } from 'react-router-dom';
|
import { useMatch, useParams } from 'react-router';
|
||||||
import { getExploreFeaturedPath, getExplorePath } from '../../pages/pathUtils';
|
import { getExploreFeaturedPath, getExplorePath } from '../../pages/pathUtils';
|
||||||
|
|
||||||
export const useExploreSelected = (): boolean => {
|
export const useExploreSelected = (): boolean => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router';
|
||||||
import {
|
import {
|
||||||
getHomeCreatePath,
|
getHomeCreatePath,
|
||||||
getHomeJoinPath,
|
getHomeJoinPath,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router';
|
||||||
import {
|
import {
|
||||||
getInboxInvitesPath,
|
getInboxInvitesPath,
|
||||||
getInboxNotificationsPath,
|
getInboxNotificationsPath,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router';
|
||||||
import { getRoomSearchParams } from '../../pages/pathSearchParam';
|
import { getRoomSearchParams } from '../../pages/pathSearchParam';
|
||||||
import { decodeSearchParamValueArray } from '../../pages/pathUtils';
|
import { decodeSearchParamValueArray } from '../../pages/pathUtils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router';
|
||||||
import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
|
import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
|
||||||
import { useMatrixClient } from '../useMatrixClient';
|
import { useMatrixClient } from '../useMatrixClient';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMatch, useParams } from 'react-router-dom';
|
import { useMatch, useParams } from 'react-router';
|
||||||
import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
|
import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
|
||||||
import { useMatrixClient } from '../useMatrixClient';
|
import { useMatrixClient } from '../useMatrixClient';
|
||||||
import { getSpaceLobbyPath, getSpaceSearchPath } from '../../pages/pathUtils';
|
import { getSpaceLobbyPath, getSpaceSearchPath } from '../../pages/pathUtils';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user