Compare commits
65
Commits
dcfee9f1df
...
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4656f08802 | ||
|
|
a631e90ea2 | ||
|
|
10270b75ca | ||
|
|
7925866868 | ||
|
|
d5cfb663b9 | ||
|
|
f2c356f288 | ||
|
|
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 |
+45
-23
@@ -6,6 +6,18 @@ on:
|
||||
pull_request:
|
||||
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:
|
||||
build:
|
||||
name: Build & Quality Checks
|
||||
@@ -18,8 +30,13 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: npm
|
||||
|
||||
# No npm / node_modules cache: the act_runner's internal cache server is
|
||||
# unreachable from job containers (`getCacheEntry failed: connect ETIMEDOUT
|
||||
# 172.17.0.2`), so every cache restore hangs ~5 min and then fails — pure
|
||||
# cost, zero benefit. `cache: npm` was removed from Setup Node above for the
|
||||
# same reason. Re-enable both (setup-node `cache: npm` + an actions/cache
|
||||
# node_modules step) once the runner's cache server is reachable from jobs.
|
||||
- name: Install dependencies
|
||||
# Harden against transient registry network failures (ECONNRESET etc.):
|
||||
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
|
||||
@@ -40,39 +57,44 @@ jobs:
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
# ── Critical gate — if this fails, nothing deploys ──────────────────
|
||||
# ── Quality gates run BEFORE the slow build so a format/lint/type/test
|
||||
# error fails in seconds instead of after the ~minutes-long build. All are
|
||||
# hard gates — any failure fails the job and blocks the deploy. The tree is
|
||||
# held clean (prettier formatted, eslint 0 errors, typecheck 0), so these
|
||||
# gate real regressions. NOTE: the lotus-build.sh upstream-merge path can
|
||||
# deploy without CI; a later normal push surfaces any introduced issue here
|
||||
# — fix forward (or briefly re-soften a gate) rather than deploy broken.
|
||||
# eslint gates on errors only (existing no-explicit-any warnings stay
|
||||
# informational — check:eslint has no --max-warnings).
|
||||
- name: Prettier
|
||||
run: npm run check:prettier
|
||||
|
||||
- name: ESLint
|
||||
run: npm run check:eslint
|
||||
|
||||
- name: TypeScript
|
||||
run: npm run typecheck
|
||||
|
||||
# Deterministic pure-logic tests on Node's built-in runner via tsx (no
|
||||
# vitest — Vite 8 is ahead of vitest's range). A failure blocks the deploy.
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
# ── Critical gate — if this fails, nothing deploys. Produces dist/. ──
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
VITE_APP_VERSION: ${{ github.sha }}
|
||||
|
||||
# Unit tests are a hard gate too — deterministic pure-logic tests on Node's
|
||||
# built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
|
||||
# A failure blocks the deploy.
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
# ── Quality checks (informational — pre-existing issues exist) ───────
|
||||
- name: TypeScript
|
||||
run: npm run typecheck
|
||||
continue-on-error: true
|
||||
|
||||
- name: ESLint
|
||||
run: npm run check:eslint
|
||||
continue-on-error: true
|
||||
|
||||
- name: Prettier
|
||||
run: npm run check:prettier
|
||||
continue-on-error: true
|
||||
|
||||
# ── Security ─────────────────────────────────────────────────────────
|
||||
# ── Security (informational — findings shouldn't block a deploy) ─────
|
||||
- name: Audit (high/critical)
|
||||
run: npm audit --audit-level=high --omit=dev
|
||||
continue-on-error: true
|
||||
|
||||
# ── Bundle size report ───────────────────────────────────────────────
|
||||
# ── Bundle size report (informational — never blocks a deploy) ───────
|
||||
- name: Report bundle sizes
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Bundle sizes" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
blank_issues_enabled: true
|
||||
|
||||
@@ -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.**
|
||||
+1
-3
@@ -1,3 +1 @@
|
||||
# These are commented until we enable lint and typecheck
|
||||
# npx tsc -p tsconfig.json --noEmit
|
||||
# npx lint-staged
|
||||
npx lint-staged
|
||||
|
||||
+9
-8
@@ -1019,10 +1019,12 @@ Fixed by replacing the single read with a `readStatus()` function called inside
|
||||
|
||||
The browser tab title updates to reflect unread state:
|
||||
|
||||
- `(N) Lotus Chat` — N unread messages
|
||||
- `· Lotus Chat` — unread activity without a specific count
|
||||
- `(N) Lotus Chat` — N mentions / keyword highlights (the count is highlights, not total unread)
|
||||
- `· Lotus Chat` — unread messages without a mention (activity, no specific count)
|
||||
- `Lotus Chat` — no unread items
|
||||
|
||||
The favicon mirrors this (highlight badge / unread dot / default).
|
||||
|
||||
### Extended Profile Fields
|
||||
|
||||
Supports MSC4133 custom profile fields via `PUT /_matrix/client/unstable/uk.tcpip.msc4133/{userId}/{field}`:
|
||||
@@ -1094,10 +1096,9 @@ OS-level notifications are unchanged and still fire when the window is not focus
|
||||
|
||||
### 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 is configurable in **Settings → Appearance**
|
||||
- Threshold: a fixed `COLLAPSE_MAX_HEIGHT` of 320px (≈ 20 lines) — not currently user-configurable
|
||||
- Uses CSS `max-height` + `overflow: hidden` with a smooth transition
|
||||
- Transition is disabled when `prefers-reduced-motion: reduce` is active
|
||||
|
||||
@@ -1288,9 +1289,9 @@ Features:
|
||||
|
||||
Accessible via **Room/Space Settings → Policy Lists** (admin only).
|
||||
|
||||
- Displays the room's subscribed policy lists in read-only format
|
||||
- Subscribe (join) and unsubscribe (leave) controls for each list
|
||||
- Enforcement is delegated to Draupnir or equivalent tooling; Lotus only manages list membership
|
||||
- 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
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
+25
-1
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
> **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)
|
||||
|
||||
### A1. Ringtone selection — preview in Settings
|
||||
|
||||
+130
-12
@@ -32,10 +32,9 @@ A three-wave feature bug-hunt (~15 parallel agents, each batch independently rev
|
||||
|
||||
**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.
|
||||
- **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.
|
||||
- **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. 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).
|
||||
- ✅ **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).
|
||||
- **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.**
|
||||
- **Native/desktop:** D7 Unity badge `application://cinny.desktop` id may not match the installed `.desktop` basename — **runtime-verify** on the `.deb`/AppImage.
|
||||
- **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**.
|
||||
|
||||
---
|
||||
@@ -92,6 +91,62 @@ Agent-surveyed findings, each **verified against the code before fixing**, then
|
||||
- [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).
|
||||
|
||||
### 🔍 Feature bug hunt (2026-07, 5-agent, LOTUS_FEATURES surface) — open findings
|
||||
|
||||
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.
|
||||
|
||||
**Embeds / URL previews**
|
||||
|
||||
- [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.
|
||||
- [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.
|
||||
- [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).
|
||||
|
||||
**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)
|
||||
|
||||
Room unread dots were inconsistent: reading a message sometimes cleared the dot, sometimes left it stuck, sometimes it resurrected. Root cause (confirmed by tracing + diffing upstream cinny `dev`): **our own "N4" change.** `handleReceipt` recomputed via `getUnreadInfo`, which reads `room.getUnreadNotificationCount()` — server-computed and **stale on the synchronous synthetic receipt echo** (SDK only zeroes it immediately when the last event is your own message) → it PUT the stale non-zero count back → stuck/resurrecting. Compounded by `hasUnread = !!unread` lighting the dot on any present map entry, incl. phantom `{0,0}` PUTs from our `UnreadNotifications` listener. Plus a Mark-as-Unread (MSC2867) flag that never cleared on opening an already-read room (no receipt → no auto-clear).
|
||||
@@ -150,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).
|
||||
|
||||
### [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)
|
||||
|
||||
After Phases A–C the client spec is ~complete. What's left, flagged by **what unblocks it**:
|
||||
@@ -204,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).
|
||||
|
||||
### [ ] 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)
|
||||
|
||||
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'`.
|
||||
- **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.)
|
||||
@@ -218,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.
|
||||
|
||||
**✅ 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] 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`.
|
||||
@@ -226,7 +331,7 @@ Also open (from the quality review): a real `onError`/error-state fallback for i
|
||||
|
||||
## 🚫 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] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
|
||||
@@ -239,8 +344,9 @@ Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgra
|
||||
|
||||
### 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`).
|
||||
- **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`.
|
||||
- **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** (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) ✅.
|
||||
- **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`.
|
||||
@@ -287,8 +393,20 @@ Also flag-gated: `lotusTransparent`/`lotusTheme`, `lotusDenoiseSource=1` (in-sou
|
||||
|
||||
```
|
||||
edit → commit → git push origin lotus
|
||||
→ Gitea Actions: tsc --noEmit, eslint, prettier (~3 min)
|
||||
→ lotus_deploy.sh on LXC 106 polls CI → npm ci && npm run build → rsync → live (~11 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 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`.
|
||||
|
||||
**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).
|
||||
|
||||
@@ -167,6 +167,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.
|
||||
|
||||
### 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
|
||||
|
||||
Voice/video channels embed **Element Call**, which is now our **self-built fork**
|
||||
|
||||
@@ -42,7 +42,7 @@ import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
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 { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||
import { useCallQuality } from '../hooks/useCallQuality';
|
||||
@@ -703,6 +703,23 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!callEmbed) return;
|
||||
@@ -714,8 +731,24 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
// When screenshare ends, release the spotlight we auto-enabled.
|
||||
const pipAutoSpotlightRef = React.useRef(false);
|
||||
useEffect(() => {
|
||||
if (!pipMode || !callEmbed) return;
|
||||
if (pipScreenshare) {
|
||||
if (!callEmbed) {
|
||||
// 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) {
|
||||
callEmbed.control.toggleSpotlight();
|
||||
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 { useAtom } from 'jotai';
|
||||
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
||||
import { IGif } from '@giphy/js-types';
|
||||
import { Box, color, config } from 'folds';
|
||||
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { addRecentGif, RecentGif, recentGifsAtom } from '../state/recentGifs';
|
||||
@@ -146,8 +147,18 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
|
||||
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 (
|
||||
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }}>
|
||||
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }} ref={containerRef}>
|
||||
{lotusTerminal && (
|
||||
<div
|
||||
style={{
|
||||
@@ -178,7 +189,7 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
<Grid
|
||||
key={searchKey}
|
||||
fetchGifs={fetchGifs}
|
||||
width={PICKER_WIDTH - 16}
|
||||
width={gridWidth}
|
||||
columns={2}
|
||||
gutter={4}
|
||||
onGifClick={handleClick}
|
||||
|
||||
@@ -88,8 +88,9 @@ export function RenderMessageContent({
|
||||
}: RenderMessageContentProps) {
|
||||
const renderUrlsPreview = (urls: string[]) => {
|
||||
// Cap previews per message so a link-dump doesn't spawn dozens of preview
|
||||
// fetches + iframes at once.
|
||||
const filteredUrls = urls.filter((url) => !testMatrixTo(url)).slice(0, 6);
|
||||
// fetches + iframes at once. De-dupe first: a message linking the same URL
|
||||
// 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;
|
||||
return (
|
||||
<UrlPreviewHolder>
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
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 { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile';
|
||||
import { UserRoomProfile } from './user-profile';
|
||||
@@ -8,6 +22,20 @@ import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
|
||||
import { stopPropagation } from '../utils/keyboard';
|
||||
import { SpaceProvider } from '../hooks/useSpace';
|
||||
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 }) {
|
||||
const { roomId, spaceId, userId, cords, position } = state;
|
||||
@@ -15,32 +43,61 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState })
|
||||
const getRoom = useGetRoom(allJoinedRooms);
|
||||
const room = getRoom(roomId);
|
||||
const space = spaceId ? getRoom(spaceId) : undefined;
|
||||
const screenSize = useScreenSize();
|
||||
|
||||
const close = useCloseUserRoomProfile();
|
||||
|
||||
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 (
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position={position ?? 'Top'}
|
||||
align="Start"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
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 focusTrapOptions={focusTrapOptions}>
|
||||
<Menu style={{ width: toRem(340) }}>{profile}</Menu>
|
||||
</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 { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { MobileTouchTarget } from '../styles/mobile.css';
|
||||
|
||||
type RecorderState = 'idle' | 'recording' | 'paused' | 'preview';
|
||||
|
||||
@@ -239,6 +240,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
if (state === 'idle') {
|
||||
return (
|
||||
<IconButton
|
||||
className={MobileTouchTarget}
|
||||
onClick={startRecording}
|
||||
aria-label="Record voice message"
|
||||
variant="SurfaceVariant"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
|
||||
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||
import { decorationUrl } from '../../features/lotus/avatarDecorations';
|
||||
|
||||
const DEFAULT_INSET = 8;
|
||||
@@ -16,8 +17,14 @@ export function AvatarDecoration({
|
||||
inset = DEFAULT_INSET,
|
||||
}: AvatarDecorationProps) {
|
||||
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}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,23 @@ export const EditorOptions = style([
|
||||
DefaultReset,
|
||||
{
|
||||
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 EditorTextarea = style([
|
||||
|
||||
@@ -124,7 +124,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
<div className={css.Editor} ref={ref}>
|
||||
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
||||
{top}
|
||||
<Box alignItems="Start">
|
||||
<Box className={css.EditorInputRow} alignItems="Start">
|
||||
{before && (
|
||||
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
||||
{before}
|
||||
|
||||
@@ -194,22 +194,43 @@ export const createCommandElement = (command: string): CommandElement => ({
|
||||
});
|
||||
|
||||
export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
|
||||
Transforms.select(editor, selectRange);
|
||||
Transforms.insertNodes(editor, element);
|
||||
Transforms.collapse(editor, {
|
||||
edge: 'end',
|
||||
});
|
||||
// Wrap the whole sequence: on a stale autocomplete range (the document changed
|
||||
// between the menu opening and the pick) `insertNodes` — not `select`, which is
|
||||
// lazy in this Slate version — can throw. This runs inside the pick's event
|
||||
// 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) => {
|
||||
// Defer to the next tick so React can flush any pending void-element DOM
|
||||
// updates (e.g. after inserting a mention) before Slate resolves cursor
|
||||
// positions via ReactEditor.toDOMNode — otherwise Slate throws
|
||||
// "Cannot resolve a DOM node from slate node".
|
||||
// Move the caret out of the just-inserted inline void and land it in a real
|
||||
// trailing text node — SYNCHRONOUSLY, in the same commit as the insert.
|
||||
// `Transforms.move` escapes the void (after insertNodes+collapse the caret is
|
||||
// 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(() => {
|
||||
ReactEditor.focus(editor);
|
||||
Transforms.move(editor);
|
||||
if (withSpace) editor.insertText(' ');
|
||||
try {
|
||||
ReactEditor.focus(editor);
|
||||
} 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);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as css from './ImageViewer.css';
|
||||
import { useZoom } from '../../hooks/useZoom';
|
||||
import { usePan } from '../../hooks/usePan';
|
||||
import { downloadMedia } from '../../utils/matrix';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
|
||||
export type ImageViewerProps = {
|
||||
alt: string;
|
||||
@@ -19,7 +20,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
const { t } = useTranslation();
|
||||
const saveFile = useSaveFile();
|
||||
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 fileContent = await downloadMedia(src);
|
||||
@@ -35,7 +36,13 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
>
|
||||
<Header className={css.ImageViewerHeader} size="400">
|
||||
<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} />
|
||||
</IconButton>
|
||||
<Text size="T300" truncate>
|
||||
@@ -48,12 +55,18 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
outlined={zoom < 1}
|
||||
size="300"
|
||||
radii="Pill"
|
||||
className={MobileTouchTarget}
|
||||
onClick={zoomOut}
|
||||
aria-label="Zoom Out"
|
||||
>
|
||||
<Icon size="50" src={Icons.Minus} />
|
||||
</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>
|
||||
</Chip>
|
||||
<IconButton
|
||||
@@ -61,6 +74,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
outlined={zoom > 1}
|
||||
size="300"
|
||||
radii="Pill"
|
||||
className={MobileTouchTarget}
|
||||
onClick={zoomIn}
|
||||
aria-label="Zoom In"
|
||||
>
|
||||
@@ -70,6 +84,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
variant="Primary"
|
||||
onClick={handleDownload}
|
||||
radii="300"
|
||||
className={MobileTouchTarget}
|
||||
before={<Icon size="50" src={Icons.Download} />}
|
||||
>
|
||||
<Text size="B300">{t('Organisms.ImageViewer.download')}</Text>
|
||||
@@ -100,6 +115,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
src={src}
|
||||
alt={alt}
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -11,7 +11,7 @@ export const MediaControl = as<'div', MediaControlProps>(
|
||||
({ before, after, leftControl, rightControl, children, ...props }, ref) => (
|
||||
<Box grow="Yes" direction="Column" gap="300" {...props} ref={ref}>
|
||||
{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">
|
||||
{leftControl}
|
||||
</Box>
|
||||
|
||||
@@ -417,6 +417,22 @@ type RenderImageContentProps = {
|
||||
markedAsSpoiler?: boolean;
|
||||
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 = {
|
||||
content: IImageContent;
|
||||
renderImageContent: (props: RenderImageContentProps) => ReactNode;
|
||||
@@ -432,11 +448,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||
|
||||
return (
|
||||
<Attachment outlined={outlined}>
|
||||
<AttachmentBox
|
||||
style={{
|
||||
height: toRem(height < 48 ? 48 : height),
|
||||
}}
|
||||
>
|
||||
<AttachmentBox style={attachmentMediaStyle(imgInfo?.w, imgInfo?.h, height)}>
|
||||
{renderImageContent({
|
||||
body: content.body || 'Image',
|
||||
info: imgInfo,
|
||||
@@ -498,11 +510,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
||||
}
|
||||
/>
|
||||
</AttachmentHeader>
|
||||
<AttachmentBox
|
||||
style={{
|
||||
height: toRem(height < 48 ? 48 : height),
|
||||
}}
|
||||
>
|
||||
<AttachmentBox style={attachmentMediaStyle(videoInfo.w, videoInfo.h, height)}>
|
||||
{renderVideoContent({
|
||||
body: content.body || 'Video',
|
||||
info: videoInfo,
|
||||
@@ -677,7 +685,7 @@ export function MLocation({ content }: MLocationProps) {
|
||||
<Button
|
||||
as="a"
|
||||
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"
|
||||
rel="noreferrer noopener"
|
||||
variant="Secondary"
|
||||
|
||||
@@ -53,6 +53,11 @@ const NavItemBase = style({
|
||||
color: OnContainer,
|
||||
outline: 'none',
|
||||
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: {
|
||||
'&:hover, &:focus-visible': {
|
||||
|
||||
@@ -27,7 +27,14 @@ type PresenceBadgeProps = {
|
||||
};
|
||||
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||
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 (
|
||||
<TooltipProvider
|
||||
@@ -38,9 +45,9 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
|
||||
<Text size="L400">{label[presence]}</Text>
|
||||
{status && <Text size="T200">•</Text>}
|
||||
{status && <Text size="T200">{status}</Text>}
|
||||
<Text size="L400">{displayLabel}</Text>
|
||||
{displayStatus && <Text size="T200">•</Text>}
|
||||
{displayStatus && <Text size="T200">{displayStatus}</Text>}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
}
|
||||
@@ -50,7 +57,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||
aria-label={ariaLabel}
|
||||
ref={triggerRef}
|
||||
size={size}
|
||||
variant={PresenceToColor[presence]}
|
||||
variant={isDnd ? 'Critical' : PresenceToColor[presence]}
|
||||
fill={presence === Presence.Offline ? 'Soft' : 'Solid'}
|
||||
radii="Pill"
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { stopPropagation } from '../../utils/keyboard';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { useMemberAvatar } from '../../hooks/useMemberAvatar';
|
||||
import { useRoomMembersChange } from '../../hooks/useRoomMemberChange';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import * as css from './ReadReceiptAvatars.css';
|
||||
|
||||
const MAX_DISPLAY = 5;
|
||||
@@ -92,7 +93,7 @@ export function ReadReceiptAvatars({
|
||||
onClick={() => setOpen(true)}
|
||||
title={tooltipNames}
|
||||
aria-label={tooltipNames}
|
||||
className={css.ReceiptTrigger}
|
||||
className={`${css.ReceiptTrigger} ${MobileTouchTarget}`}
|
||||
>
|
||||
{/* Pill wrapper ensures visibility on any wallpaper/background */}
|
||||
<span
|
||||
|
||||
@@ -6,6 +6,11 @@ export const CardGrid = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
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([
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||
import { zIndices } from '../../styles/zIndex';
|
||||
import { SeasonTheme } from './types';
|
||||
import { getActiveSeason } from './seasonSchedule';
|
||||
import { resolveSeasonTheme } from './seasonSchedule';
|
||||
import { HalloweenOverlay } from './themes/Halloween';
|
||||
import { ChristmasOverlay } from './themes/Christmas';
|
||||
import { NewYearOverlay } from './themes/NewYear';
|
||||
@@ -96,13 +96,25 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
|
||||
export function SeasonalEffect() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const reduced = useReducedMotion();
|
||||
const override = settings.seasonalThemeOverride ?? 'auto';
|
||||
|
||||
const theme = useMemo<SeasonTheme | null>(() => {
|
||||
const override = settings.seasonalThemeOverride ?? 'auto';
|
||||
if (override === 'off') return null;
|
||||
if (override === 'auto') return getActiveSeason(new Date());
|
||||
return override as SeasonTheme;
|
||||
}, [settings.seasonalThemeOverride]);
|
||||
// In auto mode, re-evaluate hourly so a long-lived session crosses a
|
||||
// season/holiday-window boundary (e.g. into a new day) without a reload —
|
||||
// otherwise the active season is frozen at the value it had on mount.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
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;
|
||||
// Suppress seasonal overlay when a chat background is active — both running simultaneously
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { test } from 'node:test';
|
||||
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';
|
||||
|
||||
// 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
|
||||
});
|
||||
|
||||
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', () => {
|
||||
assert.equal(SEASON_SCHEDULE.length, 11);
|
||||
const themes = SEASON_SCHEDULE.map((e) => e.theme);
|
||||
|
||||
@@ -93,3 +93,18 @@ export function getActiveSeason(now: Date): SeasonTheme | null {
|
||||
const day = now.getDate();
|
||||
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',
|
||||
position: 'relative',
|
||||
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: {
|
||||
'&:hover': {
|
||||
|
||||
@@ -275,6 +275,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
||||
key={key}
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
wrap="Wrap"
|
||||
style={{
|
||||
padding: config.space.S200,
|
||||
borderRadius: config.radii.R400,
|
||||
|
||||
@@ -4,7 +4,9 @@ import { DefaultReset, color, config, toRem } from 'folds';
|
||||
export const UrlPreview = style([
|
||||
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),
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
@@ -21,6 +23,19 @@ export const UrlPreviewWide = style({
|
||||
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([
|
||||
DefaultReset,
|
||||
{
|
||||
@@ -404,6 +419,56 @@ export const BadgeSteam = style({
|
||||
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({
|
||||
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
@@ -484,6 +549,16 @@ export const BadgeTidal = style({
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
export const BadgeMixcloud = style({
|
||||
backgroundColor: '#52aad8',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
export const BadgeDeezer = style({
|
||||
backgroundColor: '#a238ff',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Twitch LIVE badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
config,
|
||||
} from 'folds';
|
||||
import { ImageOverlay } from '../ImageOverlay';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
|
||||
@@ -33,11 +34,13 @@ import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import {
|
||||
extractEmbedHeight,
|
||||
getSteamTarget,
|
||||
getTikTokVideoId,
|
||||
getTweetId,
|
||||
isTikTokLink,
|
||||
MediaEmbed,
|
||||
parseMediaEmbed,
|
||||
steamWidgetEmbedUrl,
|
||||
tiktokIdFromOembed,
|
||||
tiktokOembedUrl,
|
||||
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 {
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
const h = hostname.replace(/^www\./, '');
|
||||
return h === 'twitter.com' || h === 'x.com';
|
||||
return TWITTER_HOSTS.has(hostname.replace(/^www\./, ''));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -128,9 +135,8 @@ function isTwitter(url: string): boolean {
|
||||
function isTwitterTweet(url: string): boolean {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
const h = hostname.replace(/^www\./, '');
|
||||
if (h !== 'twitter.com' && h !== 'x.com') return false;
|
||||
return /\/status\/\d+/.test(pathname);
|
||||
if (!TWITTER_HOSTS.has(hostname.replace(/^www\./, ''))) return false;
|
||||
return /\/status(?:es)?\/\d+/.test(pathname);
|
||||
} catch {
|
||||
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 {
|
||||
try {
|
||||
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 {
|
||||
// NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled
|
||||
// upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the
|
||||
@@ -320,7 +341,7 @@ function getCardVariant(url: string): CardVariant {
|
||||
if (getRedditSubreddit(url) !== null) return 'reddit';
|
||||
if (getSpotifyType(url) !== null) return 'spotify';
|
||||
if (isTwitch(url)) return 'twitch';
|
||||
if (isSteamApp(url)) return 'steam';
|
||||
if (getSteamTarget(url)) return 'steam';
|
||||
if (isWikipedia(url)) return 'wikipedia';
|
||||
if (isDiscordInvite(url)) return 'discord';
|
||||
if (isNpm(url)) return 'npm';
|
||||
@@ -726,6 +747,7 @@ function TwitterCard({
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => setExpanded(false)}
|
||||
aria-label="Collapse post"
|
||||
>
|
||||
@@ -780,6 +802,7 @@ function TwitterCard({
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => setExpanded(true)}
|
||||
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 },
|
||||
loom: { label: 'Loom', class: previewCss.BadgeLoom },
|
||||
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
|
||||
@@ -1249,6 +1274,7 @@ function MediaEmbedCard({
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
className={MobileTouchTarget}
|
||||
onClick={enterFullscreen}
|
||||
aria-label="Fullscreen"
|
||||
>
|
||||
@@ -1259,6 +1285,7 @@ function MediaEmbedCard({
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => setPlaying(false)}
|
||||
aria-label="Close player"
|
||||
>
|
||||
@@ -1407,6 +1434,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
className={MobileTouchTarget}
|
||||
onClick={enterFullscreen}
|
||||
aria-label="Fullscreen"
|
||||
>
|
||||
@@ -1416,6 +1444,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => setPlaying(false)}
|
||||
aria-label="Close player"
|
||||
>
|
||||
@@ -1492,9 +1521,17 @@ function GitHubCard({ 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 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 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 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 (
|
||||
<>
|
||||
@@ -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 }) {
|
||||
const title = prev['og:title'] ?? '';
|
||||
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 }) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const title = prev['og:title'] ?? '';
|
||||
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 (
|
||||
<>
|
||||
@@ -1827,9 +2036,14 @@ function StackOverflowCard({ url, prev }: { url: string; prev: IPreviewUrlRespon
|
||||
}
|
||||
|
||||
function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const title = prev['og:title'] ?? '';
|
||||
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 (
|
||||
<>
|
||||
@@ -1895,8 +2109,13 @@ function GifCard({
|
||||
const title = (prev['og:title'] 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
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
|
||||
? shouldServeGifOriginal(url, prev)
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication)
|
||||
: mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
|
||||
: null;
|
||||
|
||||
// If there's no image, fall back to a generic-style layout
|
||||
@@ -1994,6 +2213,7 @@ function GenericCard({
|
||||
src={displayThumb}
|
||||
alt={prev['og:title']}
|
||||
title={prev['og:title']}
|
||||
loading="lazy"
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
|
||||
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
|
||||
// player chrome / tweet content isn't cramped or clipped.
|
||||
const embed = parseMediaEmbed(url, window.location.hostname);
|
||||
const wide = !!embed || isTwitterTweet(url);
|
||||
const cardClass = wide ? previewCss.UrlPreviewWide : undefined;
|
||||
|
||||
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
|
||||
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
|
||||
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play tile.
|
||||
// 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.
|
||||
// Short "copy-link" links carry no id, so the embed is only resolvable from
|
||||
// the homeserver's canonical og:url. Resolve it here so `wide` reflects the
|
||||
// ACTUALLY rendered card — an og:url-resolved MediaEmbedCard must still get
|
||||
// the wide layout, not the cramped narrow one.
|
||||
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
|
||||
if (embed) return embed;
|
||||
const ogUrl = prev['og:url'];
|
||||
const resolvedEmbed =
|
||||
embed ??
|
||||
(typeof ogUrl === 'string' && ogUrl !== url
|
||||
? parseMediaEmbed(ogUrl, window.location.hostname)
|
||||
: null);
|
||||
return typeof ogUrl === 'string' && ogUrl !== url
|
||||
? 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) {
|
||||
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
|
||||
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);
|
||||
// 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 (
|
||||
<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)
|
||||
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;
|
||||
// `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 (
|
||||
<UrlPreview {...props} ref={ref} className={cardClass}>
|
||||
<UrlPreview {...props} ref={ref} className={buildCardClass(wide)}>
|
||||
{content}
|
||||
</UrlPreview>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading/idle: no preview data yet, so base `wide` on the url-only embed.
|
||||
return (
|
||||
<UrlPreview {...props} ref={ref} className={cardClass}>
|
||||
<UrlPreview
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={buildCardClass(!!embed || isTwitterTweet(url) || steamAppWide)}
|
||||
>
|
||||
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
</Box>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { UserAvatar } from '../user-avatar';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css';
|
||||
import { ModalMobileFull } from '../../styles/Modal.css';
|
||||
import { UserPresence } from '../../hooks/useUserPresence';
|
||||
import { AvatarPresence, PresenceBadge } from '../presence';
|
||||
import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration';
|
||||
@@ -83,7 +84,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal size="500" onContextMenu={(evt: any) => evt.stopPropagation()}>
|
||||
<Modal
|
||||
size="500"
|
||||
className={ModalMobileFull}
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
>
|
||||
<ImageViewer
|
||||
src={viewAvatar}
|
||||
alt={userId}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StatusDivider } from './components';
|
||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
|
||||
type MicrophoneButtonProps = {
|
||||
enabled: boolean;
|
||||
@@ -31,6 +32,7 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="300"
|
||||
className={MobileTouchTarget}
|
||||
onClick={toggleMic}
|
||||
outlined
|
||||
disabled={disabled || loading}
|
||||
@@ -66,6 +68,7 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="300"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => onToggle()}
|
||||
aria-label={enabled ? 'Deafen' : 'Undeafen'}
|
||||
aria-pressed={enabled}
|
||||
@@ -108,6 +111,7 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="300"
|
||||
className={MobileTouchTarget}
|
||||
onClick={toggleVideo}
|
||||
aria-label={enabled ? 'Stop Video' : 'Start Video'}
|
||||
aria-pressed={enabled}
|
||||
@@ -147,6 +151,7 @@ function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonPro
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="300"
|
||||
className={MobileTouchTarget}
|
||||
onClick={onToggle}
|
||||
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
|
||||
aria-pressed={enabled}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||
@@ -51,18 +52,25 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
const controlRef = useRef<HTMLDivElement>(null);
|
||||
const callEmbedRef = useCallEmbedRef();
|
||||
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);
|
||||
|
||||
useResizeObserver(
|
||||
useCallback(() => {
|
||||
const element = controlRef.current;
|
||||
if (!element) return;
|
||||
setCompact(element.clientWidth < 500);
|
||||
setNarrowContainer(element.clientWidth < 500);
|
||||
}, []),
|
||||
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(() => {
|
||||
const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange);
|
||||
@@ -330,6 +338,9 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
padding: '1rem 1.25rem',
|
||||
zIndex: 100,
|
||||
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)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -376,6 +387,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
radii="500"
|
||||
alignItems="Center"
|
||||
justifyContent="SpaceBetween"
|
||||
wrap="Wrap"
|
||||
>
|
||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||
<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;
|
||||
// guard those setState calls against the component unmounting first.
|
||||
const mountedRef = useRef(true);
|
||||
const safetyTimerRef = useRef<number | undefined>(undefined);
|
||||
useEffect(
|
||||
() => () => {
|
||||
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)
|
||||
setPlayingKey(flat.key);
|
||||
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 = () => {
|
||||
if (myTimer !== undefined) {
|
||||
window.clearTimeout(myTimer);
|
||||
if (safetyTimerRef.current === myTimer) safetyTimerRef.current = undefined;
|
||||
myTimer = undefined;
|
||||
}
|
||||
if (!mountedRef.current) return;
|
||||
setPlayingKey((k) => (k === flat.key ? undefined : k));
|
||||
};
|
||||
@@ -108,11 +120,12 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||
if (audio) {
|
||||
audio.addEventListener('ended', 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 {
|
||||
done();
|
||||
}
|
||||
// Safety: clear the guard even if the audio never signals end.
|
||||
window.setTimeout(done, 30_000);
|
||||
} catch {
|
||||
setError('Could not play that clip.');
|
||||
done();
|
||||
@@ -135,7 +148,12 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||
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
|
||||
shrink="No"
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds';
|
||||
import { useAtom } from 'jotai';
|
||||
import * as css from './styles.css';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
|
||||
@@ -36,6 +37,7 @@ export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
className={MobileTouchTarget}
|
||||
onClick={toggleMic}
|
||||
aria-label={enabled ? 'Turn Off Microphone' : 'Turn On Microphone'}
|
||||
outlined
|
||||
@@ -70,6 +72,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => onToggle()}
|
||||
aria-label={enabled ? 'Undeafen' : 'Deafen'}
|
||||
outlined
|
||||
@@ -113,6 +116,7 @@ export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
className={MobileTouchTarget}
|
||||
onClick={toggleVideo}
|
||||
outlined
|
||||
disabled={disabled || loading}
|
||||
@@ -155,6 +159,7 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps)
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => onToggle()}
|
||||
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
|
||||
outlined
|
||||
@@ -200,6 +205,7 @@ export function FullscreenButton({ isFullscreen, onToggle }: FullscreenButtonPro
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
className={MobileTouchTarget}
|
||||
onClick={onToggle}
|
||||
aria-label={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
||||
aria-pressed={isFullscreen}
|
||||
@@ -234,6 +240,7 @@ export function ScreenshareAudioButton({ muted, onToggle }: ScreenshareAudioButt
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
className={MobileTouchTarget}
|
||||
onClick={onToggle}
|
||||
aria-label={muted ? 'Unmute Screenshare Audio' : 'Mute Screenshare Audio'}
|
||||
aria-pressed={muted}
|
||||
@@ -266,6 +273,7 @@ export function ChatButton() {
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => setChat(!chat)}
|
||||
aria-label={chat ? 'Close Chat' : 'Open Chat'}
|
||||
aria-pressed={chat}
|
||||
|
||||
@@ -17,15 +17,29 @@ function useMediaPermissions(): MediaPermState {
|
||||
useEffect(() => {
|
||||
if (!navigator.permissions) {
|
||||
setState('unknown');
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
let permStatus: PermissionStatus | undefined;
|
||||
navigator.permissions
|
||||
.query({ name: 'microphone' as unknown as PermissionDescriptor['name'] })
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
permStatus = result;
|
||||
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;
|
||||
|
||||
@@ -119,7 +119,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
|
||||
return (
|
||||
<Box onSubmit={handleSubmit} as="form" direction="Column" gap="400">
|
||||
<Box direction="Column" gap="300">
|
||||
<Box gap="200">
|
||||
<Box gap="200" wrap="Wrap">
|
||||
<Box shrink="No" direction="Column" gap="100">
|
||||
<Text size="L400">Color</Text>
|
||||
<Box gap="200">
|
||||
|
||||
@@ -92,6 +92,16 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
const evId = ev.getId();
|
||||
if (!evId || seen.has(evId)) continue;
|
||||
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
|
||||
// (paginateEventTimeline may fetch events before the SDK decrypts them)
|
||||
if (ev.isEncrypted() && !ev.getClearContent()) {
|
||||
@@ -100,8 +110,6 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
}
|
||||
if (ev.getType() !== EventType.RoomMessage) continue;
|
||||
if (ev.isDecryptionFailure()) continue;
|
||||
const ts = ev.getTs();
|
||||
if (ts < oldestRawTs) oldestRawTs = ts;
|
||||
if (fromTs !== null && ts < fromTs) continue;
|
||||
if (toTs !== null && ts > toTs) continue;
|
||||
const content = ev.getContent();
|
||||
|
||||
@@ -307,7 +307,7 @@ export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
|
||||
gap="300"
|
||||
>
|
||||
{/* Tabs */}
|
||||
<Box gap="200">
|
||||
<Box gap="200" wrap="Wrap">
|
||||
<TabButton
|
||||
label="Users"
|
||||
count={userEntries.length}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { SequenceCard } from '../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { isValidServerPattern, matchesAnyGlob } from '../../utils/serverAcl';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
@@ -148,6 +149,7 @@ function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProp
|
||||
size="300"
|
||||
variant="Background"
|
||||
radii="300"
|
||||
className={MobileTouchTarget}
|
||||
aria-label={`Remove ${entry}`}
|
||||
onClick={() => onRemove(i)}
|
||||
style={{ flexShrink: 0 }}
|
||||
|
||||
@@ -190,6 +190,7 @@ function LightboxMedia({
|
||||
pan,
|
||||
cursor,
|
||||
onMouseDown,
|
||||
onTouchStart,
|
||||
onImageDoubleClick,
|
||||
}: {
|
||||
item: LightboxItem;
|
||||
@@ -198,6 +199,7 @@ function LightboxMedia({
|
||||
pan: Pan;
|
||||
cursor: string;
|
||||
onMouseDown: React.MouseEventHandler<HTMLElement>;
|
||||
onTouchStart: React.TouchEventHandler<HTMLElement>;
|
||||
onImageDoubleClick: () => void;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
@@ -253,6 +255,7 @@ function LightboxMedia({
|
||||
alt={item.body}
|
||||
draggable={false}
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
onDoubleClick={onImageDoubleClick}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
@@ -297,7 +300,7 @@ function Lightbox({
|
||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||
// 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).
|
||||
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]);
|
||||
|
||||
// Reset zoom when navigating to another item (and thus pan, via usePan).
|
||||
@@ -512,6 +515,7 @@ function Lightbox({
|
||||
pan={pan}
|
||||
cursor={cursor}
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
onImageDoubleClick={toggleZoom}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import classNames from 'classnames';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { Membership } from '../../../types/matrix/room';
|
||||
|
||||
import * as css from './MembersDrawer.css';
|
||||
@@ -460,6 +461,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
variant="Success"
|
||||
radii="300"
|
||||
fill="Soft"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => mx.invite(room.roomId, knockMember.userId)}
|
||||
>
|
||||
<Text size="B300">Approve</Text>
|
||||
@@ -469,6 +471,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
variant="Critical"
|
||||
radii="300"
|
||||
fill="Soft"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => mx.kick(room.roomId, knockMember.userId)}
|
||||
>
|
||||
<Text size="B300">Deny</Text>
|
||||
|
||||
@@ -230,7 +230,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
|
||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
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 showEmoji = composerToolbarButtons?.showEmoji ?? 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 (
|
||||
<div ref={ref}>
|
||||
{selectedFiles.length > 0 && (
|
||||
@@ -1035,16 +1047,31 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
)
|
||||
}
|
||||
before={
|
||||
<IconButton
|
||||
onClick={() => pickFile('*')}
|
||||
aria-label="Attach file"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
>
|
||||
<Icon src={Icons.PlusCircle} />
|
||||
</IconButton>
|
||||
isMobile ? (
|
||||
<IconButton
|
||||
onClick={() => setMobileToolsOpen((open) => !open)}
|
||||
aria-label="More actions"
|
||||
aria-expanded={mobileToolsOpen}
|
||||
aria-controls="composer-more-actions"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
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={(() => {
|
||||
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 (
|
||||
<>
|
||||
{orderedButtons}
|
||||
{isMobile ? emojiInline : orderedButtons}
|
||||
{gifError && (
|
||||
<Text
|
||||
size="T200"
|
||||
@@ -1365,12 +1420,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
);
|
||||
})()}
|
||||
bottom={
|
||||
toolbar && (
|
||||
<div>
|
||||
<Line variant="SurfaceVariant" size="300" />
|
||||
<Toolbar />
|
||||
</div>
|
||||
)
|
||||
<>
|
||||
{isMobile && mobileToolsOpen && composerOverflow && (
|
||||
<div>
|
||||
<Line variant="SurfaceVariant" size="300" />
|
||||
<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)} />}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
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 { ReactEditor } from 'slate-react';
|
||||
import { Transforms } from 'slate';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
@@ -152,17 +153,38 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
||||
<>
|
||||
{canMessage && (
|
||||
<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
|
||||
role="alert"
|
||||
style={{ padding: config.space.S200 }}
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="200"
|
||||
>
|
||||
<Text align="Center">
|
||||
Message composer encountered an error. Try refreshing.
|
||||
</Text>
|
||||
<Text align="Center">The message composer hit a snag.</Text>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
<Text size="B300">Reload composer</Text>
|
||||
</Button>
|
||||
</RoomInputPlaceholder>
|
||||
}
|
||||
)}
|
||||
>
|
||||
<RoomInput
|
||||
room={room}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
|
||||
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
|
||||
import { EventReaders } from '../../components/event-readers';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
||||
export function RoomViewFollowingPlaceholder() {
|
||||
@@ -34,6 +35,7 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
|
||||
({ className, room, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const modalStyle = useModalStyle(360);
|
||||
const latestEvent = useRoomLatestRenderedEvent(room);
|
||||
const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId());
|
||||
const names = latestEventReaders
|
||||
@@ -55,7 +57,7 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal variant="Surface" size="300">
|
||||
<Modal variant="Surface" size="300" style={modalStyle}>
|
||||
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
|
||||
@@ -65,6 +65,7 @@ import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||
import { msgTranslationActiveAtomFamily } from '../../../state/translation';
|
||||
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||
import * as css from './styles.css';
|
||||
import { MsgAppearClass, SendingSpinClass } from '../../../styles/Animations.css';
|
||||
@@ -260,6 +261,7 @@ export const MessageReadReceiptItem = as<
|
||||
}
|
||||
>(({ room, eventId, onClose, ...props }, ref) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const modalStyle = useModalStyle(360);
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
@@ -278,7 +280,7 @@ export const MessageReadReceiptItem = as<
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal variant="Surface" size="300">
|
||||
<Modal variant="Surface" size="300" style={modalStyle}>
|
||||
<EventReaders room={room} eventId={eventId} requestClose={handleClose} />
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
@@ -674,6 +676,7 @@ export const MessageReportItem = as<
|
||||
>(({ room, mEvent, onClose, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const modalStyle = useModalStyle(480);
|
||||
const [reportState, reportMessage] = useAsyncCallback(
|
||||
useCallback(
|
||||
(eventId: string, score: number, reason: string) =>
|
||||
@@ -715,7 +718,7 @@ export const MessageReportItem = as<
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface">
|
||||
<Dialog variant="Surface" style={modalStyle}>
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Badge, Box, Chip, Icon, Icons, Text, config } from 'folds';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { MobileTouchTarget } from '../../../styles/mobile.css';
|
||||
import { useThreadSummary } from '../../../hooks/useThreadSummary';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
@@ -31,6 +32,7 @@ export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="300"
|
||||
className={MobileTouchTarget}
|
||||
before={<Icon size="50" src={Icons.Thread} />}
|
||||
after={
|
||||
unread > 0 ? <Badge variant="Success" fill="Solid" radii="Pill" size="200" /> : undefined
|
||||
|
||||
@@ -4,11 +4,13 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
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 { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
|
||||
|
||||
type MSC1929Contact = {
|
||||
matrix_id?: string;
|
||||
email_address?: string;
|
||||
@@ -42,9 +44,16 @@ function useServerSupport(): { support: MSC1929Support | null; loading: boolean
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
fetch(`${baseUrl}/.well-known/matrix/support`, { signal: controller.signal })
|
||||
fetch(`https://${serverName}/.well-known/matrix/support`, { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
|
||||
@@ -4,11 +4,7 @@ import { Method } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import {
|
||||
DECORATION_CATEGORIES,
|
||||
DECORATION_CDN,
|
||||
decorationUrl,
|
||||
} from '../../lotus/avatarDecorations';
|
||||
import { DECORATION_CATEGORIES, decorationUrl } from '../../lotus/avatarDecorations';
|
||||
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
||||
|
||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||
@@ -48,7 +44,7 @@ function DecorationPreviewCell({
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`${DECORATION_CDN}/${slug}.png`}
|
||||
src={decorationUrl(slug)}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@@ -73,11 +69,11 @@ export function ProfileDecoration() {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
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
|
||||
.authedRequest<Record<string, string>>(
|
||||
Method.Get,
|
||||
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
|
||||
)
|
||||
.authedRequest<Record<string, string>>(Method.Get, `/profile/${encodeURIComponent(userId)}`)
|
||||
.then((res) => {
|
||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||
setCurrent(val);
|
||||
|
||||
@@ -129,6 +129,11 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
||||
try {
|
||||
const ctx = new AudioContext({ sampleRate: sampleRateFor(model) });
|
||||
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 inAnalyser = ctx.createAnalyser();
|
||||
inAnalyser.fftSize = 1024;
|
||||
@@ -182,7 +187,12 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
||||
timer: number;
|
||||
} | 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 [recDb, setRecDb] = useState(-100);
|
||||
const [hasClip, setHasClip] = useState(false);
|
||||
@@ -208,6 +218,10 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
||||
const startRecord = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(RAW_CONSTRAINTS);
|
||||
if (!mountedRef.current) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
const ctx = new AudioContext();
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
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(() => {
|
||||
playGenRef.current += 1;
|
||||
const p = playRef.current;
|
||||
playRef.current = null;
|
||||
if (p) {
|
||||
@@ -259,6 +281,15 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
||||
} catch {
|
||||
/* 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);
|
||||
}
|
||||
setPlaying(null);
|
||||
@@ -268,37 +299,73 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
||||
stopPlayback();
|
||||
const clip = clipRef.current;
|
||||
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 {
|
||||
// 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.
|
||||
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = clip;
|
||||
let playGate: AudioWorkletNode | null = null;
|
||||
let playModelNode: DenoiseNode | null = null;
|
||||
if (playModel) {
|
||||
let head: AudioNode = source;
|
||||
if (useGate) {
|
||||
const gate = await buildGateNode(ctx, gateThreshold);
|
||||
head.connect(gate);
|
||||
head = gate;
|
||||
playGate = await buildGateNode(ctx, gateThreshold);
|
||||
head.connect(playGate);
|
||||
head = playGate;
|
||||
}
|
||||
const denoise = await buildModelNode(ctx, playModel);
|
||||
head.connect(denoise.node);
|
||||
denoise.node.connect(ctx.destination);
|
||||
playModelNode = await buildModelNode(ctx, playModel);
|
||||
head.connect(playModelNode.node);
|
||||
playModelNode.node.connect(ctx.destination);
|
||||
} else {
|
||||
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 = () => {
|
||||
if (playRef.current?.ctx === ctx) stopPlayback();
|
||||
};
|
||||
playRef.current = { ctx, source };
|
||||
playRef.current = { ctx, source, model: playModelNode, gate: playGate };
|
||||
source.start();
|
||||
setPlaying(label);
|
||||
} catch (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(
|
||||
() => () => {
|
||||
stopLive();
|
||||
|
||||
@@ -549,7 +549,11 @@ function Appearance() {
|
||||
value={seasonalThemeOverride ?? 'auto'}
|
||||
onChange={(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>
|
||||
@@ -1898,27 +1902,27 @@ function Calls() {
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Ringtone Volume"
|
||||
description="Volume of the incoming call ringtone."
|
||||
after={
|
||||
<Box direction="Row" alignItems="Center" gap="200" style={{ minWidth: '160px' }}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={ringtoneVolume}
|
||||
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
|
||||
aria-label="Ringtone volume"
|
||||
style={{ flex: 1, accentColor: color.Primary.Main }}
|
||||
/>
|
||||
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
|
||||
{ringtoneVolume}%
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<SettingTile title="Ringtone Volume" description="Volume of the incoming call ringtone." />
|
||||
<Box
|
||||
direction="Row"
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={ringtoneVolume}
|
||||
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
|
||||
aria-label="Ringtone volume"
|
||||
style={{ flex: 1, accentColor: color.Primary.Main }}
|
||||
/>
|
||||
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
|
||||
{ringtoneVolume}%
|
||||
</Text>
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
@@ -1990,26 +1994,28 @@ function Calls() {
|
||||
}
|
||||
/>
|
||||
{soundboardEnabled && (
|
||||
<SettingTile
|
||||
title="Soundboard Volume"
|
||||
after={
|
||||
<Box alignItems="Center" gap="200" style={{ minWidth: toRem(180) }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={soundboardVolume}
|
||||
onChange={(e) => setSoundboardVolume(parseInt(e.target.value, 10))}
|
||||
style={{ flexGrow: 1 }}
|
||||
aria-label="Soundboard volume"
|
||||
/>
|
||||
<Text size="T200" style={{ minWidth: toRem(36), textAlign: 'right' }}>
|
||||
{soundboardVolume}%
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<SettingTile title="Soundboard Volume" />
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={soundboardVolume}
|
||||
onChange={(e) => setSoundboardVolume(parseInt(e.target.value, 10))}
|
||||
style={{ flexGrow: 1 }}
|
||||
aria-label="Soundboard volume"
|
||||
/>
|
||||
<Text size="T200" style={{ minWidth: toRem(36), textAlign: 'right' }}>
|
||||
{soundboardVolume}%
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
@@ -2454,19 +2460,13 @@ function Messages() {
|
||||
: 'On-device translation isn’t available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
|
||||
}
|
||||
after={
|
||||
<select
|
||||
aria-label="Translate messages into"
|
||||
disabled={!translationSupported}
|
||||
<SettingsSelect
|
||||
value={selectedTargetLang}
|
||||
onChange={(e) => setTranslateTargetLang(e.target.value)}
|
||||
style={pickerInputStyle(color, config)}
|
||||
>
|
||||
{TRANSLATE_TARGET_LANGUAGES.map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => setTranslateTargetLang(v)}
|
||||
disabled={!translationSupported}
|
||||
aria-label="Translate messages into"
|
||||
options={TRANSLATE_TARGET_LANGUAGES.map((l) => ({ value: l.code, label: l.name }))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{translationSupported && (
|
||||
|
||||
@@ -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 { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds';
|
||||
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||
@@ -56,6 +63,13 @@ function RuleEnableToggle({ kind, pushRule }: RuleEnableToggleProps) {
|
||||
const mx = useMatrixClient();
|
||||
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(
|
||||
useCallback(
|
||||
async (value: boolean) => {
|
||||
|
||||
@@ -381,6 +381,11 @@ export function SystemNotification() {
|
||||
style={selectStyle}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</SequenceCard>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef, CSSProperties } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { color, config, Icon, IconButton, Icons } from 'folds';
|
||||
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
|
||||
import { useSetting } from '../../state/hooks/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-*
|
||||
// vars only exist while Terminal mode is active).
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
const isMobile = useScreenSize() === ScreenSize.Mobile;
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -80,8 +82,11 @@ function ToastCard({ toast }: ToastCardProps) {
|
||||
}`,
|
||||
borderRadius: config.radii.R400,
|
||||
padding: `${config.space.S300} ${config.space.S400}`,
|
||||
minWidth: '280px',
|
||||
maxWidth: '340px',
|
||||
// Full-width on phones (the container spans the viewport there); a fixed
|
||||
// 280-340px card would otherwise overflow a narrow screen.
|
||||
minWidth: isMobile ? 0 : '280px',
|
||||
maxWidth: isMobile ? 'none' : '340px',
|
||||
width: isMobile ? '100%' : undefined,
|
||||
boxShadow: lotusTerminal
|
||||
? toast.sticky
|
||||
? 'var(--lt-box-glow-cyan)'
|
||||
@@ -216,22 +221,39 @@ export function LotusToastContainer() {
|
||||
}, []);
|
||||
|
||||
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;
|
||||
|
||||
const containerStyle: CSSProperties = {
|
||||
position: 'fixed',
|
||||
bottom: '1.5rem',
|
||||
right: '1.5rem',
|
||||
// Span the width just inside the screen edges on a phone (so full-width
|
||||
// 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,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S200,
|
||||
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 (
|
||||
<div style={containerStyle} aria-live="polite" aria-label="Notifications">
|
||||
<div ref={listRef} style={containerStyle} aria-live="polite" aria-label="Notifications">
|
||||
{toasts.map((toast) => (
|
||||
<ToastCard key={toast.id} toast={toast} />
|
||||
))}
|
||||
|
||||
@@ -12,6 +12,22 @@ const pending = new Map<string, Array<(val: string | null) => void>>();
|
||||
// Transient-failure attempt counts (userId → n) so a flaky federated lookup
|
||||
// can retry a couple of times, then gives up for the session.
|
||||
const failures = new Map<string, number>();
|
||||
// Mounted hooks per userId, so an invalidation (e.g. you change your own
|
||||
// decoration) re-fetches live instead of waiting for a remount.
|
||||
const listeners = new Map<string, Set<() => void>>();
|
||||
|
||||
function subscribeDecoration(userId: string, cb: () => void): () => void {
|
||||
let set = listeners.get(userId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listeners.set(userId, set);
|
||||
}
|
||||
set.add(cb);
|
||||
return () => {
|
||||
set.delete(cb);
|
||||
if (set.size === 0) listeners.delete(userId);
|
||||
};
|
||||
}
|
||||
|
||||
function fetchDecoration(
|
||||
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
|
||||
@@ -66,6 +82,10 @@ function fetchDecoration(
|
||||
|
||||
export function invalidateDecorationCache(userId: string): void {
|
||||
cache.delete(userId);
|
||||
// Also clear the give-up counter so the next fetch starts fresh.
|
||||
failures.delete(userId);
|
||||
// Notify mounted avatars for this user so they re-fetch immediately.
|
||||
listeners.get(userId)?.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
export function useAvatarDecoration(userId: string): string | null {
|
||||
@@ -74,14 +94,22 @@ export function useAvatarDecoration(userId: string): string | null {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchDecoration(
|
||||
(method, path) => mx.http.authedRequest<Record<string, string>>(method, path),
|
||||
userId,
|
||||
).then((val) => {
|
||||
if (!cancelled) setSlug(val);
|
||||
});
|
||||
const load = () => {
|
||||
fetchDecoration(
|
||||
(method, path) => mx.http.authedRequest<Record<string, string>>(method, path),
|
||||
userId,
|
||||
).then((val) => {
|
||||
if (!cancelled) setSlug(val);
|
||||
});
|
||||
};
|
||||
load();
|
||||
// Re-run on invalidation (fetchDecoration re-fetches since the cache entry
|
||||
// was cleared; concurrent mounts for the same user still de-dupe via
|
||||
// `pending`). Keeps live avatars in sync when the decoration changes.
|
||||
const unsubscribe = subscribeDecoration(userId, load);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [mx, userId]);
|
||||
|
||||
|
||||
@@ -25,8 +25,14 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
||||
const callMembers = useCallMembers(callSession);
|
||||
const joined = useCallJoined(callEmbed);
|
||||
|
||||
// C-L5 — depend on a STABLE boolean, not the callMembers array (whose identity
|
||||
// changes on every membership change). The MutationObserver + io.lotus.call_state
|
||||
// subscription below already track tiles joining/leaving live, so rebuilding
|
||||
// them on each membership change is pure churn.
|
||||
const hasCallMembers = callMembers.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!callMembers || !joined) {
|
||||
if (!hasCallMembers || !joined) {
|
||||
setSpeakers(new Set<string>());
|
||||
return undefined;
|
||||
}
|
||||
@@ -126,7 +132,7 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
||||
bodyWatcher?.disconnect();
|
||||
unsubLotus();
|
||||
};
|
||||
}, [callEmbed, callMembers, joined]);
|
||||
}, [callEmbed, hasCallMembers, joined]);
|
||||
|
||||
return speakers;
|
||||
};
|
||||
|
||||
+46
-1
@@ -1,4 +1,4 @@
|
||||
import { MouseEventHandler, useEffect, useRef, useState } from 'react';
|
||||
import { MouseEventHandler, TouchEventHandler, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type Pan = {
|
||||
translateX: number;
|
||||
@@ -21,6 +21,10 @@ export const usePan = (active: boolean) => {
|
||||
const attachedRef = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
|
||||
null,
|
||||
);
|
||||
const touchAttachedRef = useRef<{
|
||||
move: (e: TouchEvent) => void;
|
||||
end: (e: TouchEvent) => void;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCursor(active ? 'grab' : 'initial');
|
||||
@@ -53,6 +57,40 @@ export const usePan = (active: boolean) => {
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// Touch equivalent so a zoomed image can be dragged on a phone. Single-finger
|
||||
// only (ignore multi-touch / pinch); touch events carry no movementX/Y, so we
|
||||
// derive the delta from the previous touch position.
|
||||
const handleTouchStart: TouchEventHandler<HTMLElement> = (evt) => {
|
||||
if (!active || evt.touches.length !== 1) return;
|
||||
setCursor('grabbing');
|
||||
let lastX = evt.touches[0].clientX;
|
||||
let lastY = evt.touches[0].clientY;
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
const dx = t.clientX - lastX;
|
||||
const dy = t.clientY - lastY;
|
||||
lastX = t.clientX;
|
||||
lastY = t.clientY;
|
||||
setPan((p) => ({ translateX: p.translateX + dx, translateY: p.translateY + dy }));
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setCursor('grab');
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
document.removeEventListener('touchcancel', handleTouchEnd);
|
||||
touchAttachedRef.current = null;
|
||||
};
|
||||
|
||||
touchAttachedRef.current = { move: handleTouchMove, end: handleTouchEnd };
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
document.addEventListener('touchcancel', handleTouchEnd);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) setPan(INITIAL_PAN);
|
||||
}, [active]);
|
||||
@@ -65,6 +103,12 @@ export const usePan = (active: boolean) => {
|
||||
document.removeEventListener('mouseup', attachedRef.current.up);
|
||||
attachedRef.current = null;
|
||||
}
|
||||
if (touchAttachedRef.current) {
|
||||
document.removeEventListener('touchmove', touchAttachedRef.current.move);
|
||||
document.removeEventListener('touchend', touchAttachedRef.current.end);
|
||||
document.removeEventListener('touchcancel', touchAttachedRef.current.end);
|
||||
touchAttachedRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -73,5 +117,6 @@ export const usePan = (active: boolean) => {
|
||||
pan,
|
||||
cursor,
|
||||
onMouseDown: handleMouseDown,
|
||||
onTouchStart: handleTouchStart,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,21 +36,19 @@ const getJoinedRoomIds = (mx: MatrixClient): Set<string> => {
|
||||
return joined;
|
||||
};
|
||||
|
||||
const writeThreadNotificationMode = async (
|
||||
mx: MatrixClient,
|
||||
// Apply a single mode change to a base content object, returning a fresh clone
|
||||
// (never mutates the input).
|
||||
const applyThreadMode = (
|
||||
base: ThreadNotificationsContent,
|
||||
roomId: string,
|
||||
threadRootId: string,
|
||||
mode: ThreadNotificationMode,
|
||||
): Promise<void> => {
|
||||
const current = readContent(mx);
|
||||
const now = Date.now();
|
||||
|
||||
// Work on a mutable clone; prune produces a fresh object so the mutations
|
||||
// below never touch the atom's/account-data's current content.
|
||||
now: number,
|
||||
): ThreadNotificationsContent => {
|
||||
const next: ThreadNotificationsContent = {
|
||||
...current,
|
||||
...base,
|
||||
rooms: Object.fromEntries(
|
||||
Object.entries(current.rooms ?? {}).map(([rid, entries]) => [rid, { ...entries }]),
|
||||
Object.entries(base.rooms ?? {}).map(([rid, entries]) => [rid, { ...entries }]),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -70,10 +68,46 @@ const writeThreadNotificationMode = async (
|
||||
rooms[roomId][threadRootId] = { mode, ts: now };
|
||||
}
|
||||
|
||||
// ALWAYS prune before persisting to keep account data bounded.
|
||||
const finalContent = pruneThreadNotifications(next, getJoinedRoomIds(mx), now);
|
||||
return next;
|
||||
};
|
||||
|
||||
await setAccountData(mx, AccountDataEvent.LotusThreadNotifications, finalContent);
|
||||
// T7 — serialize writes so rapid, overlapping mode changes don't lost-update
|
||||
// each other. `setAccountData` is a bare PUT whose result doesn't reach the
|
||||
// local store until the /sync echo, so back-to-back writes would otherwise all
|
||||
// read the same stale base and clobber one another. Each queued write instead
|
||||
// bases its mutation on the previous write's RESULT; once the queue drains the
|
||||
// carried base is dropped so the next independent write re-reads fresh (possibly
|
||||
// externally-changed) server state.
|
||||
let writeChain: Promise<unknown> = Promise.resolve();
|
||||
let pendingWrites = 0;
|
||||
let carriedContent: ThreadNotificationsContent | null = null;
|
||||
|
||||
const writeThreadNotificationMode = (
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
threadRootId: string,
|
||||
mode: ThreadNotificationMode,
|
||||
): Promise<void> => {
|
||||
pendingWrites += 1;
|
||||
const run = writeChain.then(async () => {
|
||||
const now = Date.now();
|
||||
const base = carriedContent ?? readContent(mx);
|
||||
const next = applyThreadMode(base, roomId, threadRootId, mode, now);
|
||||
// ALWAYS prune before persisting to keep account data bounded.
|
||||
const finalContent = pruneThreadNotifications(next, getJoinedRoomIds(mx), now);
|
||||
await setAccountData(mx, AccountDataEvent.LotusThreadNotifications, finalContent);
|
||||
// Carry the result forward only on success, so a queued follow-up write bases
|
||||
// on persisted content — never on a shape the server just rejected.
|
||||
carriedContent = finalContent;
|
||||
});
|
||||
// Keep the chain alive on error; drop the carried base once the queue drains.
|
||||
writeChain = run
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
pendingWrites -= 1;
|
||||
if (pendingWrites === 0) carriedContent = null;
|
||||
});
|
||||
return run;
|
||||
};
|
||||
|
||||
export function useSetThreadNotificationMode(
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '../../hooks/useClientConfig';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
|
||||
import LotusLogo from '../../../../public/res/Lotus.png';
|
||||
import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
|
||||
import { ServerPicker } from './ServerPicker';
|
||||
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
|
||||
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
|
||||
@@ -31,6 +31,8 @@ import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
|
||||
import { AuthServerProvider } from '../../hooks/useAuthServer';
|
||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||
|
||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
|
||||
|
||||
const currentAuthPath = (pathname: string): string => {
|
||||
if (matchPath(LOGIN_PATH, pathname)) {
|
||||
return LOGIN_PATH;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk';
|
||||
import LotusLogo from '../../../../../public/res/Lotus.png';
|
||||
import { OIDC_CALLBACK_PATH } from '../../paths';
|
||||
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
|
||||
|
||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
|
||||
|
||||
/**
|
||||
* Absolute URL the OIDC provider redirects back to after authorization.
|
||||
*
|
||||
|
||||
@@ -17,9 +17,6 @@ import { manualDndAtom } from '../../state/manualDnd';
|
||||
import { isSnoozeActive, notificationSnoozeUntilAtom } from '../../state/notificationSnooze';
|
||||
import { isWithinTimeWindow } from '../../utils/timeWindow';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import LogoSVG from '../../../../public/res/lotus.png';
|
||||
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
||||
import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
|
||||
import NotificationSound from '../../../../public/sound/notification.ogg';
|
||||
import InviteSound from '../../../../public/sound/invite.ogg';
|
||||
import { notificationPermission, setFavicon, showOsNotification } from '../../utils/dom';
|
||||
@@ -29,7 +26,13 @@ import { settingsAtom } from '../../state/settings';
|
||||
import { allInvitesAtom } from '../../state/room-list/inviteList';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
|
||||
import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pathUtils';
|
||||
import {
|
||||
getDirectRoomPath,
|
||||
getHomeRoomPath,
|
||||
getInboxInvitesPath,
|
||||
getOriginBaseUrl,
|
||||
withOriginBaseUrl,
|
||||
} from '../pathUtils';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import {
|
||||
getMemberName,
|
||||
@@ -69,6 +72,10 @@ import {
|
||||
|
||||
// Grace period after the initial sync settles before invite notifications arm, so
|
||||
// the async invite-atom population lands first and isn't mistaken for new invites.
|
||||
const LogoSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus.png');
|
||||
const LogoUnreadSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-unread.png');
|
||||
const LogoHighlightSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-highlight.png');
|
||||
|
||||
const INVITE_NOTIFY_ARM_DELAY_MS = 3000;
|
||||
|
||||
function SystemEmojiFeature() {
|
||||
@@ -531,7 +538,7 @@ function MessageNotifications() {
|
||||
// thread path is already gated by shouldNotifyThreadReply, so it must NOT
|
||||
// re-gate on the room count — otherwise an explicit per-thread "All replies"
|
||||
// override in a Mentions-only room is silently dropped.
|
||||
if (!threadId && getUnreadInfo(room).total === 0) return;
|
||||
if (!threadId && getUnreadInfo(room, undefined, mx).total === 0) return;
|
||||
|
||||
lastNotifiedEventRef.current.set(dedupeKey, eventId);
|
||||
|
||||
@@ -644,13 +651,24 @@ function MessageNotifications() {
|
||||
const content = threadPrefs;
|
||||
const mode = getThreadNotificationMode(content, room.roomId, thread.id);
|
||||
const actions = mx.getPushActionsForEvent(mEvent);
|
||||
// `hasCurrentUserParticipated` is derived from the server thread bundle,
|
||||
// which lags a reply we just sent — so also treat any of our own events
|
||||
// already in the thread timeline as participation (T5: avoid under-notify).
|
||||
const myUserId = mx.getUserId();
|
||||
const participated =
|
||||
thread.hasCurrentUserParticipated ||
|
||||
thread.timeline.some((e) => e.getSender() === myUserId);
|
||||
const roomNotifType = getNotificationType(mx, room.roomId);
|
||||
const decision = shouldNotifyThreadReply({
|
||||
mode,
|
||||
defaultBehavior: content.default ?? THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
||||
participated: thread.hasCurrentUserParticipated,
|
||||
participated,
|
||||
highlight: !!actions?.tweaks?.highlight,
|
||||
notify: !!actions?.notify,
|
||||
roomMuted: getNotificationType(mx, room.roomId) === NotificationType.Mute,
|
||||
roomMuted: roomNotifType === NotificationType.Mute,
|
||||
// T6: honor a room-level "Mentions & Keywords only" setting for Default
|
||||
// threads instead of over-notifying every participated reply.
|
||||
roomMentionsOnly: roomNotifType === NotificationType.MentionsAndKeywords,
|
||||
});
|
||||
if (decision === 'none') return;
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
|
||||
import { Page, PageHero, PageHeroSection } from '../../components/page';
|
||||
import LotusLogo from '../../../../public/res/Lotus.png';
|
||||
import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
|
||||
import pkg from '../../../../package.json';
|
||||
|
||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
|
||||
|
||||
export function WelcomePage() {
|
||||
return (
|
||||
<Page>
|
||||
|
||||
@@ -27,8 +27,11 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Unread } from '../../../../types/matrix/room';
|
||||
import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort';
|
||||
import {
|
||||
factoryRoomIdByActivity,
|
||||
factoryRoomIdByAtoZ,
|
||||
factoryRoomIdByUnread,
|
||||
} from '../../../utils/sort';
|
||||
import {
|
||||
NavButton,
|
||||
NavCategory,
|
||||
@@ -210,17 +213,6 @@ function HomeEmpty() {
|
||||
);
|
||||
}
|
||||
|
||||
const factoryRoomIdByUnread =
|
||||
(roomToUnread: Map<string, Unread>) =>
|
||||
(aId: string, bId: string): number => {
|
||||
const aUnread = roomToUnread.get(aId);
|
||||
const bUnread = roomToUnread.get(bId);
|
||||
const aHas = (aUnread?.total ?? 0) > 0;
|
||||
const bHas = (bUnread?.total ?? 0) > 0;
|
||||
if (aHas !== bHas) return aHas ? -1 : 1;
|
||||
return (bUnread?.total ?? 0) - (aUnread?.total ?? 0);
|
||||
};
|
||||
|
||||
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
||||
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
||||
const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority');
|
||||
@@ -331,7 +323,7 @@ export function Home() {
|
||||
} else if (homeRoomSort === 'alpha') {
|
||||
comparator = factoryRoomIdByAtoZ(mx);
|
||||
} else if (homeRoomSort === 'unread') {
|
||||
comparator = factoryRoomIdByUnread(roomToUnread);
|
||||
comparator = factoryRoomIdByUnread(roomToUnread, mx);
|
||||
} else {
|
||||
comparator = factoryRoomIdByActivity(mx);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getHexcodeForEmoji, getShortcodeFor, getShortcodesFor } from './emoji';
|
||||
|
||||
describe('getHexcodeForEmoji', () => {
|
||||
it('converts a single astral codepoint to an uppercase hexcode', () => {
|
||||
// 😀 = U+1F600
|
||||
assert.equal(getHexcodeForEmoji('😀'), '1F600');
|
||||
});
|
||||
|
||||
it('zero-pads BMP codepoints to at least four hex digits', () => {
|
||||
// ☺ = U+263A ; # = U+0023 (must pad "23" -> "0023")
|
||||
assert.equal(getHexcodeForEmoji('☺'), '263A');
|
||||
assert.equal(getHexcodeForEmoji('#'), '0023');
|
||||
});
|
||||
|
||||
it('strips the FE0F variation selector by default', () => {
|
||||
// ❤️ = U+2764 U+FE0F
|
||||
assert.equal(getHexcodeForEmoji('❤️'), '2764');
|
||||
});
|
||||
|
||||
it('keeps the variation selector when strip is false', () => {
|
||||
assert.equal(getHexcodeForEmoji('❤️', false), '2764-FE0F');
|
||||
});
|
||||
|
||||
it('strips ZWJ (200D) joiners from a sequence by default', () => {
|
||||
// 👨👩👧 = 1F468 200D 1F469 200D 1F467
|
||||
assert.equal(getHexcodeForEmoji('👨👩👧'), '1F468-1F469-1F467');
|
||||
});
|
||||
|
||||
it('keeps ZWJ joiners when strip is false', () => {
|
||||
assert.equal(getHexcodeForEmoji('👨👩👧', false), '1F468-200D-1F469-200D-1F467');
|
||||
});
|
||||
|
||||
it('strips the FE0E text-presentation selector too', () => {
|
||||
// ▶ = U+25B6 ; ▶︎ = U+25B6 U+FE0E (text presentation)
|
||||
assert.equal(getHexcodeForEmoji('▶︎'), '25B6');
|
||||
assert.equal(getHexcodeForEmoji('▶︎', false), '25B6-FE0E');
|
||||
});
|
||||
|
||||
it('handles a keycap sequence (padding + selector strip together)', () => {
|
||||
// #️⃣ = U+0023 U+FE0F U+20E3 -> "0023" + (FE0F stripped) + "20E3"
|
||||
assert.equal(getHexcodeForEmoji('#️⃣'), '0023-20E3');
|
||||
});
|
||||
|
||||
it('handles degenerate inputs (empty string, plain ASCII per codepoint)', () => {
|
||||
assert.equal(getHexcodeForEmoji(''), '');
|
||||
assert.equal(getHexcodeForEmoji('ab'), '0061-0062');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShortcodesFor / getShortcodeFor before emoji data is loaded', () => {
|
||||
// These gracefully degrade to `undefined` until loadEmojiData() has populated
|
||||
// the shortcode maps — the contract that lets tooltips/aria-labels render
|
||||
// eagerly without pulling the emojibase runtime into the eager graph.
|
||||
it('returns undefined for getShortcodesFor', () => {
|
||||
assert.equal(getShortcodesFor('1F600'), undefined);
|
||||
});
|
||||
|
||||
it('returns undefined for getShortcodeFor', () => {
|
||||
assert.equal(getShortcodeFor('1F600'), undefined);
|
||||
});
|
||||
});
|
||||
@@ -455,6 +455,17 @@ export const getReactCustomHtmlParser = (
|
||||
return <CodeBlock opts={opts}>{children}</CodeBlock>;
|
||||
}
|
||||
|
||||
if (name === 'table') {
|
||||
// Sanitize allows tables, but a wide one would otherwise overflow the
|
||||
// message column and the page body on narrow screens. Wrap it in a
|
||||
// horizontally-scrollable container (same idea as CodeBlock's Scroll).
|
||||
return (
|
||||
<div style={{ overflowX: 'auto', maxWidth: '100%' }}>
|
||||
<table {...props}>{domToReact(children as unknown as DOMNode[], opts)}</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'blockquote') {
|
||||
return (
|
||||
<Text {...props} size="Inherit" as="blockquote" className={css.BlockQuote}>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStore } from 'jotai';
|
||||
import { enableMapSet } from 'immer';
|
||||
import { makeClosedLobbyCategoriesAtom, makeLobbyCategoryId } from './closedLobbyCategories';
|
||||
|
||||
// makeClosedLobbyCategoriesAtom(userId) builds a Set<string> atom whose reducer uses
|
||||
// immer produce (PUT add / DELETE delete) and persists to a per-user localStorage
|
||||
// key `closedLobbyCategories<userId>`. The reducers produce over a Set, so enable
|
||||
// immer's Map/Set plugin (the app does this once at startup).
|
||||
// makeLobbyCategoryId joins args with '|'. (Mirrors closedNavCategories.test.ts.)
|
||||
enableMapSet();
|
||||
|
||||
type Store = Record<string, string>;
|
||||
const installLocalStorage = (): Store => {
|
||||
const data: Store = {};
|
||||
const ls = {
|
||||
getItem: (k: string) => (k in data ? data[k] : null),
|
||||
setItem: (k: string, v: string) => {
|
||||
data[k] = String(v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
delete data[k];
|
||||
},
|
||||
};
|
||||
(globalThis as { localStorage?: unknown }).localStorage = ls;
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
test('makeLobbyCategoryId joins args with "|"', () => {
|
||||
assert.equal(makeLobbyCategoryId('space', 'catA'), 'space|catA');
|
||||
assert.equal(makeLobbyCategoryId('only'), 'only');
|
||||
assert.equal(makeLobbyCategoryId(), '');
|
||||
});
|
||||
|
||||
test('starts empty when nothing is stored', () => {
|
||||
installLocalStorage();
|
||||
const store = createStore();
|
||||
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
|
||||
assert.equal(store.get(lobbyAtom).size, 0);
|
||||
});
|
||||
|
||||
test('hydrates the Set from the per-user localStorage key', () => {
|
||||
const data = installLocalStorage();
|
||||
data['closedLobbyCategories@u:server'] = JSON.stringify(['x', 'y']);
|
||||
const store = createStore();
|
||||
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
|
||||
assert.deepEqual(Array.from(store.get(lobbyAtom)).sort(), ['x', 'y']);
|
||||
});
|
||||
|
||||
test('PUT adds a category and DELETE removes it', () => {
|
||||
installLocalStorage();
|
||||
const store = createStore();
|
||||
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
|
||||
|
||||
store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' });
|
||||
assert.deepEqual(Array.from(store.get(lobbyAtom)), ['cat1']);
|
||||
|
||||
store.set(lobbyAtom, { type: 'DELETE', categoryId: 'cat1' });
|
||||
assert.equal(store.get(lobbyAtom).has('cat1'), false);
|
||||
});
|
||||
|
||||
test('PUT of an existing category is idempotent', () => {
|
||||
installLocalStorage();
|
||||
const store = createStore();
|
||||
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
|
||||
store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' });
|
||||
store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' });
|
||||
assert.equal(store.get(lobbyAtom).size, 1);
|
||||
});
|
||||
|
||||
test('DELETE of an absent category is a no-op', () => {
|
||||
installLocalStorage();
|
||||
const store = createStore();
|
||||
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
|
||||
store.set(lobbyAtom, { type: 'DELETE', categoryId: 'missing' });
|
||||
assert.equal(store.get(lobbyAtom).size, 0);
|
||||
});
|
||||
|
||||
test('writes persist to localStorage as an array', () => {
|
||||
const data = installLocalStorage();
|
||||
const store = createStore();
|
||||
const lobbyAtom = makeClosedLobbyCategoriesAtom('@u:server');
|
||||
store.set(lobbyAtom, { type: 'PUT', categoryId: 'cat1' });
|
||||
assert.deepEqual(JSON.parse(data['closedLobbyCategories@u:server']), ['cat1']);
|
||||
});
|
||||
|
||||
test('the storage key is namespaced per user', () => {
|
||||
const data = installLocalStorage();
|
||||
const store = createStore();
|
||||
const aAtom = makeClosedLobbyCategoriesAtom('@a:server');
|
||||
const bAtom = makeClosedLobbyCategoriesAtom('@b:server');
|
||||
|
||||
store.set(aAtom, { type: 'PUT', categoryId: 'only-a' });
|
||||
assert.deepEqual(JSON.parse(data['closedLobbyCategories@a:server']), ['only-a']);
|
||||
assert.equal(data['closedLobbyCategories@b:server'], undefined);
|
||||
assert.equal(store.get(bAtom).size, 0);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { MatrixClient, MatrixEvent, MatrixEventEvent, Room } from 'matrix-js-sdk';
|
||||
import { roomHaveNotification, unreadIsOnlyVerification } from '../../utils/room';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
|
||||
/**
|
||||
* A COMPLETED in-room device-verification request is a plain `m.room.message`
|
||||
* that permanently keeps a DM's server/SDK notification count > 0 (it matches the
|
||||
* default DM push rule and there's no recency gate), so the DM re-lights as unread
|
||||
* on every fresh sync. `getUnreadInfo`'s suppression hides the dot, but the raw
|
||||
* SDK count stays "dirty" (desktop badge, other consumers) and the SDK re-inflates
|
||||
* it on every decrypt. The only durable, SDK-supported fix is a read receipt that
|
||||
* covers the request event.
|
||||
*
|
||||
* This hook sends that receipt — but ONLY when a room's ENTIRE unread span is
|
||||
* verification-flow events (`unreadIsOnlyVerification`), so it can never mark a
|
||||
* real unread message read. It fires at most once per room per session, after the
|
||||
* tail decrypts (the count is only attributable to the request post-decryption).
|
||||
* `markAsRead` honours the user's private-read-receipt setting.
|
||||
*/
|
||||
export const useAutoMarkVerificationRead = (mx: MatrixClient): void => {
|
||||
const doneRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const done = doneRef.current;
|
||||
|
||||
const maybeMark = (room: Room) => {
|
||||
const { roomId } = room;
|
||||
if (done.has(roomId)) return;
|
||||
if (room.getMyMembership() !== 'join') return;
|
||||
// Only touch rooms the SDK actually counts as notifying...
|
||||
if (!roomHaveNotification(room)) return;
|
||||
// ...and only when the whole unread span is a completed verification.
|
||||
if (!unreadIsOnlyVerification(room, mx.getUserId())) return;
|
||||
|
||||
done.add(roomId);
|
||||
markAsRead(mx, roomId, false).catch(() => {
|
||||
// Let a later decrypt/sweep retry on transient failure.
|
||||
done.delete(roomId);
|
||||
});
|
||||
};
|
||||
|
||||
// Sweep once on mount (after initial sync some verification tails are already
|
||||
// decrypted), then re-check whenever an event decrypts — the count only
|
||||
// becomes attributable to the verification request once it's decrypted.
|
||||
mx.getRooms().forEach(maybeMark);
|
||||
|
||||
const onDecrypted = (event: MatrixEvent) => {
|
||||
const roomId = event.getRoomId();
|
||||
const room = roomId ? mx.getRoom(roomId) : null;
|
||||
if (room) maybeMark(room);
|
||||
};
|
||||
mx.on(MatrixEventEvent.Decrypted, onDecrypted);
|
||||
return () => {
|
||||
mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
|
||||
};
|
||||
}, [mx]);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { markedUnreadAtom, useBindMarkedUnreadAtom } from '../room/markedUnread'
|
||||
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
|
||||
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
||||
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
|
||||
import { useAutoMarkVerificationRead } from './useAutoMarkVerificationRead';
|
||||
|
||||
export const useBindAtoms = (mx: MatrixClient) => {
|
||||
useBindMDirectAtom(mx, mDirectAtom);
|
||||
@@ -16,6 +17,7 @@ export const useBindAtoms = (mx: MatrixClient) => {
|
||||
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
|
||||
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
|
||||
useBindMarkedUnreadAtom(mx, markedUnreadAtom);
|
||||
useAutoMarkVerificationRead(mx);
|
||||
|
||||
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
||||
};
|
||||
|
||||
@@ -254,6 +254,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
||||
unreadInfo: getUnreadInfo(
|
||||
room,
|
||||
getMutedThreads(threadNotificationsRef.current, room.roomId),
|
||||
mx,
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -332,6 +333,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
||||
unreadInfo: getUnreadInfo(
|
||||
room,
|
||||
getMutedThreads(threadNotificationsRef.current, room.roomId),
|
||||
mx,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -7,12 +7,13 @@ import { toastQueueAtom, dismissToastAtom, ToastNotif, createDownloadToast } fro
|
||||
// (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id)
|
||||
// through a jotai store and read back via toastQueueAtom's getter.
|
||||
|
||||
const makeToast = (id: string): ToastNotif => ({
|
||||
const makeToast = (id: string, sticky?: boolean): ToastNotif => ({
|
||||
id,
|
||||
displayName: `name-${id}`,
|
||||
body: `body-${id}`,
|
||||
roomName: `room-${id}`,
|
||||
roomId: `!${id}:server`,
|
||||
...(sticky ? { sticky: true } : {}),
|
||||
});
|
||||
|
||||
test('starts empty', () => {
|
||||
@@ -86,6 +87,46 @@ test('dismissToastAtom for an unknown id is a no-op', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('toastQueueAtom caps at 5, dropping the oldest non-sticky', () => {
|
||||
const store = createStore();
|
||||
// Append 7 transient toasts; the queue should keep only the newest 5.
|
||||
for (let i = 0; i < 7; i += 1) store.set(toastQueueAtom, makeToast(`t${i}`));
|
||||
assert.deepEqual(
|
||||
store.get(toastQueueAtom).map((t) => t.id),
|
||||
['t2', 't3', 't4', 't5', 't6'],
|
||||
);
|
||||
});
|
||||
|
||||
test('toastQueueAtom never drops a sticky toast, even over cap', () => {
|
||||
const store = createStore();
|
||||
store.set(toastQueueAtom, makeToast('sticky-old', true));
|
||||
for (let i = 0; i < 7; i += 1) store.set(toastQueueAtom, makeToast(`t${i}`));
|
||||
const ids = store.get(toastQueueAtom).map((t) => t.id);
|
||||
// The sticky action toast survives; the oldest non-sticky ones are dropped.
|
||||
assert.ok(ids.includes('sticky-old'));
|
||||
assert.ok(ids.includes('t6')); // newest kept
|
||||
assert.ok(!ids.includes('t0')); // oldest non-sticky dropped
|
||||
assert.equal(ids.length, 5);
|
||||
});
|
||||
|
||||
test('toastQueueAtom queue of all-sticky toasts is allowed to exceed the cap', () => {
|
||||
const store = createStore();
|
||||
for (let i = 0; i < 7; i += 1) store.set(toastQueueAtom, makeToast(`s${i}`, true));
|
||||
// Nothing droppable → all 7 retained rather than silently losing action toasts.
|
||||
assert.equal(store.get(toastQueueAtom).length, 7);
|
||||
});
|
||||
|
||||
test('toastQueueAtom keeps a new transient toast even when the cap is full of stickies', () => {
|
||||
const store = createStore();
|
||||
// Fill the cap with sticky action toasts, then a normal message toast arrives.
|
||||
for (let i = 0; i < 5; i += 1) store.set(toastQueueAtom, makeToast(`s${i}`, true));
|
||||
store.set(toastQueueAtom, makeToast('fresh'));
|
||||
const ids = store.get(toastQueueAtom).map((t) => t.id);
|
||||
// The newest is never the one dropped — the queue stretches instead of eating it.
|
||||
assert.ok(ids.includes('fresh'));
|
||||
assert.equal(ids.length, 6);
|
||||
});
|
||||
|
||||
test('createDownloadToast: filename in body, no room navigation, unique ids', () => {
|
||||
const a = createDownloadToast('photo.jpg');
|
||||
assert.equal(a.displayName, 'Downloaded');
|
||||
|
||||
+17
-1
@@ -46,12 +46,28 @@ export const createErrorToast = (
|
||||
|
||||
const baseAtom = atom<ToastNotif[]>([]);
|
||||
|
||||
// Cap concurrent toasts so a burst (e.g. many rooms lighting up while focused)
|
||||
// can't stack unbounded and cover the viewport.
|
||||
const MAX_TOASTS = 5;
|
||||
|
||||
// Write-only setter used in ClientNonUIFeatures
|
||||
export const toastQueueAtom = atom<ToastNotif[], [ToastNotif | null], void>(
|
||||
(get) => get(baseAtom),
|
||||
(get, set, notif) => {
|
||||
if (notif === null) return; // no-op guard
|
||||
set(baseAtom, [...get(baseAtom), notif]);
|
||||
const next = [...get(baseAtom), notif];
|
||||
// Over cap: drop the oldest NON-sticky toasts (transient message/error
|
||||
// toasts auto-dismiss anyway); never drop a sticky action toast, which
|
||||
// requires a click. The `length - 1` bound excludes the just-appended
|
||||
// newest, so a fresh toast is never the one dropped — if everything older
|
||||
// is sticky the cap simply stretches rather than eating the new notice.
|
||||
for (let i = 0; i < next.length - 1 && next.length > MAX_TOASTS; i += 1) {
|
||||
if (!next[i].sticky) {
|
||||
next.splice(i, 1);
|
||||
i -= 1;
|
||||
}
|
||||
}
|
||||
set(baseAtom, next);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
const mobileFullscreen = {
|
||||
minWidth: '100vw',
|
||||
minHeight: '100vh',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
borderRadius: 0,
|
||||
} as const;
|
||||
|
||||
export const ModalWide = style({
|
||||
minWidth: '85vw',
|
||||
minHeight: '90vh',
|
||||
'@media': {
|
||||
// Fill the phone screen instead of floating as an 85vw card with margins.
|
||||
'(max-width: 750px)': mobileFullscreen,
|
||||
},
|
||||
});
|
||||
|
||||
// Mobile-only full-screen: no desktop effect (keeps the modal's normal size),
|
||||
// but fills the viewport on phones. For dialogs that should stay a small card on
|
||||
// desktop but go edge-to-edge on mobile (e.g. the avatar viewer).
|
||||
export const ModalMobileFull = style({
|
||||
'@media': {
|
||||
'(max-width: 750px)': mobileFullscreen,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { toRem } from 'folds';
|
||||
|
||||
/**
|
||||
* A 44px minimum touch target on phones for otherwise-small interactive controls
|
||||
* (folds `IconButton`/`Chip` at `size="300"`/`"400"` render ~24–40px, below the
|
||||
* 44px guideline). Apply via `className`; the icon/label stays its normal visual
|
||||
* size — only the hit area grows — and desktop is unchanged (the rule is gated to
|
||||
* `@media (max-width: 750px)`, matching MOBILE_BREAKPOINT).
|
||||
*/
|
||||
export const MobileTouchTarget = style({
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
minWidth: toRem(44),
|
||||
minHeight: toRem(44),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { MatrixClient } from 'matrix-js-sdk';
|
||||
import { buildCryptoDiagReport, getCryptoDiagEntries, installCryptoDiagLog } from './cryptoDiagLog';
|
||||
|
||||
// installCryptoDiagLog() replaces console.warn/error with capturing wrappers that
|
||||
// ALWAYS pass through to the original. Silence the originals here (before install
|
||||
// captures them) so the ring-buffer test doesn't spam hundreds of lines into the
|
||||
// test output — the capture logic itself still runs. The module's `entries` buffer
|
||||
// is module-global and accumulates across tests in this file, so assertions use
|
||||
// deltas or the absolute cap rather than assuming an empty buffer.
|
||||
console.warn = () => undefined;
|
||||
console.error = () => undefined;
|
||||
installCryptoDiagLog();
|
||||
|
||||
const mockClient = (partial: Partial<Record<string, unknown>>): MatrixClient =>
|
||||
partial as unknown as MatrixClient;
|
||||
|
||||
test('captures a KE-signature line and ignores non-matching output', () => {
|
||||
const before = getCryptoDiagEntries().length;
|
||||
console.error('POST /keys/upload 400 M_UNKNOWN: One time key already exists');
|
||||
console.warn('just a normal warning with nothing to capture');
|
||||
const after = getCryptoDiagEntries();
|
||||
assert.equal(after.length, before + 1, 'only the matching line is captured');
|
||||
const last = after[after.length - 1];
|
||||
assert.equal(last.ke, 'KE-1');
|
||||
assert.equal(last.signature, 'already exists');
|
||||
assert.equal(last.level, 'error');
|
||||
assert.match(last.message, /already exists/);
|
||||
assert.match(last.ts, /^\d{4}-\d\d-\d\dT/); // ISO-8601 UTC
|
||||
});
|
||||
|
||||
test('matches the first (tightest) signature when several apply', () => {
|
||||
const before = getCryptoDiagEntries().length;
|
||||
// Matches both KE-1 'already exists' (index 0) and KE-2 'MissingKey' (index 3);
|
||||
// find() returns the first, so the tightest/most-specific label is recorded.
|
||||
console.error('MissingKey: the session key already exists somehow');
|
||||
const e = getCryptoDiagEntries();
|
||||
assert.equal(e.length, before + 1);
|
||||
assert.equal(e[e.length - 1].ke, 'KE-1');
|
||||
assert.equal(e[e.length - 1].signature, 'already exists');
|
||||
});
|
||||
|
||||
test('serializes Error and object args into the captured message', () => {
|
||||
const b1 = getCryptoDiagEntries().length;
|
||||
console.error(new Error('boom: io.element.call.encryption_keys arrived encrypted'));
|
||||
const afterErr = getCryptoDiagEntries();
|
||||
assert.equal(afterErr.length, b1 + 1);
|
||||
assert.match(afterErr[afterErr.length - 1].message, /^Error: boom/);
|
||||
assert.equal(afterErr[afterErr.length - 1].ke, 'KE-2');
|
||||
|
||||
const b2 = getCryptoDiagEntries().length;
|
||||
console.warn('missing key at index', { index: 7 });
|
||||
const afterObj = getCryptoDiagEntries();
|
||||
assert.equal(afterObj.length, b2 + 1);
|
||||
assert.match(afterObj[afterObj.length - 1].message, /\{"index":7\}/);
|
||||
assert.equal(afterObj[afterObj.length - 1].ke, 'KE-2');
|
||||
});
|
||||
|
||||
test('ring-buffers to at most 200 entries, evicting the oldest', () => {
|
||||
// Push well past the cap with uniquely-tagged KE-4 lines (matches /delayed event/i).
|
||||
for (let i = 0; i < 250; i += 1) {
|
||||
console.warn(`delayed event tag=${i};`);
|
||||
}
|
||||
const e = getCryptoDiagEntries();
|
||||
assert.equal(e.length, 200, 'buffer is capped at MAX_ENTRIES');
|
||||
assert.match(e[e.length - 1].message, /tag=249;/, 'newest is retained');
|
||||
assert.ok(!e.some((x) => x.message.includes('tag=0;')), 'the oldest pushes were evicted');
|
||||
});
|
||||
|
||||
test('getCryptoDiagEntries returns a copy, not the live buffer', () => {
|
||||
const a = getCryptoDiagEntries();
|
||||
const b = getCryptoDiagEntries();
|
||||
assert.notEqual(a, b, 'each call returns a fresh array');
|
||||
const len = a.length;
|
||||
a.push({ ts: 'x', level: 'warn', ke: 'X', signature: 'x', message: 'x' });
|
||||
assert.equal(getCryptoDiagEntries().length, len, 'mutating the copy does not affect the buffer');
|
||||
});
|
||||
|
||||
// NOTE: these run after the ring-buffer test has filled the buffer to its cap,
|
||||
// so a capture evicts the oldest and length stays at 200 — assert on the NEWEST
|
||||
// entry (always the line just pushed) rather than a length delta.
|
||||
test('captures the KE-3 and KE-4 signatures', () => {
|
||||
console.error('DecryptionError: unable to decrypt event');
|
||||
const e1 = getCryptoDiagEntries();
|
||||
assert.equal(e1[e1.length - 1].ke, 'KE-3');
|
||||
assert.equal(e1[e1.length - 1].signature, 'DecryptionError');
|
||||
|
||||
// underscore, not a space, so it matches the tighter 'update_delayed_event'
|
||||
// row (index 5) rather than the looser 'delayed event' row.
|
||||
console.warn('msc4157.update_delayed_event timed out');
|
||||
const e2 = getCryptoDiagEntries();
|
||||
assert.equal(e2[e2.length - 1].ke, 'KE-4');
|
||||
assert.equal(e2[e2.length - 1].signature, 'update_delayed_event');
|
||||
});
|
||||
|
||||
test('serializes an unserializable (circular) arg via the String() fallback without throwing', () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular; // JSON.stringify throws → stringifyArg falls back to String()
|
||||
// Pair with a matching string so the line is captured; the object exercises
|
||||
// the catch branch. This must not throw.
|
||||
console.error('DecryptionError from', circular);
|
||||
const e = getCryptoDiagEntries();
|
||||
assert.equal(e[e.length - 1].ke, 'KE-3');
|
||||
assert.match(e[e.length - 1].message, /\[object Object\]/);
|
||||
});
|
||||
|
||||
test('installCryptoDiagLog is idempotent — a second call does not re-wrap console', () => {
|
||||
const wrappedWarn = console.warn;
|
||||
installCryptoDiagLog(); // guarded no-op
|
||||
// Reference unchanged ⇒ not re-wrapped ⇒ a line is captured once, not doubled.
|
||||
assert.equal(console.warn, wrappedWarn, 'console.warn is not re-wrapped');
|
||||
console.error('DecryptionError single-capture check');
|
||||
const e = getCryptoDiagEntries();
|
||||
assert.match(e[e.length - 1].message, /single-capture check/, 'still captured');
|
||||
});
|
||||
|
||||
test('buildCryptoDiagReport captures client metadata in a fixed, PII-safe shape', () => {
|
||||
const mx = mockClient({
|
||||
getDeviceId: () => 'DEVICE123',
|
||||
getUserId: () => '@alice:example.org',
|
||||
getSyncState: () => 'SYNCING',
|
||||
getCrypto: () => ({}),
|
||||
getSdkVersion: () => '99.9.9',
|
||||
});
|
||||
const report = JSON.parse(buildCryptoDiagReport(mx));
|
||||
|
||||
assert.equal(report.kind, 'lotus-crypto-diag');
|
||||
assert.equal(report.deviceId, 'DEVICE123');
|
||||
assert.equal(report.userId, '@alice:example.org');
|
||||
assert.equal(report.syncState, 'SYNCING');
|
||||
assert.equal(report.cryptoReady, true);
|
||||
assert.equal(report.sdkVersion, '99.9.9');
|
||||
assert.equal(report.maxEntries, 200);
|
||||
assert.equal(report.entryCount, getCryptoDiagEntries().length);
|
||||
|
||||
const sum = Object.values<number>(report.countsByKe).reduce((a, b) => a + b, 0);
|
||||
assert.equal(sum, report.entryCount, 'countsByKe sums to entryCount');
|
||||
|
||||
// Locked field set: nothing beyond these documented keys ever leaks.
|
||||
assert.deepEqual(Object.keys(report).sort(), [
|
||||
'countsByKe',
|
||||
'cryptoReady',
|
||||
'deviceId',
|
||||
'entries',
|
||||
'entryCount',
|
||||
'generatedAt',
|
||||
'kind',
|
||||
'maxEntries',
|
||||
'sdkVersion',
|
||||
'syncState',
|
||||
'userId',
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildCryptoDiagReport degrades gracefully with no client', () => {
|
||||
const report = JSON.parse(buildCryptoDiagReport());
|
||||
assert.equal(report.deviceId, null);
|
||||
assert.equal(report.userId, null);
|
||||
assert.equal(report.syncState, null);
|
||||
assert.equal(report.cryptoReady, false);
|
||||
// sdkVersion falls back to the declared package.json pin — a version string.
|
||||
assert.equal(typeof report.sdkVersion, 'string');
|
||||
assert.ok(report.sdkVersion.length > 0);
|
||||
});
|
||||
|
||||
test('sdkVersion falls back to the declared pin when the client getter throws', () => {
|
||||
const mx = mockClient({
|
||||
getDeviceId: () => null,
|
||||
getUserId: () => null,
|
||||
getSyncState: () => null,
|
||||
getCrypto: () => undefined,
|
||||
getSdkVersion: () => {
|
||||
throw new Error('not ready');
|
||||
},
|
||||
});
|
||||
const report = JSON.parse(buildCryptoDiagReport(mx));
|
||||
assert.equal(typeof report.sdkVersion, 'string');
|
||||
assert.ok(report.sdkVersion.length > 0);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
canFitInScrollView,
|
||||
getThumbnailDimensions,
|
||||
isInScrollView,
|
||||
isIntersectingScrollView,
|
||||
syntaxErrorPosition,
|
||||
tryDecodeURIComponent,
|
||||
} from './dom';
|
||||
|
||||
// The scroll-view helpers only read numeric layout properties off their
|
||||
// elements, so a plain duck-typed object stands in for an HTMLElement.
|
||||
type ElLike = {
|
||||
offsetTop?: number;
|
||||
scrollTop?: number;
|
||||
offsetHeight?: number;
|
||||
clientHeight?: number;
|
||||
};
|
||||
const el = (props: ElLike): HTMLElement => props as unknown as HTMLElement;
|
||||
|
||||
describe('getThumbnailDimensions', () => {
|
||||
it('leaves dimensions within the 400x300 cap untouched', () => {
|
||||
assert.deepEqual(getThumbnailDimensions(200, 150), [200, 150]);
|
||||
assert.deepEqual(getThumbnailDimensions(400, 300), [400, 300]);
|
||||
assert.deepEqual(getThumbnailDimensions(100, 100), [100, 100]);
|
||||
});
|
||||
|
||||
it('scales down by height when taller than 300', () => {
|
||||
// 200x600 -> width * (300/600) = 100, height clamped to 300
|
||||
assert.deepEqual(getThumbnailDimensions(200, 600), [100, 300]);
|
||||
});
|
||||
|
||||
it('scales down by width when wider than 400', () => {
|
||||
// 800x200 -> height * (400/800) = 100, width clamped to 400
|
||||
assert.deepEqual(getThumbnailDimensions(800, 200), [400, 100]);
|
||||
});
|
||||
|
||||
it('applies the height clamp first, then the width clamp', () => {
|
||||
// 800x600 -> height clamp: 400x300 (width already at cap, no further change)
|
||||
assert.deepEqual(getThumbnailDimensions(800, 600), [400, 300]);
|
||||
// 1200x600 -> height clamp: 600x300 -> width clamp: 400x200
|
||||
assert.deepEqual(getThumbnailDimensions(1200, 600), [400, 200]);
|
||||
});
|
||||
|
||||
it('floors fractional results', () => {
|
||||
// 300x700 -> width * (300/700) = 128.57 -> floored to 128
|
||||
assert.deepEqual(getThumbnailDimensions(300, 700), [128, 300]);
|
||||
});
|
||||
|
||||
it('scales on a just-over-boundary input (strict > comparisons)', () => {
|
||||
// one over the height cap -> scales; one over the width cap -> scales
|
||||
assert.deepEqual(getThumbnailDimensions(400, 301), [398, 300]);
|
||||
assert.deepEqual(getThumbnailDimensions(401, 300), [400, 299]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryDecodeURIComponent', () => {
|
||||
it('decodes a valid encoded component', () => {
|
||||
assert.equal(tryDecodeURIComponent('a%20b'), 'a b');
|
||||
assert.equal(tryDecodeURIComponent('%C3%A9'), 'é');
|
||||
});
|
||||
|
||||
it('returns the input unchanged when it has no escapes', () => {
|
||||
assert.equal(tryDecodeURIComponent('hello'), 'hello');
|
||||
});
|
||||
|
||||
it('returns the raw input on a malformed sequence instead of throwing', () => {
|
||||
assert.equal(tryDecodeURIComponent('%'), '%');
|
||||
assert.equal(tryDecodeURIComponent('%E0%A4%A'), '%E0%A4%A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syntaxErrorPosition', () => {
|
||||
it('extracts the position when the number ends the message (real V8/Node shape)', () => {
|
||||
// Real JSON.parse errors read "... at position N" with N at end-of-string.
|
||||
assert.equal(
|
||||
syntaxErrorPosition(new SyntaxError('Unexpected end of JSON input at position 10')),
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts the position when it is followed by more text', () => {
|
||||
// Newer V8 appends "(line N column M)" after the number.
|
||||
assert.equal(
|
||||
syntaxErrorPosition(new SyntaxError('bad token in JSON at position 6 (line 1 column 7)')),
|
||||
6,
|
||||
);
|
||||
assert.equal(syntaxErrorPosition(new SyntaxError('bad at position 42 more')), 42);
|
||||
});
|
||||
|
||||
it('returns undefined when the message has no position', () => {
|
||||
assert.equal(syntaxErrorPosition(new SyntaxError('Unexpected end of input')), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isIntersectingScrollView', () => {
|
||||
// Viewport spans 0..100 (offsetTop 0 + scrollTop 0, height 100).
|
||||
const view = el({ offsetTop: 0, scrollTop: 0, offsetHeight: 100 });
|
||||
|
||||
it('is true for a child fully inside the view', () => {
|
||||
assert.equal(isIntersectingScrollView(view, el({ offsetTop: 20, clientHeight: 30 })), true);
|
||||
});
|
||||
|
||||
it('is true for a child straddling the top edge', () => {
|
||||
// -10..20 -> bottom (20) is within 0..100
|
||||
assert.equal(isIntersectingScrollView(view, el({ offsetTop: -10, clientHeight: 30 })), true);
|
||||
});
|
||||
|
||||
it('is true for a child taller than and spanning the whole view', () => {
|
||||
// -20..180 -> top above, bottom below
|
||||
assert.equal(isIntersectingScrollView(view, el({ offsetTop: -20, clientHeight: 200 })), true);
|
||||
});
|
||||
|
||||
it('is false for a child entirely above or below the view', () => {
|
||||
assert.equal(isIntersectingScrollView(view, el({ offsetTop: -50, clientHeight: 20 })), false);
|
||||
assert.equal(isIntersectingScrollView(view, el({ offsetTop: 200, clientHeight: 20 })), false);
|
||||
});
|
||||
|
||||
it('respects the strict pixel boundaries (> vs >=)', () => {
|
||||
// child bottom sits exactly on scrollTop (0) -> not intersecting (childBottom > scrollTop is strict)
|
||||
assert.equal(isIntersectingScrollView(view, el({ offsetTop: -10, clientHeight: 10 })), false);
|
||||
// child top sits exactly on scrollBottom (100) -> not intersecting (childTop < scrollBottom is strict)
|
||||
assert.equal(isIntersectingScrollView(view, el({ offsetTop: 100, clientHeight: 20 })), false);
|
||||
});
|
||||
|
||||
it('accounts for the view scrollTop offset', () => {
|
||||
// View 0..100 in layout, scrolled by 100 -> logical window 100..200.
|
||||
const scrolled = el({ offsetTop: 0, scrollTop: 100, offsetHeight: 100 });
|
||||
assert.equal(
|
||||
isIntersectingScrollView(scrolled, el({ offsetTop: 120, clientHeight: 10 })),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isIntersectingScrollView(scrolled, el({ offsetTop: 20, clientHeight: 10 })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInScrollView', () => {
|
||||
const view = el({ offsetTop: 0, scrollTop: 0, offsetHeight: 100 });
|
||||
|
||||
it('is true only when the child is fully within the view', () => {
|
||||
assert.equal(isInScrollView(view, el({ offsetTop: 10, offsetHeight: 50 })), true);
|
||||
// straddles the bottom edge -> not fully in
|
||||
assert.equal(isInScrollView(view, el({ offsetTop: 80, offsetHeight: 50 })), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canFitInScrollView', () => {
|
||||
it('is true when the child is shorter than the view', () => {
|
||||
const view = el({ offsetHeight: 100 });
|
||||
assert.equal(canFitInScrollView(view, el({ offsetHeight: 60 })), true);
|
||||
assert.equal(canFitInScrollView(view, el({ offsetHeight: 100 })), false);
|
||||
assert.equal(canFitInScrollView(view, el({ offsetHeight: 140 })), false);
|
||||
});
|
||||
});
|
||||
+19
-2
@@ -230,7 +230,11 @@ export const tryDecodeURIComponent = (encodedURIComponent: string): string => {
|
||||
};
|
||||
|
||||
export const syntaxErrorPosition = (error: SyntaxError): number | undefined => {
|
||||
const match = error.message.match(/position\s(\d+)\s/);
|
||||
// The number may sit at the very end of the message — real V8/Node JSON
|
||||
// errors read "... at position 7" with no trailing character — so do NOT
|
||||
// require whitespace after the digits (that made this return undefined for
|
||||
// every real error, silently pointing the editors' cursor at position 0).
|
||||
const match = error.message.match(/position\s(\d+)/);
|
||||
if (!match) return undefined;
|
||||
|
||||
const posStr = match[1];
|
||||
@@ -264,13 +268,26 @@ export const notificationPermission = (permission: NotificationPermission) => {
|
||||
* (with the provided `onClick`) when no service worker is available, preserving
|
||||
* the previous behaviour.
|
||||
*/
|
||||
// Tauri v2 injects `__TAURI_INTERNALS__` into the webview. On the desktop build,
|
||||
// an injected `window.Notification` shim routes `tag`-bearing message toasts to
|
||||
// the native rich WinRT toast, whose click focuses the app AND navigates to the
|
||||
// message (via the `lotus-notification-activate` event → useTauriToastActions).
|
||||
const isDesktopApp = (): boolean =>
|
||||
(window as unknown as { __TAURI_INTERNALS__?: { invoke?: unknown } }).__TAURI_INTERNALS__
|
||||
?.invoke !== undefined;
|
||||
|
||||
export const showOsNotification = async (
|
||||
title: string,
|
||||
options: NotificationOptions & { data?: { path?: string } },
|
||||
onClick?: () => void,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if ('serviceWorker' in navigator) {
|
||||
// On desktop, skip the service-worker notification: WebView2 exposes a
|
||||
// service worker, so the SW-owned toast would win here and bypass the
|
||||
// Notification shim above — its click focuses the app but never navigates to
|
||||
// the message. Falling through to `new Notification()` lets the shim route
|
||||
// to the native rich toast, which does navigate.
|
||||
if (!isDesktopApp() && 'serviceWorker' in navigator) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
if (registration && typeof registration.showNotification === 'function') {
|
||||
await registration.showNotification(title, options);
|
||||
|
||||
@@ -29,6 +29,18 @@ const getCtx = (): AudioContext | undefined => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* C-L3 — prime (create + resume) the shared ringtone AudioContext from within a
|
||||
* user gesture. Browsers keep a fresh context suspended until a gesture, and an
|
||||
* incoming-call ring fires with no gesture of its own, so the first ring after a
|
||||
* cold page load could be silent (resume() is async and may not finish before
|
||||
* the notes are scheduled). Call this on any early app gesture so a later ring
|
||||
* plays through an already-running context. Mirrors `unlockCallSounds`.
|
||||
*/
|
||||
export const unlockRingtoneAudio = (): void => {
|
||||
getCtx();
|
||||
};
|
||||
|
||||
type Note = {
|
||||
freq: number;
|
||||
/** Offset from phrase start, in seconds */
|
||||
@@ -173,6 +185,10 @@ const startSynth = (style: SynthStyle, volume: number, loop: boolean): (() => vo
|
||||
* silent. This matches the pre-existing behaviour of the classic ringtone.
|
||||
*/
|
||||
export const startRingtone = (id: RingtoneId, volume: number): (() => void) => {
|
||||
// C-L2 — a real incoming ring supersedes any lingering Settings preview so the
|
||||
// two don't overlap (the preview is otherwise only cleared by its own timer).
|
||||
activePreviewStop?.();
|
||||
activePreviewStop = null;
|
||||
if (id === 'none') return () => undefined;
|
||||
if (id === 'classic') return startClassic(volume, true);
|
||||
return startSynth(id, volume, true);
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
EventTimeline,
|
||||
JoinRule,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
NotificationCountType,
|
||||
Room,
|
||||
@@ -24,6 +25,10 @@ import {
|
||||
isMutedRule,
|
||||
findMutedRule,
|
||||
isNotificationEvent,
|
||||
isVerificationFlowEvent,
|
||||
unreadIsOnlyVerification,
|
||||
readReceiptCoversTail,
|
||||
roomHasUnreadThread,
|
||||
roomHaveNotification,
|
||||
getUnreadInfo,
|
||||
getRoomIconSrc,
|
||||
@@ -410,6 +415,232 @@ test('getUnreadInfo uses highlight when it exceeds total', () => {
|
||||
assert.deepEqual(getUnreadInfo(room2), { roomId: '!r:y', highlight: 1, total: 7 });
|
||||
});
|
||||
|
||||
// --- verification-flow unread suppression --------------------------------
|
||||
|
||||
test('isVerificationFlowEvent', () => {
|
||||
// the in-room request (m.room.message + verification msgtype)
|
||||
assert.equal(
|
||||
isVerificationFlowEvent(
|
||||
mockEvent({
|
||||
getType: () => 'm.room.message',
|
||||
getContent: () => ({ msgtype: 'm.key.verification.request' }),
|
||||
}),
|
||||
),
|
||||
true,
|
||||
);
|
||||
// the handshake events (their own m.key.verification.* types)
|
||||
['ready', 'start', 'accept', 'key', 'mac', 'done', 'cancel'].forEach((phase) => {
|
||||
assert.equal(
|
||||
isVerificationFlowEvent(mockEvent({ getType: () => `m.key.verification.${phase}` })),
|
||||
true,
|
||||
);
|
||||
});
|
||||
// a normal message is not verification flow
|
||||
assert.equal(
|
||||
isVerificationFlowEvent(
|
||||
mockEvent({ getType: () => 'm.room.message', getContent: () => ({ msgtype: 'm.text' }) }),
|
||||
),
|
||||
false,
|
||||
);
|
||||
// a still-encrypted event can't be classified → false (conservative)
|
||||
assert.equal(isVerificationFlowEvent(mockEvent({ getType: () => 'm.room.encrypted' })), false);
|
||||
});
|
||||
|
||||
const mockUnreadRoom = (
|
||||
events: MatrixEvent[],
|
||||
readUpToId: string | null,
|
||||
counts: { total: number; highlight: number } = { total: 0, highlight: 0 },
|
||||
threadCounts: Record<string, { total: number; highlight: number }> = {},
|
||||
): Room =>
|
||||
({
|
||||
roomId: '!r:x',
|
||||
getEventReadUpTo: () => readUpToId,
|
||||
getLiveTimeline: () => ({ getEvents: () => events }),
|
||||
getUnreadNotificationCount: (type: NotificationCountType) =>
|
||||
type === NotificationCountType.Total ? counts.total : counts.highlight,
|
||||
getThreads: () => Object.keys(threadCounts).map((id) => ({ id })),
|
||||
getThreadUnreadNotificationCount: (threadId: string, type: NotificationCountType) =>
|
||||
type === NotificationCountType.Total
|
||||
? (threadCounts[threadId]?.total ?? 0)
|
||||
: (threadCounts[threadId]?.highlight ?? 0),
|
||||
}) as unknown as Room;
|
||||
|
||||
const mx = { getUserId: () => '@me:x' } as unknown as MatrixClient;
|
||||
const verifRequest = (id: string) =>
|
||||
mockEvent({
|
||||
getId: () => id,
|
||||
getType: () => 'm.room.message',
|
||||
getContent: () => ({ msgtype: 'm.key.verification.request' }),
|
||||
});
|
||||
const verifPhase = (id: string, phase: string) =>
|
||||
mockEvent({ getId: () => id, getType: () => `m.key.verification.${phase}` });
|
||||
const textMsg = (id: string) =>
|
||||
mockEvent({
|
||||
getId: () => id,
|
||||
getType: () => 'm.room.message',
|
||||
getContent: () => ({ msgtype: 'm.text', body: 'hi' }),
|
||||
});
|
||||
const reactionEv = (id: string) =>
|
||||
mockEvent({ getId: () => id, getType: () => 'm.reaction', getContent: () => ({}) });
|
||||
const encryptedEv = (id: string) =>
|
||||
mockEvent({ getId: () => id, getType: () => 'm.room.encrypted', getContent: () => ({}) });
|
||||
const pollEv = (id: string) =>
|
||||
mockEvent({ getId: () => id, getType: () => 'm.poll.start', getContent: () => ({}) });
|
||||
|
||||
test('unreadIsOnlyVerification: verification-only unread tail → true', () => {
|
||||
// timeline oldest→newest: [read msg] then the verification handshake at the tail
|
||||
const events = [textMsg('$read'), verifPhase('$done', 'done'), verifRequest('$req')];
|
||||
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), true);
|
||||
});
|
||||
|
||||
test('unreadIsOnlyVerification: a real unread message in the span → false', () => {
|
||||
// an unread text message sits between the read marker and the verification tail
|
||||
const events = [textMsg('$read'), textMsg('$new'), verifRequest('$req')];
|
||||
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), false);
|
||||
});
|
||||
|
||||
test('unreadIsOnlyVerification: read marker off-window → false (conservative)', () => {
|
||||
// the read marker isn't in the loaded timeline
|
||||
const events = [verifPhase('$done', 'done'), verifRequest('$req')];
|
||||
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$offwindow'), '@me:x'), false);
|
||||
});
|
||||
|
||||
test('unreadIsOnlyVerification: still-encrypted tail → false (conservative)', () => {
|
||||
const encryptedTail = mockEvent({ getId: () => '$enc', getType: () => 'm.room.encrypted' });
|
||||
const events = [textMsg('$read'), encryptedTail];
|
||||
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), false);
|
||||
});
|
||||
|
||||
test('unreadIsOnlyVerification: no userId → false', () => {
|
||||
const events = [textMsg('$read'), verifRequest('$req')];
|
||||
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), null), false);
|
||||
});
|
||||
|
||||
test('unreadIsOnlyVerification: verification-only main tail but a real unread THREAD → false', () => {
|
||||
// markAsRead clears every thread, so a verification-only main timeline must NOT
|
||||
// count as "only verification" when a thread still has a genuine unread reply.
|
||||
const events = [textMsg('$read'), verifRequest('$req')];
|
||||
const room = mockUnreadRoom(
|
||||
events,
|
||||
'$read',
|
||||
{ total: 2, highlight: 0 },
|
||||
{
|
||||
$thread: { total: 1, highlight: 0 },
|
||||
},
|
||||
);
|
||||
assert.equal(unreadIsOnlyVerification(room, '@me:x'), false);
|
||||
});
|
||||
|
||||
test('getUnreadInfo suppresses a verification-only room to {0,0} when mx is passed', () => {
|
||||
const events = [textMsg('$read'), verifRequest('$req')];
|
||||
const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 });
|
||||
// Without mx, the raw count is trusted (backward compatible).
|
||||
assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 0, total: 1 });
|
||||
// With mx, the verification-only count is suppressed.
|
||||
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 0 });
|
||||
});
|
||||
|
||||
test('getUnreadInfo does NOT suppress a highlight (real mention) even if the tail is a verification', () => {
|
||||
const events = [textMsg('$read'), verifRequest('$req')];
|
||||
const room = mockUnreadRoom(events, '$read', { total: 2, highlight: 1 });
|
||||
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 1, total: 2 });
|
||||
});
|
||||
|
||||
test('getUnreadInfo does NOT suppress when a real message is unread alongside a verification', () => {
|
||||
const events = [textMsg('$read'), textMsg('$new'), verifRequest('$req')];
|
||||
const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 });
|
||||
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 1 });
|
||||
});
|
||||
|
||||
// --- readReceiptCoversTail (UTD / spurious-count suppression) --------------
|
||||
|
||||
test('readReceiptCoversTail: receipt on the tail (a reaction) → true', () => {
|
||||
// The Cool Kids case: a corrupt/undecryptable event sits BEFORE the read
|
||||
// receipt, and the receipt itself landed on the trailing reaction.
|
||||
const events = [textMsg('$read'), encryptedEv('$corrupt'), reactionEv('$tail')];
|
||||
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$tail'), '@me:x'), true);
|
||||
});
|
||||
|
||||
test('readReceiptCoversTail: only non-notifiable events after the receipt → true', () => {
|
||||
const events = [textMsg('$read'), reactionEv('$r1'), verifRequest('$v')];
|
||||
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), true);
|
||||
});
|
||||
|
||||
test('readReceiptCoversTail: a real unread message after the receipt → false', () => {
|
||||
const events = [textMsg('$read'), reactionEv('$r'), textMsg('$new')];
|
||||
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false);
|
||||
});
|
||||
|
||||
test('readReceiptCoversTail: an unread poll after the receipt → false (polls are content)', () => {
|
||||
const events = [textMsg('$read'), pollEv('$poll')];
|
||||
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false);
|
||||
});
|
||||
|
||||
test('isNotificationEvent recognizes polls (MSC3381)', () => {
|
||||
assert.equal(isNotificationEvent(pollEv('$p')), true);
|
||||
});
|
||||
|
||||
test('readReceiptCoversTail: a still-encrypted message after the receipt → false (conservative)', () => {
|
||||
const events = [textMsg('$read'), encryptedEv('$enc')];
|
||||
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false);
|
||||
});
|
||||
|
||||
test('readReceiptCoversTail: receipt off-window → false', () => {
|
||||
const events = [reactionEv('$r'), textMsg('$new')];
|
||||
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$gone'), '@me:x'), false);
|
||||
});
|
||||
|
||||
test('readReceiptCoversTail: no receipt / null user → false', () => {
|
||||
const events = [textMsg('$read'), reactionEv('$tail')];
|
||||
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, null), '@me:x'), false);
|
||||
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$tail'), null), false);
|
||||
});
|
||||
|
||||
test('readReceiptCoversTail: a genuine unread THREAD blocks suppression → false', () => {
|
||||
const events = [textMsg('$read'), reactionEv('$tail')];
|
||||
const room = mockUnreadRoom(
|
||||
events,
|
||||
'$tail',
|
||||
{ total: 2, highlight: 0 },
|
||||
{
|
||||
$thread: { total: 1, highlight: 0 },
|
||||
},
|
||||
);
|
||||
assert.equal(readReceiptCoversTail(room, '@me:x'), false);
|
||||
});
|
||||
|
||||
test('roomHasUnreadThread reflects per-thread counts', () => {
|
||||
const events = [textMsg('$read')];
|
||||
assert.equal(roomHasUnreadThread(mockUnreadRoom(events, '$read')), false);
|
||||
assert.equal(
|
||||
roomHasUnreadThread(
|
||||
mockUnreadRoom(
|
||||
events,
|
||||
'$read',
|
||||
{ total: 1, highlight: 0 },
|
||||
{ $t: { total: 1, highlight: 0 } },
|
||||
),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('getUnreadInfo suppresses a spurious count when the read receipt covers the tail', () => {
|
||||
// Inflated Total=1 (undecryptable event) but the receipt is on the trailing
|
||||
// reaction → the room is genuinely read; suppress to {0,0}. Without mx the raw
|
||||
// count is trusted (backward compatible).
|
||||
const events = [textMsg('$read'), encryptedEv('$corrupt'), reactionEv('$tail')];
|
||||
const room = mockUnreadRoom(events, '$tail', { total: 1, highlight: 0 });
|
||||
assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 0, total: 1 });
|
||||
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 0 });
|
||||
});
|
||||
|
||||
test('getUnreadInfo does NOT suppress a spurious count when a real message is unread past the receipt', () => {
|
||||
const events = [textMsg('$read'), textMsg('$new')];
|
||||
const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 });
|
||||
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 1 });
|
||||
});
|
||||
|
||||
const mockRoomWithThreadCounts = (
|
||||
total: number,
|
||||
highlight: number,
|
||||
|
||||
+121
-5
@@ -214,6 +214,11 @@ const NOTIFICATION_EVENT_TYPES = [
|
||||
'm.room.encrypted',
|
||||
'm.room.member',
|
||||
'm.sticker',
|
||||
// Polls (MSC3381) are real content the server can count toward a room's total.
|
||||
// Recognizing them keeps a poll-only unread from being walked past by the
|
||||
// read-receipt/tail scans (roomHaveUnread, readReceiptCoversTail).
|
||||
'm.poll.start',
|
||||
'org.matrix.msc3381.poll.start',
|
||||
];
|
||||
// In-room device-verification requests are sent as m.room.message with this
|
||||
// msgtype (the rest of the flow — start/accept/key/mac/done/cancel — uses its own
|
||||
@@ -237,6 +242,84 @@ export const isNotificationEvent = (mEvent: MatrixEvent) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// In-room device verification is a small burst at the tail of a DM: the
|
||||
// `m.key.verification.request` message plus the `m.key.verification.*` flow
|
||||
// (ready/start/key/mac/done/cancel). A COMPLETED request keeps the server/SDK
|
||||
// Total notification count > 0 forever — it's a plain `m.room.message`, so it
|
||||
// matches the default DM push rule and there's no recency gate — so the DM
|
||||
// re-lights as unread on every fresh sync.
|
||||
export const isVerificationFlowEvent = (mEvent: MatrixEvent): boolean => {
|
||||
// getType() returns the CLEAR type once decrypted; while still encrypted we
|
||||
// can't tell, so this returns false and callers treat that as "not confirmed".
|
||||
const eType = mEvent.getType();
|
||||
if (eType.startsWith('m.key.verification.')) return true;
|
||||
if (eType === 'm.room.message') {
|
||||
return mEvent.getContent().msgtype === VERIFICATION_REQUEST_MSGTYPE;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// True iff the room has any thread carrying a real unread notification. A room's
|
||||
// server/SDK Total INCLUDES its threads, and `markAsRead` clears every thread
|
||||
// unconditionally, so any tail-based suppression must bail when a thread is
|
||||
// genuinely unread — otherwise it would hide (or wrongly ack) a real thread reply.
|
||||
export const roomHasUnreadThread = (room: Room): boolean =>
|
||||
room
|
||||
.getThreads()
|
||||
.some(
|
||||
(thread) => room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) > 0,
|
||||
);
|
||||
|
||||
// True iff a room's ENTIRE unread span (tail → the user's read receipt) is
|
||||
// nothing but verification-flow events — i.e. the only "unread" is a completed
|
||||
// device verification, not a real message. Conservative: returns false when the
|
||||
// read marker isn't in the loaded timeline (can't confirm the span) or while the
|
||||
// tail is still encrypted (undecryptable → unknown), so it never suppresses or
|
||||
// auto-reads a genuine unread message.
|
||||
export const unreadIsOnlyVerification = (room: Room, userId: string | null): boolean => {
|
||||
if (!userId) return false;
|
||||
if (roomHasUnreadThread(room)) return false;
|
||||
const readUpToId = room.getEventReadUpTo(userId);
|
||||
const liveEvents = room.getLiveTimeline().getEvents();
|
||||
let sawVerification = false;
|
||||
for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
|
||||
const event = liveEvents[i];
|
||||
if (!event) return false;
|
||||
if (event.getId() === readUpToId) return sawVerification;
|
||||
if (isNotificationEvent(event) && !isVerificationFlowEvent(event)) return false;
|
||||
if (isVerificationFlowEvent(event)) sawVerification = true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// True iff the user's read receipt already covers the room's entire notifiable
|
||||
// tail: walking from the newest live event, we reach the receipt's event without
|
||||
// crossing any notification-worthy event. In that case there is demonstrably
|
||||
// nothing real left to read, so a lingering Total > 0 is a spurious SDK count.
|
||||
// matrix-js-sdk's `fixNotificationCountOnDecryption` only ever INCREMENTS an
|
||||
// encrypted room's Total, and `addReceipt`'s auto-clear-to-zero only fires when
|
||||
// the tail event is the user's own — so a count inflated in an earlier state
|
||||
// (before a receipt covered the tail, e.g. by a since-corrupted/undecryptable
|
||||
// event) is never decremented and keeps a genuinely-read room lit across cold
|
||||
// starts. Anchoring on the read receipt is safe: a genuine unread would sit AFTER
|
||||
// the receipt and stop the walk at `isNotificationEvent`. Conservative: returns
|
||||
// false when the receipt isn't in the loaded window (can't confirm) or a thread
|
||||
// is genuinely unread.
|
||||
export const readReceiptCoversTail = (room: Room, userId: string | null): boolean => {
|
||||
if (!userId) return false;
|
||||
if (roomHasUnreadThread(room)) return false;
|
||||
const readUpToId = room.getEventReadUpTo(userId);
|
||||
if (!readUpToId) return false;
|
||||
const liveEvents = room.getLiveTimeline().getEvents();
|
||||
for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
|
||||
const event = liveEvents[i];
|
||||
if (!event) return false;
|
||||
if (event.getId() === readUpToId) return true;
|
||||
if (isNotificationEvent(event)) return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const roomHaveNotification = (room: Room): boolean => {
|
||||
const total = room.getUnreadNotificationCount(NotificationCountType.Total);
|
||||
const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
|
||||
@@ -263,7 +346,11 @@ export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getUnreadInfo = (room: Room, mutedThreads?: Set<string>): UnreadInfo => {
|
||||
export const getUnreadInfo = (
|
||||
room: Room,
|
||||
mutedThreads?: Set<string>,
|
||||
mx?: MatrixClient,
|
||||
): UnreadInfo => {
|
||||
let total = room.getUnreadNotificationCount(NotificationCountType.Total);
|
||||
let highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
|
||||
|
||||
@@ -280,10 +367,28 @@ export const getUnreadInfo = (room: Room, mutedThreads?: Set<string>): UnreadInf
|
||||
if (highlight < 0) highlight = 0;
|
||||
}
|
||||
|
||||
const resolvedTotal = highlight > total ? highlight : total;
|
||||
|
||||
// Suppress a spurious Total when the room isn't really unread. Two safe cases,
|
||||
// both requiring `mx` (backward-compatible for callers/tests without it) and a
|
||||
// highlight-free count (a real mention must never be hidden):
|
||||
// 1. the entire unread span is a completed device verification, or
|
||||
// 2. the user's read receipt already covers the whole notifiable tail (the
|
||||
// SDK re-inflated an encrypted-room count past a receipt that genuinely
|
||||
// covers everything — e.g. a permanently-undecryptable event).
|
||||
if (
|
||||
mx &&
|
||||
resolvedTotal > 0 &&
|
||||
highlight === 0 &&
|
||||
(unreadIsOnlyVerification(room, mx.getUserId()) || readReceiptCoversTail(room, mx.getUserId()))
|
||||
) {
|
||||
return { roomId: room.roomId, highlight: 0, total: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
highlight,
|
||||
total: highlight > total ? highlight : total,
|
||||
total: resolvedTotal,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -298,13 +403,24 @@ export const getUnreadInfos = (
|
||||
|
||||
if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
|
||||
const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined;
|
||||
const info = getUnreadInfo(room, mutedThreads);
|
||||
const info = getUnreadInfo(room, mutedThreads, mx);
|
||||
// Skip a phantom {0,0} entry: a room whose ONLY unread is a muted thread has
|
||||
// roomHaveNotification true (the server room total includes the muted
|
||||
// thread's count), but getUnreadInfo subtracts it back to zero. Pushing it
|
||||
// would still light the nav row + pollute "unread only" filters. Keep it
|
||||
// only if there's real unread (count > 0) or a genuine unread marker.
|
||||
if (info.total > 0 || info.highlight > 0 || roomHaveUnread(mx, room)) {
|
||||
// only if there's real unread (count > 0) or a genuine unread marker. The
|
||||
// unreadIsOnlyVerification/readReceiptCoversTail guards below mirror the
|
||||
// getUnreadInfo suppression: roomHaveUnread returning `true` here already
|
||||
// implies both are false (they only report `true` once the receipt covers
|
||||
// the tail, exactly where roomHaveUnread returns `false`), so the guards are
|
||||
// defensive insurance against divergence, not load-bearing.
|
||||
if (
|
||||
info.total > 0 ||
|
||||
info.highlight > 0 ||
|
||||
(roomHaveUnread(mx, room) &&
|
||||
!unreadIsOnlyVerification(room, mx.getUserId()) &&
|
||||
!readReceiptCoversTail(room, mx.getUserId()))
|
||||
) {
|
||||
unread.push(info);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
computeCoverage,
|
||||
evictCount,
|
||||
mergeSearchResults,
|
||||
putRows,
|
||||
queryRoom,
|
||||
@@ -57,6 +58,18 @@ test('mergeSearchResults: missing ts sorts as 0 (last)', () => {
|
||||
|
||||
const row = (ts: number): Pick<SearchCacheRow, 'ts'> => ({ ts });
|
||||
|
||||
test('evictCount: 0 when under or at the cap, else the excess', () => {
|
||||
assert.equal(evictCount(0, 100), 0);
|
||||
assert.equal(evictCount(100, 100), 0); // exactly at cap → nothing evicted
|
||||
assert.equal(evictCount(101, 100), 1);
|
||||
assert.equal(evictCount(250, 100), 150);
|
||||
});
|
||||
|
||||
test('evictCount: uses the default per-room cap (5000)', () => {
|
||||
assert.equal(evictCount(5000), 0);
|
||||
assert.equal(evictCount(5001), 1);
|
||||
});
|
||||
|
||||
test('computeCoverage: derives oldest/newest from rows', () => {
|
||||
const cov = computeCoverage('!r', [row(30), row(10), row(20)], 3);
|
||||
assert.deepEqual(cov, { roomId: '!r', oldestTs: 10, newestTs: 30, count: 3 });
|
||||
|
||||
@@ -16,6 +16,17 @@ const DB_NAME = 'lotus-search-cache';
|
||||
const DB_VERSION = 1;
|
||||
const MESSAGES_STORE = 'messages';
|
||||
const COVERAGE_STORE = 'coverage';
|
||||
|
||||
// Cap cached rows per room so the on-disk index can't grow unbounded over a
|
||||
// long-lived session. When a room exceeds this, the oldest rows (by ts) are
|
||||
// evicted on write. ~5k small rows/room is generous search history; the coverage
|
||||
// window is intentionally left claiming the evicted tail so we don't re-fetch +
|
||||
// re-evict it forever (Clear cached index / logout still wipe everything).
|
||||
const MAX_ROWS_PER_ROOM = 5000;
|
||||
|
||||
/** How many of a room's rows to evict to bring it back to the cap (0 if under). */
|
||||
export const evictCount = (currentCount: number, max = MAX_ROWS_PER_ROOM): number =>
|
||||
Math.max(0, currentCount - max);
|
||||
const ROOM_TS_INDEX = 'roomTs';
|
||||
|
||||
/** A single cached, decrypted message row. Keyed on `[roomId, eventId]`. */
|
||||
@@ -90,6 +101,29 @@ const awaitTx = (tx: IDBTransaction): Promise<void> =>
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
|
||||
/**
|
||||
* Within an open readwrite tx, delete the oldest rows of `roomId` (ascending
|
||||
* `[roomId, ts]` index) until it's back under the cap. Self-chains IDB requests
|
||||
* so the transaction stays alive — never awaits a non-IDB promise mid-tx (which
|
||||
* would let the transaction auto-commit early).
|
||||
*/
|
||||
const pruneRoom = (store: IDBObjectStore, roomId: string): void => {
|
||||
const index = store.index(ROOM_TS_INDEX);
|
||||
const countReq = index.count(roomRange(roomId));
|
||||
countReq.onsuccess = () => {
|
||||
let remaining = evictCount(countReq.result);
|
||||
if (remaining <= 0) return;
|
||||
const cursorReq = index.openCursor(roomRange(roomId), 'next'); // oldest first
|
||||
cursorReq.onsuccess = () => {
|
||||
const cursor = cursorReq.result;
|
||||
if (!cursor || remaining <= 0) return;
|
||||
cursor.delete();
|
||||
remaining -= 1;
|
||||
cursor.continue();
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/** Upsert message rows. No-op on empty input or when IDB is unavailable. */
|
||||
export const putRows = async (rows: SearchCacheRow[]): Promise<void> => {
|
||||
if (rows.length === 0) return;
|
||||
@@ -99,6 +133,8 @@ export const putRows = async (rows: SearchCacheRow[]): Promise<void> => {
|
||||
const tx = db.transaction(MESSAGES_STORE, 'readwrite');
|
||||
const store = tx.objectStore(MESSAGES_STORE);
|
||||
rows.forEach((row) => store.put(row));
|
||||
// Bound growth: prune each room this batch touched back to the cap.
|
||||
new Set(rows.map((row) => row.roomId)).forEach((roomId) => pruneRoom(store, roomId));
|
||||
await awaitTx(tx);
|
||||
} catch {
|
||||
// Cache write failures must never surface to the UI.
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
factoryRoomIdByUnreadCount,
|
||||
factoryRoomIdByActivity,
|
||||
factoryRoomIdByAtoZ,
|
||||
factoryRoomIdByUnread,
|
||||
} from './sort';
|
||||
import type { Unread } from '../../types/matrix/room';
|
||||
|
||||
test('byTsOldToNew sorts ascending by timestamp', () => {
|
||||
assert.ok(byTsOldToNew(1, 2) < 0);
|
||||
@@ -45,6 +47,40 @@ test('factoryRoomIdByActivity sorts most-recently-active first', () => {
|
||||
assert.deepEqual(['missing', 'new'].sort(cmp), ['new', 'missing']);
|
||||
});
|
||||
|
||||
test('factoryRoomIdByUnread: unread first, by count, then activity for ties', () => {
|
||||
const ts: Record<string, number> = { a: 100, b: 300, c: 200, d: 400 };
|
||||
const mx = {
|
||||
getRoom: (id: string) => (id in ts ? { getLastActiveTimestamp: () => ts[id] } : null),
|
||||
} as unknown as MatrixClient;
|
||||
const mkUnread = (total: number): Unread => ({ total, highlight: 0, from: null });
|
||||
// a: 2 unread, b: 5 unread, c: read, d: read
|
||||
const roomToUnread = new Map<string, Unread>([
|
||||
['a', mkUnread(2)],
|
||||
['b', mkUnread(5)],
|
||||
['c', mkUnread(0)],
|
||||
['d', mkUnread(0)],
|
||||
]);
|
||||
const cmp = factoryRoomIdByUnread(roomToUnread, mx);
|
||||
// b (5) and a (2) lead by unread; then the read tail c/d breaks by activity
|
||||
// (d @400 more recent than c @200) rather than arbitrary order.
|
||||
assert.deepEqual(['a', 'b', 'c', 'd'].sort(cmp), ['b', 'a', 'd', 'c']);
|
||||
});
|
||||
|
||||
test('factoryRoomIdByUnread: equal unread counts break by activity', () => {
|
||||
const ts: Record<string, number> = { x: 100, y: 500 };
|
||||
const mx = {
|
||||
getRoom: (id: string) => ({ getLastActiveTimestamp: () => ts[id] ?? 0 }),
|
||||
} as unknown as MatrixClient;
|
||||
const mkUnread = (total: number): Unread => ({ total, highlight: 0, from: null });
|
||||
const roomToUnread = new Map<string, Unread>([
|
||||
['x', mkUnread(3)],
|
||||
['y', mkUnread(3)],
|
||||
]);
|
||||
const cmp = factoryRoomIdByUnread(roomToUnread, mx);
|
||||
// Same unread count → y (more recent) before x.
|
||||
assert.deepEqual(['x', 'y'].sort(cmp), ['y', 'x']);
|
||||
});
|
||||
|
||||
test('factoryRoomIdByAtoZ sorts case-insensitively and ignores leading #', () => {
|
||||
const names: Record<string, string> = { a: 'Banana', b: 'apple', c: '#Cherry' };
|
||||
const mx = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { Unread } from '../../types/matrix/room';
|
||||
|
||||
export type SortFunc<T> = (a: T, b: T) => number;
|
||||
|
||||
@@ -42,6 +43,26 @@ export const factoryRoomIdByUnreadCount =
|
||||
return bT - aT;
|
||||
};
|
||||
|
||||
// "Unread First": rooms with unread sort before those without, then by unread
|
||||
// count desc, then — crucially for the large all-read tail where counts tie —
|
||||
// by recent activity, so it isn't left in arbitrary order.
|
||||
export const factoryRoomIdByUnread = (
|
||||
roomToUnread: Map<string, Unread>,
|
||||
mx: MatrixClient,
|
||||
): SortFunc<string> => {
|
||||
const byActivity = factoryRoomIdByActivity(mx);
|
||||
return (a, b) => {
|
||||
const aUnread = roomToUnread.get(a);
|
||||
const bUnread = roomToUnread.get(b);
|
||||
const aHas = (aUnread?.total ?? 0) > 0;
|
||||
const bHas = (bUnread?.total ?? 0) > 0;
|
||||
if (aHas !== bHas) return aHas ? -1 : 1;
|
||||
const byCount = (bUnread?.total ?? 0) - (aUnread?.total ?? 0);
|
||||
if (byCount !== 0) return byCount;
|
||||
return byActivity(a, b);
|
||||
};
|
||||
};
|
||||
|
||||
export const byTsOldToNew: SortFunc<number> = (a, b) => a - b;
|
||||
|
||||
export const byOrderKey: SortFunc<string | undefined> = (a, b) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ const decide = (
|
||||
highlight: false,
|
||||
notify: false,
|
||||
roomMuted: false,
|
||||
roomMentionsOnly: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -53,6 +54,25 @@ describe('shouldNotifyThreadReply', () => {
|
||||
assert.equal(decide({ mode: ThreadNotificationMode.All, highlight: false }), 'notify');
|
||||
});
|
||||
|
||||
it('roomMentionsOnly: Default + participating + participated but no highlight => none', () => {
|
||||
assert.equal(decide({ roomMentionsOnly: true, participated: true }), 'none');
|
||||
});
|
||||
|
||||
it('roomMentionsOnly: Default + highlight still notifies loudly', () => {
|
||||
assert.equal(decide({ roomMentionsOnly: true, highlight: true }), 'loud');
|
||||
});
|
||||
|
||||
it('roomMentionsOnly does NOT suppress an explicit All override', () => {
|
||||
assert.equal(
|
||||
decide({ roomMentionsOnly: true, mode: ThreadNotificationMode.All, highlight: false }),
|
||||
'notify',
|
||||
);
|
||||
});
|
||||
|
||||
it('roomMentionsOnly: Default + defaultBehavior all + no highlight => none', () => {
|
||||
assert.equal(decide({ roomMentionsOnly: true, defaultBehavior: 'all' }), 'none');
|
||||
});
|
||||
|
||||
it('mode MentionsOnly + highlight => loud', () => {
|
||||
assert.equal(decide({ mode: ThreadNotificationMode.MentionsOnly, highlight: true }), 'loud');
|
||||
});
|
||||
|
||||
@@ -106,8 +106,16 @@ export function shouldNotifyThreadReply(input: {
|
||||
highlight: boolean;
|
||||
notify: boolean;
|
||||
roomMuted: boolean;
|
||||
/**
|
||||
* The room is set to "Mentions & Keywords only" (room push rule, not the
|
||||
* global default). When the thread mode is Default, this makes only
|
||||
* highlights notify — honoring the room preference instead of the
|
||||
* all/participating default (which otherwise over-notifies). An explicit
|
||||
* per-thread All/MentionsOnly/Mute override still wins.
|
||||
*/
|
||||
roomMentionsOnly: boolean;
|
||||
}): ThreadNotifyDecision {
|
||||
const { mode, defaultBehavior, participated, highlight, roomMuted } = input;
|
||||
const { mode, defaultBehavior, participated, highlight, roomMuted, roomMentionsOnly } = input;
|
||||
|
||||
if (roomMuted) return 'none';
|
||||
if (mode === ThreadNotificationMode.Mute) return 'none';
|
||||
@@ -120,13 +128,13 @@ export function shouldNotifyThreadReply(input: {
|
||||
return highlight ? 'loud' : 'none';
|
||||
}
|
||||
|
||||
// ThreadNotificationMode.Default
|
||||
if (defaultBehavior === 'all') {
|
||||
return highlight ? 'loud' : 'notify';
|
||||
}
|
||||
|
||||
// defaultBehavior === 'participating'
|
||||
// ThreadNotificationMode.Default — highlights always notify loudly.
|
||||
if (highlight) return 'loud';
|
||||
// Room is "Mentions & Keywords only": a Default thread inherits that, so a
|
||||
// non-highlight reply does not notify.
|
||||
if (roomMentionsOnly) return 'none';
|
||||
if (defaultBehavior === 'all') return 'notify';
|
||||
// defaultBehavior === 'participating'
|
||||
return participated ? 'notify' : 'none';
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
getBlueskyEmbed,
|
||||
getLoomId,
|
||||
getKickChannel,
|
||||
getMixcloudFeed,
|
||||
getDeezerEmbed,
|
||||
deezerEmbedHeight,
|
||||
getSteamTarget,
|
||||
steamWidgetEmbedUrl,
|
||||
buildVideoEmbedUrl,
|
||||
spotifyEmbedHeight,
|
||||
parseMediaEmbed,
|
||||
@@ -57,6 +62,10 @@ test('Vimeo (incl. unlisted hash + channel/group/album forms)', () => {
|
||||
assert.equal(getVimeoParts('https://vimeo.com/channels/staffpicks/76979871')?.id, '76979871');
|
||||
assert.equal(getVimeoParts('https://vimeo.com/groups/motion/videos/12345')?.id, '12345');
|
||||
assert.equal(getVimeoParts('https://vimeo.com/album/99/video/54321')?.id, '54321');
|
||||
// a normal video with a trailing sub-path segment must NOT capture it as a hash
|
||||
assert.equal(getVimeoParts('https://vimeo.com/123456789/likes')?.hash, undefined);
|
||||
assert.equal(getVimeoParts('https://vimeo.com/123456789/settings')?.hash, undefined);
|
||||
assert.equal(getVimeoParts('https://vimeo.com/123456789/likes')?.id, '123456789');
|
||||
});
|
||||
|
||||
test('extractEmbedHeight: Instagram / Reddit / Twitter shapes', () => {
|
||||
@@ -113,6 +122,7 @@ test('Dailymotion + Streamable', () => {
|
||||
assert.equal(getDailymotionId('https://dai.ly/x8abcde'), 'x8abcde');
|
||||
assert.equal(getStreamableId('https://streamable.com/abc12'), 'abc12');
|
||||
assert.equal(getStreamableId('https://streamable.com/e/abc12'), null); // already an embed path
|
||||
assert.equal(getStreamableId('https://streamable.com/login'), null); // reserved page
|
||||
});
|
||||
|
||||
test('Twitch: channel / video / clip', () => {
|
||||
@@ -132,6 +142,10 @@ test('Twitch: channel / video / clip', () => {
|
||||
type: 'clip',
|
||||
value: 'CoolSlug',
|
||||
});
|
||||
// reserved utility pages are not channels
|
||||
assert.equal(getTwitchTarget('https://twitch.tv/directory'), null);
|
||||
assert.equal(getTwitchTarget('https://twitch.tv/settings'), null);
|
||||
assert.equal(getTwitchTarget('https://twitch.tv/videos'), null); // bare /videos, not a channel
|
||||
});
|
||||
|
||||
test('Spotify target + height', () => {
|
||||
@@ -153,6 +167,9 @@ test('SoundCloud track detection', () => {
|
||||
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile
|
||||
// on.soundcloud.com short links intentionally not handled (need oEmbed resolve)
|
||||
assert.equal(isSoundCloudTrack('https://on.soundcloud.com/abc123'), false);
|
||||
assert.equal(isSoundCloudTrack('https://soundcloud.com/discover/xyz'), false); // site section
|
||||
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/sets'), false); // profile-tab listing
|
||||
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/sets/my-set'), true); // a real set
|
||||
});
|
||||
|
||||
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => {
|
||||
@@ -196,6 +213,88 @@ test('Apple Music: album vs single song height, embed host swap', () => {
|
||||
assert.equal(getAppleMusicEmbed('https://example.com/album/x/1'), null);
|
||||
});
|
||||
|
||||
test('Mixcloud: cloudcast feed vs profile/section', () => {
|
||||
assert.equal(
|
||||
getMixcloudFeed('https://www.mixcloud.com/NTSRadio/some-show-2024/'),
|
||||
'https://www.mixcloud.com/NTSRadio/some-show-2024/',
|
||||
);
|
||||
assert.equal(getMixcloudFeed('https://www.mixcloud.com/NTSRadio/'), null); // bare profile
|
||||
assert.equal(getMixcloudFeed('https://www.mixcloud.com/NTSRadio/uploads/'), null); // profile tab
|
||||
assert.equal(getMixcloudFeed('https://www.mixcloud.com/discover/house/'), null); // site section
|
||||
assert.equal(getMixcloudFeed('https://example.com/a/b/'), null);
|
||||
assert.ok(
|
||||
parseMediaEmbed('https://www.mixcloud.com/NTSRadio/some-show/', HOST)?.embedUrl.startsWith(
|
||||
'https://www.mixcloud.com/widget/iframe/?feed=',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('Deezer: track / album / playlist (+ locale prefix)', () => {
|
||||
assert.deepEqual(getDeezerEmbed('https://www.deezer.com/track/3135556'), {
|
||||
type: 'track',
|
||||
id: '3135556',
|
||||
});
|
||||
assert.deepEqual(getDeezerEmbed('https://deezer.com/en/album/302127'), {
|
||||
type: 'album',
|
||||
id: '302127',
|
||||
});
|
||||
assert.deepEqual(getDeezerEmbed('https://www.deezer.com/us/playlist/1479458365?utm=x'), {
|
||||
type: 'playlist',
|
||||
id: '1479458365',
|
||||
});
|
||||
// Podcasts live at /show/<id>; /podcast/<id> is not a real Deezer path.
|
||||
assert.deepEqual(getDeezerEmbed('https://www.deezer.com/us/show/1002330852'), {
|
||||
type: 'show',
|
||||
id: '1002330852',
|
||||
});
|
||||
assert.deepEqual(getDeezerEmbed('https://www.deezer.com/us/episode/897651701'), {
|
||||
type: 'episode',
|
||||
id: '897651701',
|
||||
});
|
||||
assert.equal(getDeezerEmbed('https://www.deezer.com/us/podcast/1002330852'), null);
|
||||
assert.equal(getDeezerEmbed('https://www.deezer.com/'), null);
|
||||
assert.equal(getDeezerEmbed('https://www.deezer.com/track/notanid'), null);
|
||||
assert.equal(deezerEmbedHeight('track'), 152);
|
||||
assert.equal(deezerEmbedHeight('episode'), 152);
|
||||
assert.equal(deezerEmbedHeight('show'), 352);
|
||||
assert.equal(deezerEmbedHeight('album'), 352);
|
||||
assert.ok(
|
||||
parseMediaEmbed('https://www.deezer.com/track/3135556', HOST)?.embedUrl.startsWith(
|
||||
'https://widget.deezer.com/widget/dark/track/3135556',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('getSteamTarget: app / news / bundle / non-content', () => {
|
||||
assert.deepEqual(getSteamTarget('https://store.steampowered.com/app/739630/Phasmophobia/'), {
|
||||
kind: 'app',
|
||||
appId: '739630',
|
||||
});
|
||||
assert.deepEqual(
|
||||
getSteamTarget(
|
||||
'https://store.steampowered.com/news/app/739630/view/668371152183232404?l=english',
|
||||
),
|
||||
{ kind: 'news', appId: '739630', gid: '668371152183232404' },
|
||||
);
|
||||
assert.deepEqual(getSteamTarget('https://store.steampowered.com/bundle/232/'), {
|
||||
kind: 'store',
|
||||
label: 'bundle',
|
||||
});
|
||||
assert.deepEqual(getSteamTarget('https://store.steampowered.com/sub/12345/'), {
|
||||
kind: 'store',
|
||||
label: 'sub',
|
||||
});
|
||||
// non-content store pages and other hosts are not embedded
|
||||
assert.equal(getSteamTarget('https://store.steampowered.com/'), null);
|
||||
assert.equal(getSteamTarget('https://store.steampowered.com/search/?term=horror'), null);
|
||||
assert.equal(getSteamTarget('https://steamcommunity.com/app/739630'), null);
|
||||
assert.equal(getSteamTarget('not a url'), null);
|
||||
});
|
||||
|
||||
test('steamWidgetEmbedUrl', () => {
|
||||
assert.equal(steamWidgetEmbedUrl('739630'), 'https://store.steampowered.com/widget/739630/');
|
||||
});
|
||||
|
||||
test('getTweetId', () => {
|
||||
assert.equal(getTweetId('https://x.com/user/status/1799999999999999999'), '1799999999999999999');
|
||||
assert.equal(getTweetId('https://twitter.com/user/status/12345'), '12345');
|
||||
@@ -287,6 +386,8 @@ test('Bluesky / Loom / Kick', () => {
|
||||
|
||||
assert.equal(getKickChannel('https://kick.com/somestreamer'), 'somestreamer');
|
||||
assert.equal(getKickChannel('https://kick.com/streamer/videos/123'), null); // VOD → no embed
|
||||
assert.equal(getKickChannel('https://kick.com/browse'), null); // nav page, not a channel
|
||||
assert.equal(getKickChannel('https://kick.com/following'), null);
|
||||
assert.ok(
|
||||
parseMediaEmbed('https://kick.com/streamer', 'h')?.embedUrl.includes(
|
||||
'player.kick.com/streamer?autoplay=true',
|
||||
|
||||
+246
-6
@@ -66,8 +66,10 @@ export function getVimeoParts(url: string): { id: string; hash?: string } | null
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
||||
// Canonical /{id} or unlisted /{id}/{hash}
|
||||
let m = pathname.match(/^\/(\d+)(?:\/([0-9a-zA-Z]+))?/);
|
||||
// Canonical /{id} or unlisted /{id}/{hash}. The hash is a lowercase-hex token
|
||||
// (constrain it so a normal video's trailing segment — /likes, /settings, a
|
||||
// review slug — isn't captured as a bogus `h=` param that Vimeo then rejects).
|
||||
let m = pathname.match(/^\/(\d+)(?:\/([0-9a-f]{6,}))?/);
|
||||
if (m) return { id: m[1], hash: m[2] };
|
||||
// channels/groups/album share a trailing numeric video id
|
||||
m = pathname.match(/\/(?:channels\/[^/]+|groups\/[^/]+\/videos|album\/[^/]+\/video)\/(\d+)/);
|
||||
@@ -192,17 +194,67 @@ export function getDailymotionId(url: string): string | null {
|
||||
|
||||
// --- Streamable -----------------------------------------------------------
|
||||
|
||||
// Streamable's own utility/first-path pages that are not video ids.
|
||||
const STREAMABLE_RESERVED = new Set([
|
||||
'e',
|
||||
'login',
|
||||
'signup',
|
||||
'settings',
|
||||
'account',
|
||||
'dashboard',
|
||||
'help',
|
||||
'terms',
|
||||
'privacy',
|
||||
'about',
|
||||
]);
|
||||
|
||||
export function getStreamableId(url: string): string | null {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname.replace(/^www\./, '') !== 'streamable.com') return null;
|
||||
const m = pathname.match(/^\/([A-Za-z0-9]+)/);
|
||||
return m && m[1] !== 'e' ? m[1] : null;
|
||||
return m && !STREAMABLE_RESERVED.has(m[1].toLowerCase()) ? m[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Steam ----------------------------------------------------------------
|
||||
|
||||
export type SteamTarget =
|
||||
// a game/app store page → gets the official click-to-play store widget
|
||||
| { kind: 'app'; appId: string }
|
||||
// a news/announcement post → a rich card (no official widget for these)
|
||||
| { kind: 'news'; appId: string; gid: string }
|
||||
// bundle / sub / dlc pages → an OG store card (no per-app widget)
|
||||
| { kind: 'store'; label: string };
|
||||
|
||||
/**
|
||||
* Classify a store.steampowered.com content URL. Only content pages (app / news /
|
||||
* bundle / sub / dlc) match; the homepage, search, wishlist, cart etc. return
|
||||
* null and fall through to the generic preview card.
|
||||
*/
|
||||
export function getSteamTarget(url: string): SteamTarget | null {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname.replace(/^www\./, '') !== 'store.steampowered.com') return null;
|
||||
let m = pathname.match(/^\/news\/app\/(\d+)\/view\/(\d+)/);
|
||||
if (m) return { kind: 'news', appId: m[1], gid: m[2] };
|
||||
m = pathname.match(/^\/app\/(\d+)/);
|
||||
if (m) return { kind: 'app', appId: m[1] };
|
||||
m = pathname.match(/^\/(bundle|sub|dlc)\/\d+/);
|
||||
if (m) return { kind: 'store', label: m[1] };
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Steam's official embeddable store widget (live price / discount / Buy). */
|
||||
export function steamWidgetEmbedUrl(appId: string): string {
|
||||
return `https://store.steampowered.com/widget/${encodeURIComponent(appId)}/`;
|
||||
}
|
||||
|
||||
// --- Twitch ---------------------------------------------------------------
|
||||
|
||||
export type TwitchTarget =
|
||||
@@ -210,6 +262,32 @@ export type TwitchTarget =
|
||||
| { type: 'video'; value: string }
|
||||
| { type: 'clip'; value: string };
|
||||
|
||||
// Twitch's own reserved first-path segments — single-segment paths that are
|
||||
// utility pages, not channels, and must NOT be embedded as `channel=<x>`.
|
||||
const TWITCH_RESERVED = new Set([
|
||||
'directory',
|
||||
'videos',
|
||||
'settings',
|
||||
'subscriptions',
|
||||
'following',
|
||||
'followers',
|
||||
'friends',
|
||||
'inventory',
|
||||
'wallet',
|
||||
'drops',
|
||||
'prime',
|
||||
'turbo',
|
||||
'downloads',
|
||||
'jobs',
|
||||
'store',
|
||||
'search',
|
||||
'dashboard',
|
||||
'popout',
|
||||
'p',
|
||||
'u',
|
||||
'team',
|
||||
]);
|
||||
|
||||
export function getTwitchTarget(url: string): TwitchTarget | null {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
@@ -219,7 +297,9 @@ export function getTwitchTarget(url: string): TwitchTarget | null {
|
||||
if (h === 'twitch.tv' || h === 'm.twitch.tv') {
|
||||
if (parts[0] === 'videos' && parts[1]) return { type: 'video', value: parts[1] };
|
||||
if (parts[1] === 'clip' && parts[2]) return { type: 'clip', value: parts[2] };
|
||||
if (parts.length === 1 && parts[0]) return { type: 'channel', value: parts[0] };
|
||||
if (parts.length === 1 && parts[0] && !TWITCH_RESERVED.has(parts[0].toLowerCase())) {
|
||||
return { type: 'channel', value: parts[0] };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -248,6 +328,38 @@ export function getSpotifyEmbedTarget(url: string): { type: SpotifyType; id: str
|
||||
|
||||
// --- SoundCloud -----------------------------------------------------------
|
||||
|
||||
// SoundCloud's own site sections (first segment) that are never `<artist>`.
|
||||
const SOUNDCLOUD_RESERVED = new Set([
|
||||
'discover',
|
||||
'you',
|
||||
'stream',
|
||||
'search',
|
||||
'upload',
|
||||
'settings',
|
||||
'notifications',
|
||||
'messages',
|
||||
'tags',
|
||||
'charts',
|
||||
'people',
|
||||
'pages',
|
||||
'terms',
|
||||
'pro',
|
||||
]);
|
||||
// Profile tabs — `/<artist>/<tab>` is a listing, not a single track (a real set
|
||||
// is the deeper `/<artist>/sets/<slug>`, which has length >= 3 and is allowed).
|
||||
const SOUNDCLOUD_PROFILE_TABS = new Set([
|
||||
'tracks',
|
||||
'sets',
|
||||
'albums',
|
||||
'reposts',
|
||||
'likes',
|
||||
'following',
|
||||
'followers',
|
||||
'comments',
|
||||
'popular-tracks',
|
||||
'toptracks',
|
||||
]);
|
||||
|
||||
export function isSoundCloudTrack(url: string): boolean {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
@@ -260,7 +372,11 @@ export function isSoundCloudTrack(url: string): boolean {
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.split('/')
|
||||
.filter(Boolean);
|
||||
return parts.length >= 2;
|
||||
if (parts.length < 2) return false;
|
||||
if (SOUNDCLOUD_RESERVED.has(parts[0].toLowerCase())) return false;
|
||||
// `/<artist>/<tab>` profile-tab listing (not a playable single track/set).
|
||||
if (parts.length === 2 && SOUNDCLOUD_PROFILE_TABS.has(parts[1].toLowerCase())) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -391,6 +507,22 @@ export function getLoomId(url: string): string | null {
|
||||
|
||||
// --- Kick (live channels only; VODs/clips have no clean iframe) ------------
|
||||
|
||||
// Kick's own reserved first-path segments (nav pages, not channels).
|
||||
const KICK_RESERVED = new Set([
|
||||
'browse',
|
||||
'following',
|
||||
'category',
|
||||
'categories',
|
||||
'search',
|
||||
'messages',
|
||||
'subscriptions',
|
||||
'settings',
|
||||
'wallet',
|
||||
'help',
|
||||
'clips',
|
||||
'dashboard',
|
||||
]);
|
||||
|
||||
export function getKickChannel(url: string): string | null {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
@@ -399,7 +531,11 @@ export function getKickChannel(url: string): string | null {
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.split('/')
|
||||
.filter(Boolean);
|
||||
return parts.length === 1 && /^[A-Za-z0-9_]+$/.test(parts[0]) ? parts[0] : null;
|
||||
return parts.length === 1 &&
|
||||
/^[A-Za-z0-9_]+$/.test(parts[0]) &&
|
||||
!KICK_RESERVED.has(parts[0].toLowerCase())
|
||||
? parts[0]
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -419,6 +555,90 @@ export function getBlueskyEmbed(url: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mixcloud -------------------------------------------------------------
|
||||
|
||||
// Mixcloud's own site sections (first segment) that are never a `<user>`.
|
||||
const MIXCLOUD_RESERVED = new Set([
|
||||
'discover',
|
||||
'categories',
|
||||
'upload',
|
||||
'live',
|
||||
'settings',
|
||||
'notifications',
|
||||
'search',
|
||||
'tag',
|
||||
'select',
|
||||
'browse',
|
||||
]);
|
||||
// Profile tabs — `/<user>/<tab>` is a listing, not a single cloudcast.
|
||||
const MIXCLOUD_PROFILE_TABS = new Set([
|
||||
'uploads',
|
||||
'favorites',
|
||||
'listens',
|
||||
'following',
|
||||
'followers',
|
||||
'playlists',
|
||||
'stream',
|
||||
'reposts',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Canonical Mixcloud cloudcast feed URL (`/<user>/<slug>/`) for the widget's
|
||||
* `feed=` param, or null. Bare profiles / profile-tab listings / site sections
|
||||
* are excluded (they aren't a single playable cloudcast).
|
||||
*/
|
||||
export function getMixcloudFeed(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.hostname.replace(/^www\./, '') !== 'mixcloud.com') return null;
|
||||
const parts = u.pathname
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.split('/')
|
||||
.filter(Boolean);
|
||||
if (parts.length < 2) return null;
|
||||
if (MIXCLOUD_RESERVED.has(parts[0].toLowerCase())) return null;
|
||||
if (MIXCLOUD_PROFILE_TABS.has(parts[1].toLowerCase())) return null;
|
||||
return `https://www.mixcloud.com/${parts[0]}/${parts[1]}/`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Deezer ---------------------------------------------------------------
|
||||
|
||||
// NB: Deezer podcast pages are `/show/<id>`, not `/podcast/<id>` — the latter
|
||||
// 404s on their own oEmbed API, and `widget.deezer.com/widget/dark/show/<id>`
|
||||
// is the matching widget path.
|
||||
const DEEZER_TYPES = ['track', 'album', 'playlist', 'artist', 'show', 'episode'] as const;
|
||||
export type DeezerType = (typeof DEEZER_TYPES)[number];
|
||||
|
||||
/** deezer.com[/<locale>]/<type>/<id> → widget target, or null. */
|
||||
export function getDeezerEmbed(url: string): { type: DeezerType; id: string } | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.hostname.replace(/^www\./, '') !== 'deezer.com') return null;
|
||||
const parts = u.pathname.replace(/^\/+/, '').split('/').filter(Boolean);
|
||||
// optional locale prefix (/en/, /us/, /fr/…) then <type>/<id>
|
||||
const idx = parts.findIndex((p) => (DEEZER_TYPES as readonly string[]).includes(p));
|
||||
if (idx === -1 || !parts[idx + 1]) return null;
|
||||
const id = parts[idx + 1].split('?')[0];
|
||||
if (!/^\d+$/.test(id)) return null;
|
||||
return { type: parts[idx] as DeezerType, id };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deezer single track/episode players are compact; collections show a scrollable
|
||||
* tracklist and need room. Mirrors the Spotify sizing (152 / 352) — the widget
|
||||
* requests `tracklist=true`, so 352 keeps the list from being clipped the way a
|
||||
* shorter box would.
|
||||
*/
|
||||
export function deezerEmbedHeight(type: DeezerType): number {
|
||||
return type === 'track' || type === 'episode' ? 152 : 352;
|
||||
}
|
||||
|
||||
// --- Embed-URL builders ---------------------------------------------------
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
@@ -536,6 +756,26 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
|
||||
if (tidal)
|
||||
return { provider: 'tidal', kind: tidal.kind, embedUrl: tidal.embedUrl, height: tidal.height };
|
||||
|
||||
const mixFeed = getMixcloudFeed(url);
|
||||
if (mixFeed)
|
||||
return {
|
||||
provider: 'mixcloud',
|
||||
kind: 'audio',
|
||||
embedUrl: `https://www.mixcloud.com/widget/iframe/?feed=${enc(mixFeed)}&light=0`,
|
||||
height: 120,
|
||||
};
|
||||
|
||||
const deezer = getDeezerEmbed(url);
|
||||
if (deezer)
|
||||
return {
|
||||
provider: 'deezer',
|
||||
kind: 'audio',
|
||||
embedUrl: `https://widget.deezer.com/widget/dark/${deezer.type}/${enc(
|
||||
deezer.id,
|
||||
)}?app_id=457142&autoplay=false&radius=true&tracklist=true`,
|
||||
height: deezerEmbedHeight(deezer.type),
|
||||
};
|
||||
|
||||
const insta = getInstagramEmbed(url);
|
||||
if (insta) return { provider: 'instagram', kind: 'rich', embedUrl: insta, height: 720 };
|
||||
|
||||
|
||||
@@ -30,13 +30,23 @@ export class LotusOidcTokenRefresher extends OidcTokenRefresher {
|
||||
this.oidcRef = oidc;
|
||||
}
|
||||
|
||||
// F5 — persist the new expiry so the stored `expiresAt` stays fresh across
|
||||
// reloads instead of going stale. The SDK invokes persistTokens synchronously
|
||||
// inside the refresh and passes the freshly-refreshed `expiry` (a Date) on the
|
||||
// tokens object at runtime, even though its published type omits it — so read
|
||||
// it here directly (a doRefreshAccessToken override would run too late, since
|
||||
// persistTokens is called before that method returns).
|
||||
protected async persistTokens(tokens: {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiry?: Date;
|
||||
}): Promise<void> {
|
||||
const expiresInMs =
|
||||
tokens.expiry instanceof Date ? Math.max(0, tokens.expiry.getTime() - Date.now()) : undefined;
|
||||
setFallbackSession(tokens.accessToken, this.deviceIdRef, this.userIdRef, this.baseUrlRef, {
|
||||
refreshToken: tokens.refreshToken,
|
||||
oidc: this.oidcRef,
|
||||
expiresInMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user