Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d9ee10926 | ||
|
|
f2c356f288 | ||
|
|
f136082181 | ||
|
|
0aef410f60 | ||
|
|
b2376513fd | ||
|
|
6a0037ecec | ||
|
|
2bbd390a3b | ||
|
|
f12e05c510 | ||
|
|
d47032a14f | ||
|
|
b2678d5c6d | ||
|
|
1176bea0ee | ||
|
|
477df4ae32 | ||
|
|
15d85f52c4 | ||
|
|
0ddf86c678 | ||
|
|
bd5f6a0855 | ||
|
|
5175c095b7 | ||
|
|
99629edd9c | ||
|
|
8a461610f4 | ||
|
|
53a2f738a9 | ||
|
|
08e191008b | ||
|
|
654466cf45 | ||
|
|
3ff8fb8e55 | ||
|
|
02089cf60e | ||
|
|
fff811cb2d | ||
|
|
f54c386f36 | ||
|
|
6dc0865965 | ||
|
|
5656162720 | ||
|
|
c6d558e5dd | ||
|
|
d416c62b4c | ||
|
|
a24c98b199 | ||
|
|
8fbde6df36 | ||
|
|
1963222d1e | ||
|
|
d07f16586a | ||
|
|
2c0cd0d26c | ||
|
|
b0a3c81b15 | ||
|
|
c9d9d91415 | ||
|
|
29ff16546a | ||
|
|
e1bb8301f0 | ||
|
|
098e3c900f | ||
|
|
6cbd7337f4 | ||
|
|
4154cae55a | ||
|
|
a5cc8a6d77 |
+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:
|
The browser tab title updates to reflect unread state:
|
||||||
|
|
||||||
- `(N) Lotus Chat` — N unread messages
|
- `(N) Lotus Chat` — N mentions / keyword highlights (the count is highlights, not total unread)
|
||||||
- `· Lotus Chat` — unread activity without a specific count
|
- `· Lotus Chat` — unread messages without a mention (activity, no specific count)
|
||||||
- `Lotus Chat` — no unread items
|
- `Lotus Chat` — no unread items
|
||||||
|
|
||||||
|
The favicon mirrors this (highlight badge / unread dot / default).
|
||||||
|
|
||||||
### Extended Profile Fields
|
### Extended Profile Fields
|
||||||
|
|
||||||
Supports MSC4133 custom profile fields via `PUT /_matrix/client/unstable/uk.tcpip.msc4133/{userId}/{field}`:
|
Supports MSC4133 custom profile fields via `PUT /_matrix/client/unstable/uk.tcpip.msc4133/{userId}/{field}`:
|
||||||
@@ -1094,10 +1096,9 @@ OS-level notifications are unchanged and still fire when the window is not focus
|
|||||||
|
|
||||||
### Collapsible Long Messages
|
### Collapsible Long Messages
|
||||||
|
|
||||||
Messages exceeding a configurable line threshold are truncated with a "Show more" toggle.
|
Messages exceeding a fixed height threshold are truncated with a "Show more" toggle.
|
||||||
|
|
||||||
- Default threshold: 20 lines
|
- Threshold: a fixed `COLLAPSE_MAX_HEIGHT` of 320px (≈ 20 lines) — not currently user-configurable
|
||||||
- Threshold is configurable in **Settings → Appearance**
|
|
||||||
- Uses CSS `max-height` + `overflow: hidden` with a smooth transition
|
- Uses CSS `max-height` + `overflow: hidden` with a smooth transition
|
||||||
- Transition is disabled when `prefers-reduced-motion: reduce` is active
|
- Transition is disabled when `prefers-reduced-motion: reduce` is active
|
||||||
|
|
||||||
@@ -1288,9 +1289,9 @@ Features:
|
|||||||
|
|
||||||
Accessible via **Room/Space Settings → Policy Lists** (admin only).
|
Accessible via **Room/Space Settings → Policy Lists** (admin only).
|
||||||
|
|
||||||
- Displays the room's subscribed policy lists in read-only format
|
- Enter a policy-list room's **ID or alias** (one you have already joined) to view its `m.policy.rule.user` / `.room` / `.server` rules in read-only format
|
||||||
- Subscribe (join) and unsubscribe (leave) controls for each list
|
- Viewer only — there are **no** subscribe/unsubscribe controls and no listing of "subscribed" lists; join or leave the policy-list room itself the normal way
|
||||||
- Enforcement is delegated to Draupnir or equivalent tooling; Lotus only manages list membership
|
- Enforcement is delegated to Draupnir or equivalent tooling
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -1,6 +1,6 @@
|
|||||||
# Lotus Chat — Manual Testing Guide
|
# Lotus Chat — Manual Testing Guide
|
||||||
|
|
||||||
**Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP)
|
**Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP; added the **Automated coverage map** below — logic now pinned by unit tests, so manual QA can focus on the human-only surface)
|
||||||
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
|
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
|
||||||
|
|
||||||
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
|
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
|
||||||
@@ -28,6 +28,30 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Automated coverage map — what the unit tests already pin (2026-07)
|
||||||
|
|
||||||
|
**Read this before working the guide.** Much of the _logic_ these manual checks were written to catch is now locked by deterministic unit tests (`npm test`, 920+ cases, green in CI). Unit tests do **not** prove visual rendering, real-call behavior, the desktop build, E2EE, or cross-device sync — those still need a human. But where a decision is pure logic, you can **trust the test and spend your manual time on the human-only part**. For each row below, the middle column is "don't bother re-deriving this by hand"; the right column is "this is what your manual pass is actually for."
|
||||||
|
|
||||||
|
| Guide item | Logic **pinned by a unit test** (trust it) | What still needs **you** (manual) |
|
||||||
|
| :----------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **A1** ringtone previews | `callSounds.test.ts` — each style's synthesized melody (chime/soft/retro), click-free gain ramps, context unlock/reuse, unknown = no-op | that it's actually **audible** + the WebAudio first-gesture caveat |
|
||||||
|
| **A2** ringtone persist/fallback | `settings.test.ts` — unknown `ringtoneId` → default, malformed JSON → defaults, merge-over-defaults (this **is** A2 step 3) | the dropdown shows the persisted value after reload (trivial glance) |
|
||||||
|
| **B2/B3** poll voting | `poll.test.ts` (18) — vote tally, latest-per-sender, multi-select, cleared/re-vote, winners, results-visible, single-vs-multi validation | **visual** only: borders, radio-vs-checkbox, progress-bar fill, on each theme |
|
||||||
|
| **O2/P4-1** thread notifications | `threadNotifications.test.ts` (32) — the **entire** notify decision (participating default, All/Mentions/Mute, @mention+highlight override, room-mute trumps), mode-map + muted-badge hygiene | live **2-person** delivery + sound + **cross-device** account-data sync |
|
||||||
|
| **O3** math / LaTeX | `mathParse.test.ts` (14) — inline `$…$`, block `$$…$$`, **currency guard** (`$5 and $10`), escaped/unbalanced stay text, adjacency rules | KaTeX **renders** visually + lazy-chunk load; code-block-literal is the markdown pipeline |
|
||||||
|
| **O4/P4-8** encrypted search cache | `searchCache.test.ts` — the pure helpers `mergeSearchResults` (merge/dedupe/sort) + `computeCoverage` (window widening) + resilient-when-IDB-absent. **The IDB round-trip test is `skip`ped under `npm test`** (node has no IndexedDB), so it runs only in a browser-like env, not CI | the actual IndexedDB persist-across-**reload**, Clear button, **logout wipe** (integration — and the round-trip itself) |
|
||||||
|
| **M1** `has:image/file/video` | `useMessageSearch.test.ts` — `filterGroupsByMsgType` union filter, drops empty groups, ignores non-string msgtype | the chips render + compose with room/sender/date filters |
|
||||||
|
| **M4** pinned-only filter | `useMessageSearch.test.ts` — `filterGroupsByPinned` keeps pinned, drops empty | chip renders; needs a room with actual pins |
|
||||||
|
| **M2** recent searches | `recentSearches.test.ts` (6) — prepend, dedupe+move-to-front, trim, ignore-empty, cap-at-10 | chips render/click-re-run; persistence across refresh |
|
||||||
|
| **Retention** (disappearing msgs) | `retention.test.ts` — `isExpired` window math (strict boundary), disabled = never, preset monotonicity | the timeline **hide** + self-**redact** integration; Synapse-side purge |
|
||||||
|
| **O5/N97a** session hardening | `sessions.test.ts` (22) — blob migration, legacy-key coercion, dual-write blob↔legacy sync, corrupt/partial-blob fallback, token-refresh, AND the `subscribeSessionChanges` storage-event logic (fires on session/null, ignores unrelated keys) | the real **cross-tab** logout _behavior_ end-to-end (two live tabs) |
|
||||||
|
| **Q1/Q2** embeds (URL→player) | `videoEmbed.test.ts` (26) — every provider's URL→`{provider, kind, embedUrl, height}` parse (incl. Mixcloud/Deezer, TikTok, reserved-path guards) | the click-to-play **facade**, no-network-until-Play, the **CSP** (esp. desktop), visuals |
|
||||||
|
| **Seasonal theme resolution** (part of F2) | `seasonSchedule.test.ts` — `resolveSeasonTheme` (off→none, auto→active season, pinned→that) + `getActiveSeason` priority/boundary days. **NB: this pins _which_ theme shows for a date, NOT F2's background↔seasonal mutual exclusion** — that write-side logic is untested | all of **F2**: the picker actually clearing the _other_ setting live, and the overlay suppression when a background is set |
|
||||||
|
|
||||||
|
Everything else in the guide (calls, screen readers, desktop/Tauri, chat backgrounds, animated visuals, PWA install, real E2EE) is genuinely manual — no unit test substitutes for it. Items already **verified live** are listed at the very bottom ("Verified working in live testing").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## A. Calls — new ringtone + notification work (highest priority)
|
## A. Calls — new ringtone + notification work (highest priority)
|
||||||
|
|
||||||
### A1. Ringtone selection — preview in Settings
|
### A1. Ringtone selection — preview in Settings
|
||||||
|
|||||||
+88
-8
@@ -91,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.
|
- [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).
|
- **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)
|
### ✅ 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).
|
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).
|
||||||
@@ -149,6 +205,31 @@ Genuine Matrix client-spec / MSC features Lotus does **not** yet implement (audi
|
|||||||
|
|
||||||
**Server-gated / advanced (capture, don't build yet):** QR sign-in for a new device (**MSC4108** rendezvous — needs an HS-side endpoint); dehydrated devices (**MSC3814** — offline key delivery, also helps the E2EE KE cluster); E2EE history key sharing on invite (**MSC3061** `shared_history`, niche); voice broadcast (Element MSC3888, low value — skip).
|
**Server-gated / advanced (capture, don't build yet):** QR sign-in for a new device (**MSC4108** rendezvous — needs an HS-side endpoint); dehydrated devices (**MSC3814** — offline key delivery, also helps the E2EE KE cluster); E2EE history key sharing on invite (**MSC3061** `shared_history`, niche); voice broadcast (Element MSC3888, low value — skip).
|
||||||
|
|
||||||
|
### [PARKED] Matrix 2.0 call membership — MSC4354 Sticky Events (investigated 2026-07, 3 agents + live infra check)
|
||||||
|
|
||||||
|
Move MatrixRTC/Element Call call-membership from state events (MSC3401) to **sticky events** — the "Matrix 2.0" path. **Not a flag flip; a coordinated rollout. Parked deliberately.**
|
||||||
|
|
||||||
|
Findings:
|
||||||
|
|
||||||
|
- **Server (Synapse 1.157.1, LXC 151):** `msc4354_enabled` defaults `false`. Enabling is **low-risk, additive, reversible** — schema (`sticky_events` table) already ships unconditionally, no migration/backfill, all runtime paths flag-gated, residual rows self-expire ≤1h. The one historical `/sync` EDU-filter bug (#19787) was fixed in 1.155.0; SQLite guard N/A (we're Postgres).
|
||||||
|
- **The flag alone is a no-op for behavior.** Our EC fork (upstream **v0.20.1** base, `@lotusguild/element-call-embedded`, bundled into Cinny at build → fleet upgrades atomically) gates sticky mode behind BOTH server support AND a per-device **developer-settings** radio (`matrix-rtc-mode`, defaults `Legacy`). Enabling the flag only un-greys that radio; no client changes what it sends until a human toggles it.
|
||||||
|
- **Matrix-layer mixed-mode = safe:** js-sdk (v41.6.0) reads + merges sticky and state membership, so cross-mode participants see each other.
|
||||||
|
- **Open risk before any real rollout:** media layer. Sticky mode drops `livekit_alias` + uses lk-jwt-service `/get_token` (slot `m.call#ROOM`); legacy uses `/sfu/get` (`room=roomId`). Both endpoints are **live** on our lk-jwt-service, but whether they resolve to the **same LiveKit room** is unverified — must confirm with a **two-account cross-mode test call** (one device `Matrix_2_0`, one `Legacy`) before changing the default, else split-at-media.
|
||||||
|
|
||||||
|
To actually adopt (future): (1) enable `msc4354_enabled: true` + restart; (2) two-account media-interop test; (3) if unified, flip EC default mode `Legacy`→`Compatibility`/`Matrix_2_0` in the fork + redeploy; (4) keep legacy fallback during transition. **No user benefit until step 3.**
|
||||||
|
|
||||||
|
### [ ] Matrix 2.0 call membership — MSC4354 sticky events (INVESTIGATED 2026-07, deliberately NOT enabled)
|
||||||
|
|
||||||
|
3-agent investigation after the 1.157.1 upgrade (EC-fork behavior · Synapse/upstream readiness · client-fleet composition). **Conclusion: leave `msc4354_enabled` OFF for now** — enabling it is safe but delivers **zero user-visible benefit on its own**, and introduces a latent footgun.
|
||||||
|
|
||||||
|
**Why it's a no-op alone:** the EC fork's `doesServerSupportUnstableFeature(MSC4354)` probe feeds **exactly one thing** — whether the "Matrix 2.0" radio in **Developer Settings** is greyed out (`DeveloperSettingsTab.tsx:349-353`). The real switch is the per-device `matrixRTCMode` setting (`settings.ts:149-152`), which **defaults to `Legacy`** and never auto-enables. Sticky sending is gated at `LocalMember.ts:862` (`unstableSendStickyEvents: mode === Matrix_2_0`). So flipping the server flag changes nothing any client sends.
|
||||||
|
|
||||||
|
**Verified safe:** Synapse-side is **additive and cleanly reversible** — the `sticky_events` schema ships unconditionally (no migration/backfill on enable), every write/read/serialize/replication path is flag-gated, disabling stops it instantly and residual rows self-expire ≤1h. The one relevant bug (#19787 `/sync` EDU-filter) was fixed in 1.155.0; the SQLite<3.40 guard doesn't apply (we're on PG 17.10). Matrix-layer **mixed-mode visibility is safe**: js-sdk `collectMembersEvents` reads **both** sticky and state membership and merges them, so sticky-mode and legacy-mode participants see each other. Our `lk-jwt-service` already serves **both** JWT endpoints (legacy `/sfu/get` **and** the sticky-mode `/get_token` — both probed live, 400-with-validation-error = present). EC is bundled into cinny's build (`@lotusguild/element-call-embedded`), so the fleet upgrades **atomically** — the "all EC clients ≥ v0.17.0" precondition is structurally guaranteed for our own users.
|
||||||
|
|
||||||
|
**The one unresolved risk (blocks a real rollout, not the flag):** sticky mode drops `livekit_alias` and uses `/get_token` (slot `m.call#ROOM`) while legacy uses `/sfu/get` (`room=roomId`). **Whether both resolve to the same LiveKit room is a property of lk-jwt-service, not the client** — unverified. If they diverge, cross-mode participants appear in each other's member list but are **split at the media layer** (silent, no error). Requires a **two-account test call** (one device on Legacy, one on Matrix 2.0) to confirm before anyone relies on it.
|
||||||
|
|
||||||
|
**If we ever do this:** (1) run the two-account media-interop test; (2) only then consider enabling `msc4354_enabled: true` in `/etc/matrix-synapse/homeserver.yaml` (LXC 151) + restart; (3) treat a default-mode change as a separate coordinated EC rollout. MSC4354 is still **OPEN upstream** (not in FCP, `needs-implementation`), so this stays experimental regardless.
|
||||||
|
|
||||||
### Remaining spec/MSC gaps (2026-07 full-surface survey)
|
### Remaining spec/MSC gaps (2026-07 full-surface survey)
|
||||||
|
|
||||||
After Phases A–C the client spec is ~complete. What's left, flagged by **what unblocks it**:
|
After Phases A–C the client spec is ~complete. What's left, flagged by **what unblocks it**:
|
||||||
@@ -221,11 +302,7 @@ Intentional desktop deltas (disclosed, non-regressive): volume sliders below lab
|
|||||||
|
|
||||||
### [ ] Inline media embeds — remaining providers (LOW PRIORITY)
|
### [ ] Inline media embeds — remaining providers (LOW PRIORITY)
|
||||||
|
|
||||||
The inline embed system (`videoEmbed.ts`) covers 16 providers; three more were **deliberately deferred** (verified against 2026 docs by review agents):
|
The inline embed system (`videoEmbed.ts`) covers 18 providers (16 + Mixcloud/Deezer); three more were **deliberately deferred** (verified against 2026 docs by review agents):
|
||||||
|
|
||||||
### [ ] 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):
|
|
||||||
|
|
||||||
- **Bandcamp** (highest-value audio add) — needs an **oEmbed** round-trip: the player URL requires numeric `album`/`track` item ids that aren't in the page URL (`bandcamp.com/oembed` is the resolver; mirror the `TikTokEmbedCard` on-click oEmbed pattern). CSP `frame-src`: `bandcamp.com`. Classify `kind: 'audio'`.
|
- **Bandcamp** (highest-value audio add) — needs an **oEmbed** round-trip: the player URL requires numeric `album`/`track` item ids that aren't in the page URL (`bandcamp.com/oembed` is the resolver; mirror the `TikTokEmbedCard` on-click oEmbed pattern). CSP `frame-src`: `bandcamp.com`. Classify `kind: 'audio'`.
|
||||||
- **SoundCloud `on.soundcloud.com` short links** — the `w.soundcloud` widget resolver does **not** follow the redirect; needs the same on-click oEmbed resolve (`soundcloud.com/oembed`, CORS-enabled) to get the canonical URL. (Canonical `soundcloud.com/{user}/{track}` links already work.)
|
- **SoundCloud `on.soundcloud.com` short links** — the `w.soundcloud` widget resolver does **not** follow the redirect; needs the same on-click oEmbed resolve (`soundcloud.com/oembed`, CORS-enabled) to get the canonical URL. (Canonical `soundcloud.com/{user}/{track}` links already work.)
|
||||||
@@ -235,6 +312,8 @@ Also open (from the quality review): a real `onError`/error-state fallback for i
|
|||||||
|
|
||||||
**✅ 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.
|
**✅ 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.
|
**✅ 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):**
|
**Deferred / surfaced from the hunt (not fixed — decide before doing):**
|
||||||
@@ -252,7 +331,7 @@ Also open (from the quality review): a real `onError`/error-state fallback for i
|
|||||||
|
|
||||||
## 🚫 Blocked Features (server / upstream gated)
|
## 🚫 Blocked Features (server / upstream gated)
|
||||||
|
|
||||||
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade.
|
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
|
||||||
|
|
||||||
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
|
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
|
||||||
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
|
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
|
||||||
@@ -265,8 +344,9 @@ Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgra
|
|||||||
|
|
||||||
### Server Capabilities (as of 2026-07)
|
### Server Capabilities (as of 2026-07)
|
||||||
|
|
||||||
- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.156.0+trixie1` (upgraded 2026-07-07 from 1.155; apt package on Debian 13, LXC 151) · **Matrix spec** up to `v1.12` (Synapse still advertises v1.12; MSC features via `unstable_features`).
|
- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.157.1+trixie1` (upgraded 2026-07-23 from **1.156.0** — note the host was found on 1.156.0 while the docs claimed 1.155.0, so **always verify with `dpkg-query -W matrix-synapse-py3`**, don't trust the docs; apt package on Debian 13, LXC 151) · **Matrix spec** up to `v1.12` (Synapse still advertises v1.12; MSC features via `unstable_features`).
|
||||||
- **MSC ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (room summary live at unstable `im.nheko.summary/summary/{id}` — 200; the `/v1/rooms/{id}/summary` path is still 404) · `msc3401_matrix_rtc`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892`.
|
- **MSC ON** (re-dumped live from `/_matrix/client/versions` on 1.157.1): `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (room summary live at unstable `im.nheko.summary/summary/{id}` — 200; the `/v1/rooms/{id}/summary` path is still 404) · `msc3401_matrix_rtc` · `msc2285.stable` · `msc3827.stable` · `msc3981` · `msc4380.stable` · `msc4445` · `msc2659.stable` · `msc2666` · `msc2432` · `e2e_cross_signing` · `label_based_filtering`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892` · `msc4028` · `msc4069` · `msc4108` · `msc3391` · `msc4354` (sticky events — **deliberately off**, see the Matrix 2.0 section above) · `msc4143` (RTC foci — **not a gap**: LiveKit is discovered via `.well-known` `org.matrix.msc4143.rtc_foci`, confirmed live, not this flag).
|
||||||
|
- **Dead client code:** Synapse 1.157.0 **removed** `msc3861` (MAS auth delegation) entirely — the ~6 `msc3861`/`msc2965` references in `src/` can never activate against this homeserver (we auth via Authelia `oidc_providers`). Harmless, but cleanup material.
|
||||||
- **Live endpoints:** Report User (MSC4260) **200** ✅ · Report Room (MSC4151) ✅.
|
- **Live endpoints:** Report User (MSC4260) **200** ✅ · Report Room (MSC4151) ✅.
|
||||||
- **Homeserver access (audits):** Synapse = LXC 151 (`pct exec 151 -- bash`), config `/etc/matrix-synapse/homeserver.yaml`. Web deploy = LXC 106. Voice guard = `voice-limit-guard.py` on LXC 151.
|
- **Homeserver access (audits):** Synapse = LXC 151 (`pct exec 151 -- bash`), config `/etc/matrix-synapse/homeserver.yaml`. Web deploy = LXC 106. Voice guard = `voice-limit-guard.py` on LXC 151.
|
||||||
- **SDK notes:** no arbitrary profile-field methods (use `mx.http.authedRequest()` for MSC4133); js-sdk can't per-room filter `/sync`; sanitizer strips `<math>`/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`.
|
- **SDK notes:** no arbitrary profile-field methods (use `mx.http.authedRequest()` for MSC4133); js-sdk can't per-room filter `/sync`; sanitizer strips `<math>`/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`.
|
||||||
|
|||||||
@@ -13,7 +13,38 @@ The source code is licensed under [AGPLv3](LICENSE), the same license as the ups
|
|||||||
The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
|
The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## Development Environment Setup
|
||||||
|
#### Getting correct Node version
|
||||||
|
- Ensure you have the correct version of node installed, specified in `.node-version`
|
||||||
|
- Use this command from the terminal to install nvm
|
||||||
|
```bash
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
|
||||||
|
```
|
||||||
|
- Reload your terminal shell using (for Ubuntu):
|
||||||
|
```bash
|
||||||
|
source ~/.bashrc
|
||||||
|
```
|
||||||
|
- Install the specified Node version
|
||||||
|
```bash
|
||||||
|
NODE_VERSION="$(tr -d '[:space:]' < .node-version)"
|
||||||
|
nvm install "$NODE_VERSION"
|
||||||
|
nvm use "$NODE_VERSION"
|
||||||
|
```
|
||||||
|
- verify the correct version was installed by running
|
||||||
|
```bash
|
||||||
|
node --version
|
||||||
|
```
|
||||||
|
and comparing the output to what is listed in `.node-version`
|
||||||
|
#### Install npm packages
|
||||||
|
- To install the npm packages listed in `package.json` run:
|
||||||
|
```bash
|
||||||
|
npm i
|
||||||
|
```
|
||||||
|
### Start Development Server
|
||||||
|
```bash
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
You should now have an active development server at `localhost:8080`, where you can make changes to the code and see the UI update in real time
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Messaging
|
### Messaging
|
||||||
@@ -167,6 +198,23 @@ The source code lives in `/root/code/cinny`. All changes should be made on the `
|
|||||||
|
|
||||||
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
|
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
Lotus Chat is a **pure client — there is no backend of its own to run.** It talks directly to a Matrix homeserver (Synapse) over HTTPS, so the only thing you run locally is the Vite dev server; it connects to a real homeserver for all data. If you were looking for "the backend to pair with it," there isn't one — that's the homeserver.
|
||||||
|
|
||||||
|
**Prerequisites:** Node 20+ (CI builds on Node 24) and npm.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci # deps; @lotusguild/* come from our Gitea npm registry (public read — no auth/token needed)
|
||||||
|
npm start # Vite dev server → http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already in use it falls through to 8081+, so check the "Local:" URL Vite prints on startup. If it boots but the page renders blank, it's almost always a failed module/asset resolution, not a "missing backend" — open the devtools console and read the first error.
|
||||||
|
|
||||||
|
**Which homeserver / logging in:** `config.json` sets `defaultHomeserver: 0` → `matrix.lotusguild.org`, so you sign in with your normal `@you:matrix.lotusguild.org` account. That homeserver is **live production** — anything you send is real, so keep test traffic to a DM with yourself or a throwaway room. To develop fully isolated instead, point `config.json` at a throwaway `matrix.org` account (already in `homeserverList`) or a local Synapse.
|
||||||
|
|
||||||
|
- **SSO / OIDC works from localhost.** Login goes through Authelia via OIDC dynamic registration; the provider redirects back to `http://localhost:8080/…` and the client registers that redirect on the fly, so no server-side allow-listing is needed. After the callback you may see a `GET …/_matrix/media/v1/thumbnail/… 404` — that's just a missing avatar thumbnail, **not** a login failure.
|
||||||
|
|
||||||
### 🔱 Element Call fork ("Lotus Call") — LIVE
|
### 🔱 Element Call fork ("Lotus Call") — LIVE
|
||||||
|
|
||||||
Voice/video channels embed **Element Call**, which is now our **self-built fork**
|
Voice/video channels embed **Element Call**, which is now our **self-built fork**
|
||||||
|
|||||||
@@ -731,8 +731,24 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
// When screenshare ends, release the spotlight we auto-enabled.
|
// When screenshare ends, release the spotlight we auto-enabled.
|
||||||
const pipAutoSpotlightRef = React.useRef(false);
|
const pipAutoSpotlightRef = React.useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pipMode || !callEmbed) return;
|
if (!callEmbed) {
|
||||||
if (pipScreenshare) {
|
// The embed (and its spotlight) is torn down with the call; drop the latch
|
||||||
|
// so a stale ref can't act on the next call's fresh embed.
|
||||||
|
pipAutoSpotlightRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Spotlight is wanted only while in pip with an active screenshare. Release
|
||||||
|
// it when EITHER ends — including leaving pip (returning to the call room).
|
||||||
|
// The release must not sit behind a `!pipMode` early-return, or a
|
||||||
|
// screenshare→pip→back sequence leaves the auto-enabled spotlight stuck on
|
||||||
|
// with pipAutoSpotlightRef latched true. The ref gates release so we only
|
||||||
|
// ever undo a spotlight we turned on (never one the user set).
|
||||||
|
// NB: `control.spotlight` is read below but deliberately NOT a dependency —
|
||||||
|
// this effect reacts to pip/screenshare *intent*, not to spotlight changes.
|
||||||
|
// Adding it as a dep would re-run on every manual spotlight toggle and fight
|
||||||
|
// the user.
|
||||||
|
const wantSpotlight = pipMode && pipScreenshare;
|
||||||
|
if (wantSpotlight) {
|
||||||
if (!callEmbed.control.spotlight) {
|
if (!callEmbed.control.spotlight) {
|
||||||
callEmbed.control.toggleSpotlight();
|
callEmbed.control.toggleSpotlight();
|
||||||
pipAutoSpotlightRef.current = true;
|
pipAutoSpotlightRef.current = true;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import { Box, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
import { Box, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { MobileTouchTarget } from '../styles/mobile.css';
|
||||||
|
|
||||||
type RecorderState = 'idle' | 'recording' | 'paused' | 'preview';
|
type RecorderState = 'idle' | 'recording' | 'paused' | 'preview';
|
||||||
|
|
||||||
@@ -239,6 +240,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
if (state === 'idle') {
|
if (state === 'idle') {
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={startRecording}
|
onClick={startRecording}
|
||||||
aria-label="Record voice message"
|
aria-label="Record voice message"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|||||||
@@ -194,22 +194,43 @@ export const createCommandElement = (command: string): CommandElement => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
|
export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
|
||||||
Transforms.select(editor, selectRange);
|
// Wrap the whole sequence: on a stale autocomplete range (the document changed
|
||||||
Transforms.insertNodes(editor, element);
|
// between the menu opening and the pick) `insertNodes` — not `select`, which is
|
||||||
Transforms.collapse(editor, {
|
// lazy in this Slate version — can throw. This runs inside the pick's event
|
||||||
edge: 'end',
|
// handler, so an escape wouldn't hit the error boundary, but keep it contained.
|
||||||
});
|
try {
|
||||||
|
Transforms.select(editor, selectRange);
|
||||||
|
Transforms.insertNodes(editor, element);
|
||||||
|
Transforms.collapse(editor, { edge: 'end' });
|
||||||
|
} catch {
|
||||||
|
/* stale range — the pick is a no-op rather than an uncaught error */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const moveCursor = (editor: Editor, withSpace?: boolean) => {
|
export const moveCursor = (editor: Editor, withSpace?: boolean) => {
|
||||||
// Defer to the next tick so React can flush any pending void-element DOM
|
// Move the caret out of the just-inserted inline void and land it in a real
|
||||||
// updates (e.g. after inserting a mention) before Slate resolves cursor
|
// trailing text node — SYNCHRONOUSLY, in the same commit as the insert.
|
||||||
// positions via ReactEditor.toDOMNode — otherwise Slate throws
|
// `Transforms.move` escapes the void (after insertNodes+collapse the caret is
|
||||||
// "Cannot resolve a DOM node from slate node".
|
// INSIDE the void's inner text node; insertText there is a no-op, blocked by
|
||||||
|
// Slate's void guard). The space then lands in a real text node.
|
||||||
|
// Doing this in the same commit (vs the old deferred setTimeout) means the
|
||||||
|
// caret never sits on the void's zero-width edge on a racy tick — that edge's
|
||||||
|
// DOM (a U+FEFF node) isn't populated yet, so slate-react's commit-phase
|
||||||
|
// selection sync (setBaseAndExtent) threw IndexSizeError mid-render and tripped
|
||||||
|
// the composer error boundary. Both ops are pure model transforms (no DOM
|
||||||
|
// resolution), so running them synchronously is safe.
|
||||||
|
Transforms.move(editor);
|
||||||
|
if (withSpace) editor.insertText(' ');
|
||||||
|
// Re-assert focus next tick (a pick usually keeps the editor focused). Guarded
|
||||||
|
// because ReactEditor.focus resolves the DOM; with the caret now in a real text
|
||||||
|
// node this is safe, but stay defensive against a mid-flight editor.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
ReactEditor.focus(editor);
|
try {
|
||||||
Transforms.move(editor);
|
ReactEditor.focus(editor);
|
||||||
if (withSpace) editor.insertText(' ');
|
} catch {
|
||||||
|
// The editor DOM can be mid-flight (autocomplete just closed / re-render
|
||||||
|
// landed). The element is already inserted, so skip the focus nudge.
|
||||||
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -685,7 +685,7 @@ export function MLocation({ content }: MLocationProps) {
|
|||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
size="400"
|
size="400"
|
||||||
href={`https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`}
|
href={`https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
|
|||||||
@@ -27,7 +27,14 @@ type PresenceBadgeProps = {
|
|||||||
};
|
};
|
||||||
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||||
const label = usePresenceLabel();
|
const label = usePresenceLabel();
|
||||||
const ariaLabel = status ? `${label[presence]} — ${status}` : label[presence];
|
// DND is encoded as unavailable + status_msg 'dnd'; render it red/"Do Not
|
||||||
|
// Disturb" to match PresenceRingAvatar and the settings picker (which both
|
||||||
|
// special-case 'dnd' → Critical) — the badge was the lone outlier showing a
|
||||||
|
// yellow "Idle". The 'dnd' sentinel isn't surfaced as a status line.
|
||||||
|
const isDnd = presence === Presence.Unavailable && status === 'dnd';
|
||||||
|
const displayLabel = isDnd ? 'Do Not Disturb' : label[presence];
|
||||||
|
const displayStatus = isDnd ? undefined : status;
|
||||||
|
const ariaLabel = displayStatus ? `${displayLabel} — ${displayStatus}` : displayLabel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
@@ -38,9 +45,9 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
|||||||
tooltip={
|
tooltip={
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
|
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
|
||||||
<Text size="L400">{label[presence]}</Text>
|
<Text size="L400">{displayLabel}</Text>
|
||||||
{status && <Text size="T200">•</Text>}
|
{displayStatus && <Text size="T200">•</Text>}
|
||||||
{status && <Text size="T200">{status}</Text>}
|
{displayStatus && <Text size="T200">{displayStatus}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
@@ -50,7 +57,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
size={size}
|
size={size}
|
||||||
variant={PresenceToColor[presence]}
|
variant={isDnd ? 'Critical' : PresenceToColor[presence]}
|
||||||
fill={presence === Presence.Offline ? 'Soft' : 'Solid'}
|
fill={presence === Presence.Offline ? 'Soft' : 'Solid'}
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||||
import { zIndices } from '../../styles/zIndex';
|
import { zIndices } from '../../styles/zIndex';
|
||||||
import { SeasonTheme } from './types';
|
import { SeasonTheme } from './types';
|
||||||
import { getActiveSeason } from './seasonSchedule';
|
import { resolveSeasonTheme } from './seasonSchedule';
|
||||||
import { HalloweenOverlay } from './themes/Halloween';
|
import { HalloweenOverlay } from './themes/Halloween';
|
||||||
import { ChristmasOverlay } from './themes/Christmas';
|
import { ChristmasOverlay } from './themes/Christmas';
|
||||||
import { NewYearOverlay } from './themes/NewYear';
|
import { NewYearOverlay } from './themes/NewYear';
|
||||||
@@ -96,13 +96,25 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
|
|||||||
export function SeasonalEffect() {
|
export function SeasonalEffect() {
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const reduced = useReducedMotion();
|
const reduced = useReducedMotion();
|
||||||
|
const override = settings.seasonalThemeOverride ?? 'auto';
|
||||||
|
|
||||||
const theme = useMemo<SeasonTheme | null>(() => {
|
// In auto mode, re-evaluate hourly so a long-lived session crosses a
|
||||||
const override = settings.seasonalThemeOverride ?? 'auto';
|
// season/holiday-window boundary (e.g. into a new day) without a reload —
|
||||||
if (override === 'off') return null;
|
// otherwise the active season is frozen at the value it had on mount.
|
||||||
if (override === 'auto') return getActiveSeason(new Date());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
return override as SeasonTheme;
|
useEffect(() => {
|
||||||
}, [settings.seasonalThemeOverride]);
|
if (override !== 'auto') return undefined;
|
||||||
|
// Refresh on entering auto too: `now` may be a stale mount-time value if we
|
||||||
|
// were previously in a pinned/off mode (the interval only runs while auto).
|
||||||
|
setNow(Date.now());
|
||||||
|
const id = window.setInterval(() => setNow(Date.now()), 60 * 60 * 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [override]);
|
||||||
|
|
||||||
|
const theme = useMemo<SeasonTheme | null>(
|
||||||
|
() => resolveSeasonTheme(override, now),
|
||||||
|
[override, now],
|
||||||
|
);
|
||||||
|
|
||||||
if (!theme) return null;
|
if (!theme) return null;
|
||||||
// Suppress seasonal overlay when a chat background is active — both running simultaneously
|
// Suppress seasonal overlay when a chat background is active — both running simultaneously
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import { getActiveSeason, SEASON_SCHEDULE, SEASON_DATE_RANGES } from './seasonSchedule';
|
import {
|
||||||
|
getActiveSeason,
|
||||||
|
resolveSeasonTheme,
|
||||||
|
SEASON_SCHEDULE,
|
||||||
|
SEASON_DATE_RANGES,
|
||||||
|
} from './seasonSchedule';
|
||||||
import { SeasonTheme } from './types';
|
import { SeasonTheme } from './types';
|
||||||
|
|
||||||
// Date(year, monthIndex0, day)
|
// Date(year, monthIndex0, day)
|
||||||
@@ -52,6 +57,27 @@ test('window boundaries are inclusive at both ends', () => {
|
|||||||
assert.equal(getActiveSeason(on(1, 16)), null); // Feb 16 just after
|
assert.equal(getActiveSeason(on(1, 16)), null); // Feb 16 just after
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveSeasonTheme: off → null, pinned → that theme, auto → active season', () => {
|
||||||
|
const halloweenTs = on(9, 20).getTime(); // Oct 20 → halloween season
|
||||||
|
const offSeasonTs = on(5, 15).getTime(); // Jun 15 → no season
|
||||||
|
// 'off' never renders, regardless of date.
|
||||||
|
assert.equal(resolveSeasonTheme('off', halloweenTs), null);
|
||||||
|
// A pinned theme renders regardless of date (even off-season).
|
||||||
|
assert.equal(resolveSeasonTheme('christmas', offSeasonTs), 'christmas');
|
||||||
|
// 'auto' tracks the active season for the given instant.
|
||||||
|
assert.equal(resolveSeasonTheme('auto', halloweenTs), 'halloween');
|
||||||
|
assert.equal(resolveSeasonTheme('auto', offSeasonTs), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveSeasonTheme: auto re-evaluates as `now` advances across a boundary', () => {
|
||||||
|
// The same 'auto' override yields different themes at different instants — this
|
||||||
|
// is what the SeasonalEffect ticker relies on (incl. the switch-into-auto case
|
||||||
|
// where `now` must be current, not a stale mount value).
|
||||||
|
assert.equal(resolveSeasonTheme('auto', on(9, 20).getTime()), 'halloween'); // Oct 20
|
||||||
|
assert.equal(resolveSeasonTheme('auto', on(11, 15).getTime()), 'christmas'); // Dec 15
|
||||||
|
assert.equal(resolveSeasonTheme('auto', on(6, 4).getTime()), null); // Jul 4
|
||||||
|
});
|
||||||
|
|
||||||
test('SEASON_DATE_RANGES has a label for every scheduled theme', () => {
|
test('SEASON_DATE_RANGES has a label for every scheduled theme', () => {
|
||||||
assert.equal(SEASON_SCHEDULE.length, 11);
|
assert.equal(SEASON_SCHEDULE.length, 11);
|
||||||
const themes = SEASON_SCHEDULE.map((e) => e.theme);
|
const themes = SEASON_SCHEDULE.map((e) => e.theme);
|
||||||
|
|||||||
@@ -93,3 +93,18 @@ export function getActiveSeason(now: Date): SeasonTheme | null {
|
|||||||
const day = now.getDate();
|
const day = now.getDate();
|
||||||
return SEASON_SCHEDULE.find((entry) => entry.matches(month, day))?.theme ?? null;
|
return SEASON_SCHEDULE.find((entry) => entry.matches(month, day))?.theme ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A seasonal-theme setting value: the active season, a pinned theme, or off. */
|
||||||
|
export type SeasonalOverride = SeasonTheme | 'auto' | 'off';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The theme to render for a `seasonalThemeOverride` at time `now` (epoch ms):
|
||||||
|
* 'off' → none, 'auto' → the active season for that instant, else the pinned
|
||||||
|
* theme. Kept pure (and unit-tested) so the decision is verifiable without
|
||||||
|
* mounting the React overlay.
|
||||||
|
*/
|
||||||
|
export function resolveSeasonTheme(override: SeasonalOverride, now: number): SeasonTheme | null {
|
||||||
|
if (override === 'off') return null;
|
||||||
|
if (override === 'auto') return getActiveSeason(new Date(now));
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
|||||||
@@ -549,6 +549,16 @@ export const BadgeTidal = style({
|
|||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const BadgeMixcloud = style({
|
||||||
|
backgroundColor: '#52aad8',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BadgeDeezer = style({
|
||||||
|
backgroundColor: '#a238ff',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Twitch LIVE badge
|
// Twitch LIVE badge
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -305,6 +305,32 @@ function isTenor(url: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Synapse's thumbnailer flattens animated images to a single still frame, so a
|
||||||
|
// GIF served from /thumbnail renders but never plays. Detect GIF previews so the
|
||||||
|
// card can point at /download (the original) instead.
|
||||||
|
function isGifPreview(url: string, prev: IPreviewUrlResponse): boolean {
|
||||||
|
if (prev['og:image:type'] === 'image/gif') return true;
|
||||||
|
try {
|
||||||
|
return new URL(url).pathname.toLowerCase().endsWith('.gif');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ceiling on the /download upgrade below: a self-hosted GIF can be hundreds of
|
||||||
|
// MB, and unlike a thumbnail it is served unscaled. Past the cap we keep the
|
||||||
|
// (frozen) thumbnail — the card still links out, so the GIF is one click away.
|
||||||
|
const GIF_ORIGINAL_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Should this preview's image be fetched whole (so it animates) rather than
|
||||||
|
// thumbnailed? Size is advisory: Synapse usually reports it, and when it's
|
||||||
|
// absent we prefer a working animation over a hypothetical huge file.
|
||||||
|
function shouldServeGifOriginal(url: string, prev: IPreviewUrlResponse): boolean {
|
||||||
|
if (!isGifPreview(url, prev)) return false;
|
||||||
|
const size = prev['matrix:image:size'];
|
||||||
|
return typeof size !== 'number' || size <= GIF_ORIGINAL_MAX_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
function getCardVariant(url: string): CardVariant {
|
function getCardVariant(url: string): CardVariant {
|
||||||
// NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled
|
// NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled
|
||||||
// upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the
|
// upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the
|
||||||
@@ -1077,6 +1103,8 @@ const EMBED_BADGE: Record<string, { label: string; class: string }> = {
|
|||||||
bluesky: { label: 'Bluesky', class: previewCss.BadgeBluesky },
|
bluesky: { label: 'Bluesky', class: previewCss.BadgeBluesky },
|
||||||
loom: { label: 'Loom', class: previewCss.BadgeLoom },
|
loom: { label: 'Loom', class: previewCss.BadgeLoom },
|
||||||
kick: { label: 'Kick', class: previewCss.BadgeKick },
|
kick: { label: 'Kick', class: previewCss.BadgeKick },
|
||||||
|
mixcloud: { label: 'Mixcloud', class: previewCss.BadgeMixcloud },
|
||||||
|
deezer: { label: 'Deezer', class: previewCss.BadgeDeezer },
|
||||||
};
|
};
|
||||||
|
|
||||||
// The homeserver preview for some sites (notably Reddit) comes back as a bot-check
|
// The homeserver preview for some sites (notably Reddit) comes back as a bot-check
|
||||||
@@ -2081,8 +2109,13 @@ function GifCard({
|
|||||||
const title = (prev['og:title'] as string | undefined) ?? '';
|
const title = (prev['og:title'] as string | undefined) ?? '';
|
||||||
const mxcImage = prev['og:image'] as string | undefined;
|
const mxcImage = prev['og:image'] as string | undefined;
|
||||||
|
|
||||||
|
// A GIF card exists to show a moving GIF, so request the original rather than
|
||||||
|
// a thumbnail — the thumbnail endpoint would return a frozen first frame.
|
||||||
|
// `loading="lazy"` below keeps it off the wire until it's near the viewport.
|
||||||
const thumbSrc = mxcImage
|
const thumbSrc = mxcImage
|
||||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
|
? shouldServeGifOriginal(url, prev)
|
||||||
|
? mxcUrlToHttp(mx, mxcImage, useAuthentication)
|
||||||
|
: mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// If there's no image, fall back to a generic-style layout
|
// If there's no image, fall back to a generic-style layout
|
||||||
@@ -2180,6 +2213,7 @@ function GenericCard({
|
|||||||
src={displayThumb}
|
src={displayThumb}
|
||||||
alt={prev['og:title']}
|
alt={prev['og:title']}
|
||||||
title={prev['og:title']}
|
title={prev['og:title']}
|
||||||
|
loading="lazy"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
|
onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
|
||||||
onClick={onOpenViewer}
|
onClick={onOpenViewer}
|
||||||
@@ -2349,16 +2383,11 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
// Generic fallback — skip empty cards
|
// Generic fallback — skip empty cards
|
||||||
if (!prev['og:title'] && !prev['og:description']) return null;
|
if (!prev['og:title'] && !prev['og:description']) return null;
|
||||||
|
|
||||||
const thumbUrl = mxcUrlToHttp(
|
|
||||||
mx,
|
|
||||||
prev['og:image'] || '',
|
|
||||||
useAuthentication,
|
|
||||||
256,
|
|
||||||
256,
|
|
||||||
'scale',
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
|
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
|
||||||
|
// Show the original for GIFs so they animate; thumbnailing freezes them.
|
||||||
|
const thumbUrl = shouldServeGifOriginal(url, prev)
|
||||||
|
? imgUrl
|
||||||
|
: mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication, 256, 256, 'scale', false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GenericCard
|
<GenericCard
|
||||||
|
|||||||
@@ -67,9 +67,11 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
// C-L6: the play() flow schedules a 30s safety timeout that clears playingKey;
|
// C-L6: the play() flow schedules a 30s safety timeout that clears playingKey;
|
||||||
// guard those setState calls against the component unmounting first.
|
// guard those setState calls against the component unmounting first.
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
|
const safetyTimerRef = useRef<number | undefined>(undefined);
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
|
if (safetyTimerRef.current !== undefined) window.clearTimeout(safetyTimerRef.current);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -96,7 +98,17 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
if (playingKey) return; // one at a time (fork also enforces this)
|
if (playingKey) return; // one at a time (fork also enforces this)
|
||||||
setPlayingKey(flat.key);
|
setPlayingKey(flat.key);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
// Per-play timer token: `done` clears its OWN timer by identity, so a
|
||||||
|
// stale done() from a prior clip can't disarm a newer clip's safety timer
|
||||||
|
// (which — since a rejected audio.play() fires neither ended nor error —
|
||||||
|
// is sometimes the only thing that unsticks the playingKey guard).
|
||||||
|
let myTimer: number | undefined;
|
||||||
const done = () => {
|
const done = () => {
|
||||||
|
if (myTimer !== undefined) {
|
||||||
|
window.clearTimeout(myTimer);
|
||||||
|
if (safetyTimerRef.current === myTimer) safetyTimerRef.current = undefined;
|
||||||
|
myTimer = undefined;
|
||||||
|
}
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
setPlayingKey((k) => (k === flat.key ? undefined : k));
|
setPlayingKey((k) => (k === flat.key ? undefined : k));
|
||||||
};
|
};
|
||||||
@@ -108,11 +120,12 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
if (audio) {
|
if (audio) {
|
||||||
audio.addEventListener('ended', done, { once: true });
|
audio.addEventListener('ended', done, { once: true });
|
||||||
audio.addEventListener('error', done, { once: true });
|
audio.addEventListener('error', done, { once: true });
|
||||||
|
// Safety: clear the guard even if the audio never signals end.
|
||||||
|
myTimer = window.setTimeout(done, 30_000);
|
||||||
|
safetyTimerRef.current = myTimer;
|
||||||
} else {
|
} else {
|
||||||
done();
|
done();
|
||||||
}
|
}
|
||||||
// Safety: clear the guard even if the audio never signals end.
|
|
||||||
window.setTimeout(done, 30_000);
|
|
||||||
} catch {
|
} catch {
|
||||||
setError('Could not play that clip.');
|
setError('Could not play that clip.');
|
||||||
done();
|
done();
|
||||||
|
|||||||
@@ -17,15 +17,29 @@ function useMediaPermissions(): MediaPermState {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!navigator.permissions) {
|
if (!navigator.permissions) {
|
||||||
setState('unknown');
|
setState('unknown');
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
let permStatus: PermissionStatus | undefined;
|
||||||
navigator.permissions
|
navigator.permissions
|
||||||
.query({ name: 'microphone' as unknown as PermissionDescriptor['name'] })
|
.query({ name: 'microphone' as unknown as PermissionDescriptor['name'] })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
permStatus = result;
|
||||||
setState(result.state as MediaPermState);
|
setState(result.state as MediaPermState);
|
||||||
result.onchange = () => setState(result.state as MediaPermState);
|
result.onchange = () => {
|
||||||
|
if (!cancelled) setState(result.state as MediaPermState);
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.catch(() => setState('unknown'));
|
.catch(() => {
|
||||||
|
if (!cancelled) setState('unknown');
|
||||||
|
});
|
||||||
|
// Detach the onchange handler on unmount so it can't setState afterward (and
|
||||||
|
// so the PermissionStatus doesn't retain the callback).
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (permStatus) permStatus.onchange = null;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
|
|||||||
@@ -92,6 +92,16 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
const evId = ev.getId();
|
const evId = ev.getId();
|
||||||
if (!evId || seen.has(evId)) continue;
|
if (!evId || seen.has(evId)) continue;
|
||||||
seen.add(evId);
|
seen.add(evId);
|
||||||
|
// Advance the raw pagination boundary for EVERY event (any type,
|
||||||
|
// decrypted or not) — getTs() is unencrypted metadata. Gating this on
|
||||||
|
// a decrypted m.room.message let undecryptable/non-message old events
|
||||||
|
// stall oldestRawTs, so the fromTs break never fired → over-paginate
|
||||||
|
// and a false "truncated".
|
||||||
|
const ts = ev.getTs();
|
||||||
|
// Require a positive ts: an event with a bogus 0/negative
|
||||||
|
// origin_server_ts must not collapse the boundary and trigger an early
|
||||||
|
// break (silent under-pagination in the export).
|
||||||
|
if (ts > 0 && ts < oldestRawTs) oldestRawTs = ts;
|
||||||
// Attempt decryption for events that haven't been decrypted yet
|
// Attempt decryption for events that haven't been decrypted yet
|
||||||
// (paginateEventTimeline may fetch events before the SDK decrypts them)
|
// (paginateEventTimeline may fetch events before the SDK decrypts them)
|
||||||
if (ev.isEncrypted() && !ev.getClearContent()) {
|
if (ev.isEncrypted() && !ev.getClearContent()) {
|
||||||
@@ -100,8 +110,6 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
}
|
}
|
||||||
if (ev.getType() !== EventType.RoomMessage) continue;
|
if (ev.getType() !== EventType.RoomMessage) continue;
|
||||||
if (ev.isDecryptionFailure()) continue;
|
if (ev.isDecryptionFailure()) continue;
|
||||||
const ts = ev.getTs();
|
|
||||||
if (ts < oldestRawTs) oldestRawTs = ts;
|
|
||||||
if (fromTs !== null && ts < fromTs) continue;
|
if (fromTs !== null && ts < fromTs) continue;
|
||||||
if (toTs !== null && ts > toTs) continue;
|
if (toTs !== null && ts > toTs) continue;
|
||||||
const content = ev.getContent();
|
const content = ev.getContent();
|
||||||
|
|||||||
@@ -230,7 +230,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
|
|
||||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||||
const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
|
const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
|
||||||
const touchTarget = mobileOrTablet() ? { minWidth: '44px', minHeight: '44px' } : undefined;
|
const isMobile = mobileOrTablet();
|
||||||
|
// On phones the composer's secondary action buttons (attach, GIF, poll,
|
||||||
|
// location, voice, formatting, schedule) collapse behind a "+" toggle so the
|
||||||
|
// input stays one compact row instead of wrapping into a tall stack. Emoji +
|
||||||
|
// Send remain inline. Desktop keeps everything inline (isMobile === false).
|
||||||
|
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
|
||||||
|
const touchTarget = isMobile ? { minWidth: '44px', minHeight: '44px' } : undefined;
|
||||||
const showFormat = composerToolbarButtons?.showFormat ?? true;
|
const showFormat = composerToolbarButtons?.showFormat ?? true;
|
||||||
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
||||||
const showSticker = composerToolbarButtons?.showSticker ?? true;
|
const showSticker = composerToolbarButtons?.showSticker ?? true;
|
||||||
@@ -876,6 +882,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile "+" overflow: the `after` builder stashes the collapsed secondary
|
||||||
|
// buttons here and the `bottom` slot renders them when the toggle is open.
|
||||||
|
// React evaluates JSX props in source order (before → after → bottom), so
|
||||||
|
// `after` assigns this before `bottom` reads it within the same render.
|
||||||
|
let composerOverflow: ReactNode = null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
{selectedFiles.length > 0 && (
|
{selectedFiles.length > 0 && (
|
||||||
@@ -1035,16 +1047,31 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
before={
|
before={
|
||||||
<IconButton
|
isMobile ? (
|
||||||
onClick={() => pickFile('*')}
|
<IconButton
|
||||||
aria-label="Attach file"
|
onClick={() => setMobileToolsOpen((open) => !open)}
|
||||||
variant="SurfaceVariant"
|
aria-label="More actions"
|
||||||
size="300"
|
aria-expanded={mobileToolsOpen}
|
||||||
radii="300"
|
aria-controls="composer-more-actions"
|
||||||
style={touchTarget}
|
variant="SurfaceVariant"
|
||||||
>
|
size="300"
|
||||||
<Icon src={Icons.PlusCircle} />
|
radii="300"
|
||||||
</IconButton>
|
style={touchTarget}
|
||||||
|
>
|
||||||
|
<Icon src={mobileToolsOpen ? Icons.Cross : Icons.Plus} />
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => pickFile('*')}
|
||||||
|
aria-label="Attach file"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
style={touchTarget}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.PlusCircle} />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
after={(() => {
|
after={(() => {
|
||||||
const formatButton = showFormat ? (
|
const formatButton = showFormat ? (
|
||||||
@@ -1306,9 +1333,37 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mobile: keep only emoji/sticker inline beside Send; the rest move
|
||||||
|
// into the "+" overflow row (rendered via `bottom`), led by the attach
|
||||||
|
// button that `before` gives up on mobile. Desktop renders all inline.
|
||||||
|
const emojiInline = orderedButtons.filter(
|
||||||
|
(node) => React.isValidElement(node) && node.key === 'showEmojiSticker',
|
||||||
|
);
|
||||||
|
const overflowButtons = orderedButtons.filter(
|
||||||
|
(node) => !(React.isValidElement(node) && node.key === 'showEmojiSticker'),
|
||||||
|
);
|
||||||
|
if (isMobile) {
|
||||||
|
composerOverflow = (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
key="showAttach"
|
||||||
|
onClick={() => pickFile('*')}
|
||||||
|
aria-label="Attach file"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
style={touchTarget}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.PlusCircle} />
|
||||||
|
</IconButton>
|
||||||
|
{overflowButtons}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{orderedButtons}
|
{isMobile ? emojiInline : orderedButtons}
|
||||||
{gifError && (
|
{gifError && (
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
@@ -1365,12 +1420,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
bottom={
|
bottom={
|
||||||
toolbar && (
|
<>
|
||||||
<div>
|
{isMobile && mobileToolsOpen && composerOverflow && (
|
||||||
<Line variant="SurfaceVariant" size="300" />
|
<div>
|
||||||
<Toolbar />
|
<Line variant="SurfaceVariant" size="300" />
|
||||||
</div>
|
<Box
|
||||||
)
|
id="composer-more-actions"
|
||||||
|
role="group"
|
||||||
|
aria-label="More actions"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
wrap="Wrap"
|
||||||
|
style={{ padding: config.space.S200 }}
|
||||||
|
>
|
||||||
|
{composerOverflow}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{toolbar && (
|
||||||
|
<div>
|
||||||
|
<Line variant="SurfaceVariant" size="300" />
|
||||||
|
<Toolbar />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
|
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useCallback, useMemo, useRef } from 'react';
|
import React, { useCallback, useMemo, useRef } from 'react';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import { Box, Text, config } from 'folds';
|
import { Box, Button, Text, config } from 'folds';
|
||||||
import { EventType } from 'matrix-js-sdk';
|
import { EventType } from 'matrix-js-sdk';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
|
import { Transforms } from 'slate';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
@@ -152,17 +153,38 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||||||
<>
|
<>
|
||||||
{canMessage && (
|
{canMessage && (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={
|
onReset={() => {
|
||||||
|
// The composer crash is a transient bad-selection render
|
||||||
|
// (e.g. after an autocomplete insert); the draft content is
|
||||||
|
// intact. Clear the selection so the remounted composer can
|
||||||
|
// render — the user clicks in to continue, no page refresh.
|
||||||
|
try {
|
||||||
|
Transforms.deselect(editor);
|
||||||
|
} catch {
|
||||||
|
/* editor already in a safe state */
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fallbackRender={({ resetErrorBoundary }) => (
|
||||||
<RoomInputPlaceholder
|
<RoomInputPlaceholder
|
||||||
|
role="alert"
|
||||||
style={{ padding: config.space.S200 }}
|
style={{ padding: config.space.S200 }}
|
||||||
|
direction="Column"
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
justifyContent="Center"
|
justifyContent="Center"
|
||||||
|
gap="200"
|
||||||
>
|
>
|
||||||
<Text align="Center">
|
<Text align="Center">The message composer hit a snag.</Text>
|
||||||
Message composer encountered an error. Try refreshing.
|
<Button
|
||||||
</Text>
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
onClick={resetErrorBoundary}
|
||||||
|
>
|
||||||
|
<Text size="B300">Reload composer</Text>
|
||||||
|
</Button>
|
||||||
</RoomInputPlaceholder>
|
</RoomInputPlaceholder>
|
||||||
}
|
)}
|
||||||
>
|
>
|
||||||
<RoomInput
|
<RoomInput
|
||||||
room={room}
|
room={room}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
|
|||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import LotusLogo from '../../../../../public/res/Lotus.png';
|
import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
|
||||||
import pkg from '../../../../../package.json';
|
import pkg from '../../../../../package.json';
|
||||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
|
||||||
|
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
|
||||||
|
|
||||||
type MSC1929Contact = {
|
type MSC1929Contact = {
|
||||||
matrix_id?: string;
|
matrix_id?: string;
|
||||||
email_address?: string;
|
email_address?: string;
|
||||||
@@ -42,9 +44,16 @@ function useServerSupport(): { support: MSC1929Support | null; loading: boolean
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const baseUrl = mx.getHomeserverUrl();
|
// MSC1929 support info is served from the MXID server-name host (like
|
||||||
|
// /.well-known/matrix/client), which on delegated/split-domain servers is
|
||||||
|
// NOT the client-API URL. Derive it from the user's domain.
|
||||||
|
const serverName = mx.getDomain();
|
||||||
|
if (!serverName) {
|
||||||
|
setLoading(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetch(`${baseUrl}/.well-known/matrix/support`, { signal: controller.signal })
|
fetch(`https://${serverName}/.well-known/matrix/support`, { signal: controller.signal })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import { Method } from 'matrix-js-sdk';
|
|||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import {
|
import { DECORATION_CATEGORIES, decorationUrl } from '../../lotus/avatarDecorations';
|
||||||
DECORATION_CATEGORIES,
|
|
||||||
DECORATION_CDN,
|
|
||||||
decorationUrl,
|
|
||||||
} from '../../lotus/avatarDecorations';
|
|
||||||
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
||||||
|
|
||||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||||
@@ -48,7 +44,7 @@ function DecorationPreviewCell({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${DECORATION_CDN}/${slug}.png`}
|
src={decorationUrl(slug)}
|
||||||
alt={name}
|
alt={name}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
@@ -73,11 +69,11 @@ export function ProfileDecoration() {
|
|||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Fetch the whole profile, not the `/{field}` sub-resource: an unset field
|
||||||
|
// 404s (a console error for anyone without a decoration). The full profile
|
||||||
|
// returns 200 with all fields incl. custom MSC4133 ones — read it out.
|
||||||
mx.http
|
mx.http
|
||||||
.authedRequest<Record<string, string>>(
|
.authedRequest<Record<string, string>>(Method.Get, `/profile/${encodeURIComponent(userId)}`)
|
||||||
Method.Get,
|
|
||||||
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
|
|
||||||
)
|
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||||
setCurrent(val);
|
setCurrent(val);
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
try {
|
try {
|
||||||
const ctx = new AudioContext({ sampleRate: sampleRateFor(model) });
|
const ctx = new AudioContext({ sampleRate: sampleRateFor(model) });
|
||||||
const stream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS(nativeNS));
|
const stream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS(nativeNS));
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
ctx.close().catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const source = ctx.createMediaStreamSource(stream);
|
const source = ctx.createMediaStreamSource(stream);
|
||||||
const inAnalyser = ctx.createAnalyser();
|
const inAnalyser = ctx.createAnalyser();
|
||||||
inAnalyser.fftSize = 1024;
|
inAnalyser.fftSize = 1024;
|
||||||
@@ -182,7 +187,12 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
timer: number;
|
timer: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const clipRef = useRef<AudioBuffer | null>(null);
|
const clipRef = useRef<AudioBuffer | null>(null);
|
||||||
const playRef = useRef<{ ctx: AudioContext; source: AudioBufferSourceNode } | null>(null);
|
const playRef = useRef<{
|
||||||
|
ctx: AudioContext;
|
||||||
|
source: AudioBufferSourceNode;
|
||||||
|
model: DenoiseNode | null;
|
||||||
|
gate: AudioWorkletNode | null;
|
||||||
|
} | null>(null);
|
||||||
const [recording, setRecording] = useState(false);
|
const [recording, setRecording] = useState(false);
|
||||||
const [recDb, setRecDb] = useState(-100);
|
const [recDb, setRecDb] = useState(-100);
|
||||||
const [hasClip, setHasClip] = useState(false);
|
const [hasClip, setHasClip] = useState(false);
|
||||||
@@ -208,6 +218,10 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
const startRecord = async () => {
|
const startRecord = async () => {
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia(RAW_CONSTRAINTS);
|
const stream = await navigator.mediaDevices.getUserMedia(RAW_CONSTRAINTS);
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
const source = ctx.createMediaStreamSource(stream);
|
const source = ctx.createMediaStreamSource(stream);
|
||||||
const analyser = ctx.createAnalyser();
|
const analyser = ctx.createAnalyser();
|
||||||
@@ -249,7 +263,15 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bumped whenever a playback starts or stops. An in-flight `play()` compares
|
||||||
|
// the generation it claimed against this after its awaits; if it no longer
|
||||||
|
// matches (a newer play, a Stop, or unmount happened during model load) it
|
||||||
|
// discards what it built instead of orphaning it — closes the rapid-click /
|
||||||
|
// unmount-during-load leak.
|
||||||
|
const playGenRef = useRef(0);
|
||||||
|
|
||||||
const stopPlayback = useCallback(() => {
|
const stopPlayback = useCallback(() => {
|
||||||
|
playGenRef.current += 1;
|
||||||
const p = playRef.current;
|
const p = playRef.current;
|
||||||
playRef.current = null;
|
playRef.current = null;
|
||||||
if (p) {
|
if (p) {
|
||||||
@@ -259,6 +281,15 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
} catch {
|
} catch {
|
||||||
/* noop */
|
/* noop */
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
// Mirror stopLive: dispose the model node (worker/WASM) + gate, else each
|
||||||
|
// A/B playback through a model leaks a DeepFilterNet/DTLN worker.
|
||||||
|
p.gate?.disconnect();
|
||||||
|
p.model?.dispose();
|
||||||
|
p.model?.node.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
p.ctx.close().catch(() => undefined);
|
p.ctx.close().catch(() => undefined);
|
||||||
}
|
}
|
||||||
setPlaying(null);
|
setPlaying(null);
|
||||||
@@ -268,37 +299,73 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
stopPlayback();
|
stopPlayback();
|
||||||
const clip = clipRef.current;
|
const clip = clipRef.current;
|
||||||
if (!clip) return;
|
if (!clip) return;
|
||||||
|
// Claim this generation AFTER stopPlayback's bump; a later play/stop/unmount
|
||||||
|
// moves it past `gen`, signalling us to discard what we built below.
|
||||||
|
const gen = playGenRef.current;
|
||||||
try {
|
try {
|
||||||
// bufferSource auto-resamples the 48 kHz clip to the context rate, so DTLN
|
// bufferSource auto-resamples the 48 kHz clip to the context rate, so DTLN
|
||||||
// gets the 16 kHz it needs while raw/RNNoise/Speex stay at 48 kHz.
|
// gets the 16 kHz it needs while raw/RNNoise/Speex stay at 48 kHz.
|
||||||
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
|
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
|
||||||
const source = ctx.createBufferSource();
|
const source = ctx.createBufferSource();
|
||||||
source.buffer = clip;
|
source.buffer = clip;
|
||||||
|
let playGate: AudioWorkletNode | null = null;
|
||||||
|
let playModelNode: DenoiseNode | null = null;
|
||||||
if (playModel) {
|
if (playModel) {
|
||||||
let head: AudioNode = source;
|
let head: AudioNode = source;
|
||||||
if (useGate) {
|
if (useGate) {
|
||||||
const gate = await buildGateNode(ctx, gateThreshold);
|
playGate = await buildGateNode(ctx, gateThreshold);
|
||||||
head.connect(gate);
|
head.connect(playGate);
|
||||||
head = gate;
|
head = playGate;
|
||||||
}
|
}
|
||||||
const denoise = await buildModelNode(ctx, playModel);
|
playModelNode = await buildModelNode(ctx, playModel);
|
||||||
head.connect(denoise.node);
|
head.connect(playModelNode.node);
|
||||||
denoise.node.connect(ctx.destination);
|
playModelNode.node.connect(ctx.destination);
|
||||||
} else {
|
} else {
|
||||||
source.connect(ctx.destination);
|
source.connect(ctx.destination);
|
||||||
}
|
}
|
||||||
|
// Superseded while the WASM/worklet loaded (another Play, a Stop, or the
|
||||||
|
// panel unmounted)? Tear down this now-orphaned graph instead of storing
|
||||||
|
// it — otherwise its worker/WASM + ctx would leak and its audio would play
|
||||||
|
// over the winner.
|
||||||
|
if (playGenRef.current !== gen || !mountedRef.current) {
|
||||||
|
try {
|
||||||
|
playGate?.disconnect();
|
||||||
|
playModelNode?.dispose();
|
||||||
|
playModelNode?.node.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
ctx.close().catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
source.onended = () => {
|
source.onended = () => {
|
||||||
if (playRef.current?.ctx === ctx) stopPlayback();
|
if (playRef.current?.ctx === ctx) stopPlayback();
|
||||||
};
|
};
|
||||||
playRef.current = { ctx, source };
|
playRef.current = { ctx, source, model: playModelNode, gate: playGate };
|
||||||
source.start();
|
source.start();
|
||||||
setPlaying(label);
|
setPlaying(label);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[denoise-tester] playback failed', e);
|
console.error('[denoise-tester] playback failed', e);
|
||||||
stopPlayback();
|
// Only tear down if we're still the current playback — a superseded
|
||||||
|
// invocation must not stop the winner that replaced it.
|
||||||
|
if (playGenRef.current === gen) stopPlayback();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Guards the async getUserMedia paths: if Settings closes while the mic
|
||||||
|
// permission prompt is open, the resolved stream/ctx would otherwise be
|
||||||
|
// created after the unmount cleanup already ran, leaking + setState-after-
|
||||||
|
// unmount. Own [] effect so it only flips on real unmount.
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
// Set on mount (not just cleared on unmount) so a setup→cleanup→setup
|
||||||
|
// remount of the same fiber (StrictMode/Activity) leaves it true.
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
stopLive();
|
stopLive();
|
||||||
|
|||||||
@@ -549,7 +549,11 @@ function Appearance() {
|
|||||||
value={seasonalThemeOverride ?? 'auto'}
|
value={seasonalThemeOverride ?? 'auto'}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setSeasonalThemeOverride(v);
|
setSeasonalThemeOverride(v);
|
||||||
if (v !== 'auto' && v !== 'off') setChatBackground('none');
|
// Any active seasonal mode (incl. "auto") is mutually exclusive
|
||||||
|
// with a chat background — else picking it is a silent no-op, since
|
||||||
|
// SeasonalEffect suppresses the overlay while a background is set.
|
||||||
|
// Only "off" leaves the background alone.
|
||||||
|
if (v !== 'off') setChatBackground('none');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
|
import React, {
|
||||||
|
ChangeEventHandler,
|
||||||
|
FormEventHandler,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
|
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
|
||||||
import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds';
|
import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds';
|
||||||
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||||
@@ -56,6 +63,13 @@ function RuleEnableToggle({ kind, pushRule }: RuleEnableToggleProps) {
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [enabled, setEnabled] = useState(pushRule.enabled !== false);
|
const [enabled, setEnabled] = useState(pushRule.enabled !== false);
|
||||||
|
|
||||||
|
// Re-sync when the rule changes externally (e.g. toggled on another device →
|
||||||
|
// account-data sync). The useState initializer only runs once, so without
|
||||||
|
// this the Switch would show a stale value.
|
||||||
|
useEffect(() => {
|
||||||
|
setEnabled(pushRule.enabled !== false);
|
||||||
|
}, [pushRule.enabled]);
|
||||||
|
|
||||||
const [toggleState, toggle] = useAsyncCallback(
|
const [toggleState, toggle] = useAsyncCallback(
|
||||||
useCallback(
|
useCallback(
|
||||||
async (value: boolean) => {
|
async (value: boolean) => {
|
||||||
|
|||||||
@@ -381,6 +381,11 @@ export function SystemNotification() {
|
|||||||
style={selectStyle}
|
style={selectStyle}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
{(!quietHoursStart || !quietHoursEnd) && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
Set both a start and end time — quiet hours stay inactive until both are filled in.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
|||||||
@@ -222,6 +222,15 @@ export function LotusToastContainer() {
|
|||||||
|
|
||||||
const toasts = useAtomValue(toastQueueAtom);
|
const toasts = useAtomValue(toastQueueAtom);
|
||||||
const isMobile = useScreenSize() === ScreenSize.Mobile;
|
const isMobile = useScreenSize() === ScreenSize.Mobile;
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// The newest toast is the last (bottom) child; if the stack ever overflows its
|
||||||
|
// max-height (many sticky action toasts), keep that newest one in view instead
|
||||||
|
// of leaving it scrolled below the fold.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = listRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [toasts.length]);
|
||||||
|
|
||||||
if (toasts.length === 0) return null;
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
@@ -237,10 +246,14 @@ export function LotusToastContainer() {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: config.space.S200,
|
gap: config.space.S200,
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
|
// Safety net beyond the queue cap: if many sticky action toasts pile up they
|
||||||
|
// scroll within a bounded height instead of covering the whole screen.
|
||||||
|
maxHeight: isMobile ? '70vh' : '80vh',
|
||||||
|
overflowY: 'auto',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle} aria-live="polite" aria-label="Notifications">
|
<div ref={listRef} style={containerStyle} aria-live="polite" aria-label="Notifications">
|
||||||
{toasts.map((toast) => (
|
{toasts.map((toast) => (
|
||||||
<ToastCard key={toast.id} toast={toast} />
|
<ToastCard key={toast.id} toast={toast} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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
|
// Transient-failure attempt counts (userId → n) so a flaky federated lookup
|
||||||
// can retry a couple of times, then gives up for the session.
|
// can retry a couple of times, then gives up for the session.
|
||||||
const failures = new Map<string, number>();
|
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(
|
function fetchDecoration(
|
||||||
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
|
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
|
||||||
@@ -66,6 +82,10 @@ function fetchDecoration(
|
|||||||
|
|
||||||
export function invalidateDecorationCache(userId: string): void {
|
export function invalidateDecorationCache(userId: string): void {
|
||||||
cache.delete(userId);
|
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 {
|
export function useAvatarDecoration(userId: string): string | null {
|
||||||
@@ -74,14 +94,22 @@ export function useAvatarDecoration(userId: string): string | null {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchDecoration(
|
const load = () => {
|
||||||
(method, path) => mx.http.authedRequest<Record<string, string>>(method, path),
|
fetchDecoration(
|
||||||
userId,
|
(method, path) => mx.http.authedRequest<Record<string, string>>(method, path),
|
||||||
).then((val) => {
|
userId,
|
||||||
if (!cancelled) setSlug(val);
|
).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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, [mx, userId]);
|
}, [mx, userId]);
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from '../../hooks/useClientConfig';
|
} from '../../hooks/useClientConfig';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
|
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 { ServerPicker } from './ServerPicker';
|
||||||
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
|
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
|
||||||
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
|
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
|
||||||
@@ -31,6 +31,8 @@ import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
|
|||||||
import { AuthServerProvider } from '../../hooks/useAuthServer';
|
import { AuthServerProvider } from '../../hooks/useAuthServer';
|
||||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||||
|
|
||||||
|
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
|
||||||
|
|
||||||
const currentAuthPath = (pathname: string): string => {
|
const currentAuthPath = (pathname: string): string => {
|
||||||
if (matchPath(LOGIN_PATH, pathname)) {
|
if (matchPath(LOGIN_PATH, pathname)) {
|
||||||
return LOGIN_PATH;
|
return LOGIN_PATH;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk';
|
import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk';
|
||||||
import LotusLogo from '../../../../../public/res/Lotus.png';
|
|
||||||
import { OIDC_CALLBACK_PATH } from '../../paths';
|
import { OIDC_CALLBACK_PATH } from '../../paths';
|
||||||
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
|
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
|
||||||
|
|
||||||
|
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Absolute URL the OIDC provider redirects back to after authorization.
|
* 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 { isSnoozeActive, notificationSnoozeUntilAtom } from '../../state/notificationSnooze';
|
||||||
import { isWithinTimeWindow } from '../../utils/timeWindow';
|
import { isWithinTimeWindow } from '../../utils/timeWindow';
|
||||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
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 NotificationSound from '../../../../public/sound/notification.ogg';
|
||||||
import InviteSound from '../../../../public/sound/invite.ogg';
|
import InviteSound from '../../../../public/sound/invite.ogg';
|
||||||
import { notificationPermission, setFavicon, showOsNotification } from '../../utils/dom';
|
import { notificationPermission, setFavicon, showOsNotification } from '../../utils/dom';
|
||||||
@@ -29,7 +26,13 @@ import { settingsAtom } from '../../state/settings';
|
|||||||
import { allInvitesAtom } from '../../state/room-list/inviteList';
|
import { allInvitesAtom } from '../../state/room-list/inviteList';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
|
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 { mDirectAtom } from '../../state/mDirectList';
|
||||||
import {
|
import {
|
||||||
getMemberName,
|
getMemberName,
|
||||||
@@ -67,8 +70,16 @@ import {
|
|||||||
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
||||||
} from '../../utils/threadNotifications';
|
} from '../../utils/threadNotifications';
|
||||||
|
|
||||||
|
const LogoSVG = withOriginBaseUrl(getOriginBaseUrl(), '/lotus.png');
|
||||||
|
const LogoUnreadSVG = withOriginBaseUrl(getOriginBaseUrl(), '/lotus-unread.png');
|
||||||
|
const LogoHighlightSVG = withOriginBaseUrl(getOriginBaseUrl(), '/lotus-highlight.png');
|
||||||
|
|
||||||
// Grace period after the initial sync settles before invite notifications arm, so
|
// 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.
|
// 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;
|
const INVITE_NOTIFY_ARM_DELAY_MS = 3000;
|
||||||
|
|
||||||
function SystemEmojiFeature() {
|
function SystemEmojiFeature() {
|
||||||
@@ -531,7 +542,7 @@ function MessageNotifications() {
|
|||||||
// thread path is already gated by shouldNotifyThreadReply, so it must NOT
|
// thread path is already gated by shouldNotifyThreadReply, so it must NOT
|
||||||
// re-gate on the room count — otherwise an explicit per-thread "All replies"
|
// re-gate on the room count — otherwise an explicit per-thread "All replies"
|
||||||
// override in a Mentions-only room is silently dropped.
|
// 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);
|
lastNotifiedEventRef.current.set(dedupeKey, eventId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
|
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
|
||||||
import { Page, PageHero, PageHeroSection } from '../../components/page';
|
import { Page, PageHero, PageHeroSection } from '../../components/page';
|
||||||
import LotusLogo from '../../../../public/res/Lotus.png';
|
import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
|
||||||
import pkg from '../../../../package.json';
|
import pkg from '../../../../package.json';
|
||||||
|
|
||||||
|
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
|
||||||
|
|
||||||
export function WelcomePage() {
|
export function WelcomePage() {
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
|||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { selectAtom } from 'jotai/utils';
|
import { selectAtom } from 'jotai/utils';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { Unread } from '../../../../types/matrix/room';
|
import {
|
||||||
import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort';
|
factoryRoomIdByActivity,
|
||||||
|
factoryRoomIdByAtoZ,
|
||||||
|
factoryRoomIdByUnread,
|
||||||
|
} from '../../../utils/sort';
|
||||||
import {
|
import {
|
||||||
NavButton,
|
NavButton,
|
||||||
NavCategory,
|
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 DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
||||||
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
||||||
const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority');
|
const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority');
|
||||||
@@ -331,7 +323,7 @@ export function Home() {
|
|||||||
} else if (homeRoomSort === 'alpha') {
|
} else if (homeRoomSort === 'alpha') {
|
||||||
comparator = factoryRoomIdByAtoZ(mx);
|
comparator = factoryRoomIdByAtoZ(mx);
|
||||||
} else if (homeRoomSort === 'unread') {
|
} else if (homeRoomSort === 'unread') {
|
||||||
comparator = factoryRoomIdByUnread(roomToUnread);
|
comparator = factoryRoomIdByUnread(roomToUnread, mx);
|
||||||
} else {
|
} else {
|
||||||
comparator = factoryRoomIdByActivity(mx);
|
comparator = factoryRoomIdByActivity(mx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
|
||||||
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
||||||
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
|
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
|
||||||
|
import { useAutoMarkVerificationRead } from './useAutoMarkVerificationRead';
|
||||||
|
|
||||||
export const useBindAtoms = (mx: MatrixClient) => {
|
export const useBindAtoms = (mx: MatrixClient) => {
|
||||||
useBindMDirectAtom(mx, mDirectAtom);
|
useBindMDirectAtom(mx, mDirectAtom);
|
||||||
@@ -16,6 +17,7 @@ export const useBindAtoms = (mx: MatrixClient) => {
|
|||||||
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
|
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
|
||||||
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
|
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
|
||||||
useBindMarkedUnreadAtom(mx, markedUnreadAtom);
|
useBindMarkedUnreadAtom(mx, markedUnreadAtom);
|
||||||
|
useAutoMarkVerificationRead(mx);
|
||||||
|
|
||||||
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
|||||||
unreadInfo: getUnreadInfo(
|
unreadInfo: getUnreadInfo(
|
||||||
room,
|
room,
|
||||||
getMutedThreads(threadNotificationsRef.current, room.roomId),
|
getMutedThreads(threadNotificationsRef.current, room.roomId),
|
||||||
|
mx,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -332,6 +333,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
|||||||
unreadInfo: getUnreadInfo(
|
unreadInfo: getUnreadInfo(
|
||||||
room,
|
room,
|
||||||
getMutedThreads(threadNotificationsRef.current, room.roomId),
|
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)
|
// (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id)
|
||||||
// through a jotai store and read back via toastQueueAtom's getter.
|
// through a jotai store and read back via toastQueueAtom's getter.
|
||||||
|
|
||||||
const makeToast = (id: string): ToastNotif => ({
|
const makeToast = (id: string, sticky?: boolean): ToastNotif => ({
|
||||||
id,
|
id,
|
||||||
displayName: `name-${id}`,
|
displayName: `name-${id}`,
|
||||||
body: `body-${id}`,
|
body: `body-${id}`,
|
||||||
roomName: `room-${id}`,
|
roomName: `room-${id}`,
|
||||||
roomId: `!${id}:server`,
|
roomId: `!${id}:server`,
|
||||||
|
...(sticky ? { sticky: true } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
test('starts empty', () => {
|
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', () => {
|
test('createDownloadToast: filename in body, no room navigation, unique ids', () => {
|
||||||
const a = createDownloadToast('photo.jpg');
|
const a = createDownloadToast('photo.jpg');
|
||||||
assert.equal(a.displayName, 'Downloaded');
|
assert.equal(a.displayName, 'Downloaded');
|
||||||
|
|||||||
+17
-1
@@ -46,12 +46,28 @@ export const createErrorToast = (
|
|||||||
|
|
||||||
const baseAtom = atom<ToastNotif[]>([]);
|
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
|
// Write-only setter used in ClientNonUIFeatures
|
||||||
export const toastQueueAtom = atom<ToastNotif[], [ToastNotif | null], void>(
|
export const toastQueueAtom = atom<ToastNotif[], [ToastNotif | null], void>(
|
||||||
(get) => get(baseAtom),
|
(get) => get(baseAtom),
|
||||||
(get, set, notif) => {
|
(get, set, notif) => {
|
||||||
if (notif === null) return; // no-op guard
|
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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -268,13 +268,26 @@ export const notificationPermission = (permission: NotificationPermission) => {
|
|||||||
* (with the provided `onClick`) when no service worker is available, preserving
|
* (with the provided `onClick`) when no service worker is available, preserving
|
||||||
* the previous behaviour.
|
* 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 (
|
export const showOsNotification = async (
|
||||||
title: string,
|
title: string,
|
||||||
options: NotificationOptions & { data?: { path?: string } },
|
options: NotificationOptions & { data?: { path?: string } },
|
||||||
onClick?: () => void,
|
onClick?: () => void,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
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;
|
const registration = await navigator.serviceWorker.ready;
|
||||||
if (registration && typeof registration.showNotification === 'function') {
|
if (registration && typeof registration.showNotification === 'function') {
|
||||||
await registration.showNotification(title, options);
|
await registration.showNotification(title, options);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import {
|
import {
|
||||||
EventTimeline,
|
EventTimeline,
|
||||||
JoinRule,
|
JoinRule,
|
||||||
|
MatrixClient,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
NotificationCountType,
|
NotificationCountType,
|
||||||
Room,
|
Room,
|
||||||
@@ -24,6 +25,10 @@ import {
|
|||||||
isMutedRule,
|
isMutedRule,
|
||||||
findMutedRule,
|
findMutedRule,
|
||||||
isNotificationEvent,
|
isNotificationEvent,
|
||||||
|
isVerificationFlowEvent,
|
||||||
|
unreadIsOnlyVerification,
|
||||||
|
readReceiptCoversTail,
|
||||||
|
roomHasUnreadThread,
|
||||||
roomHaveNotification,
|
roomHaveNotification,
|
||||||
getUnreadInfo,
|
getUnreadInfo,
|
||||||
getRoomIconSrc,
|
getRoomIconSrc,
|
||||||
@@ -410,6 +415,232 @@ test('getUnreadInfo uses highlight when it exceeds total', () => {
|
|||||||
assert.deepEqual(getUnreadInfo(room2), { roomId: '!r:y', highlight: 1, total: 7 });
|
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 = (
|
const mockRoomWithThreadCounts = (
|
||||||
total: number,
|
total: number,
|
||||||
highlight: number,
|
highlight: number,
|
||||||
|
|||||||
+121
-5
@@ -214,6 +214,11 @@ const NOTIFICATION_EVENT_TYPES = [
|
|||||||
'm.room.encrypted',
|
'm.room.encrypted',
|
||||||
'm.room.member',
|
'm.room.member',
|
||||||
'm.sticker',
|
'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
|
// 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
|
// 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;
|
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 => {
|
export const roomHaveNotification = (room: Room): boolean => {
|
||||||
const total = room.getUnreadNotificationCount(NotificationCountType.Total);
|
const total = room.getUnreadNotificationCount(NotificationCountType.Total);
|
||||||
const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
|
const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
|
||||||
@@ -263,7 +346,11 @@ export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
|
|||||||
return true;
|
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 total = room.getUnreadNotificationCount(NotificationCountType.Total);
|
||||||
let highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
|
let highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
|
||||||
|
|
||||||
@@ -280,10 +367,28 @@ export const getUnreadInfo = (room: Room, mutedThreads?: Set<string>): UnreadInf
|
|||||||
if (highlight < 0) highlight = 0;
|
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 {
|
return {
|
||||||
roomId: room.roomId,
|
roomId: room.roomId,
|
||||||
highlight,
|
highlight,
|
||||||
total: highlight > total ? highlight : total,
|
total: resolvedTotal,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -298,13 +403,24 @@ export const getUnreadInfos = (
|
|||||||
|
|
||||||
if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
|
if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
|
||||||
const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined;
|
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
|
// 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
|
// roomHaveNotification true (the server room total includes the muted
|
||||||
// thread's count), but getUnreadInfo subtracts it back to zero. Pushing it
|
// thread's count), but getUnreadInfo subtracts it back to zero. Pushing it
|
||||||
// would still light the nav row + pollute "unread only" filters. Keep 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.
|
// only if there's real unread (count > 0) or a genuine unread marker. The
|
||||||
if (info.total > 0 || info.highlight > 0 || roomHaveUnread(mx, room)) {
|
// 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);
|
unread.push(info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
computeCoverage,
|
computeCoverage,
|
||||||
|
evictCount,
|
||||||
mergeSearchResults,
|
mergeSearchResults,
|
||||||
putRows,
|
putRows,
|
||||||
queryRoom,
|
queryRoom,
|
||||||
@@ -57,6 +58,18 @@ test('mergeSearchResults: missing ts sorts as 0 (last)', () => {
|
|||||||
|
|
||||||
const row = (ts: number): Pick<SearchCacheRow, 'ts'> => ({ ts });
|
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', () => {
|
test('computeCoverage: derives oldest/newest from rows', () => {
|
||||||
const cov = computeCoverage('!r', [row(30), row(10), row(20)], 3);
|
const cov = computeCoverage('!r', [row(30), row(10), row(20)], 3);
|
||||||
assert.deepEqual(cov, { roomId: '!r', oldestTs: 10, newestTs: 30, count: 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 DB_VERSION = 1;
|
||||||
const MESSAGES_STORE = 'messages';
|
const MESSAGES_STORE = 'messages';
|
||||||
const COVERAGE_STORE = 'coverage';
|
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';
|
const ROOM_TS_INDEX = 'roomTs';
|
||||||
|
|
||||||
/** A single cached, decrypted message row. Keyed on `[roomId, eventId]`. */
|
/** 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);
|
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. */
|
/** Upsert message rows. No-op on empty input or when IDB is unavailable. */
|
||||||
export const putRows = async (rows: SearchCacheRow[]): Promise<void> => {
|
export const putRows = async (rows: SearchCacheRow[]): Promise<void> => {
|
||||||
if (rows.length === 0) return;
|
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 tx = db.transaction(MESSAGES_STORE, 'readwrite');
|
||||||
const store = tx.objectStore(MESSAGES_STORE);
|
const store = tx.objectStore(MESSAGES_STORE);
|
||||||
rows.forEach((row) => store.put(row));
|
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);
|
await awaitTx(tx);
|
||||||
} catch {
|
} catch {
|
||||||
// Cache write failures must never surface to the UI.
|
// Cache write failures must never surface to the UI.
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
factoryRoomIdByUnreadCount,
|
factoryRoomIdByUnreadCount,
|
||||||
factoryRoomIdByActivity,
|
factoryRoomIdByActivity,
|
||||||
factoryRoomIdByAtoZ,
|
factoryRoomIdByAtoZ,
|
||||||
|
factoryRoomIdByUnread,
|
||||||
} from './sort';
|
} from './sort';
|
||||||
|
import type { Unread } from '../../types/matrix/room';
|
||||||
|
|
||||||
test('byTsOldToNew sorts ascending by timestamp', () => {
|
test('byTsOldToNew sorts ascending by timestamp', () => {
|
||||||
assert.ok(byTsOldToNew(1, 2) < 0);
|
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']);
|
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 #', () => {
|
test('factoryRoomIdByAtoZ sorts case-insensitively and ignores leading #', () => {
|
||||||
const names: Record<string, string> = { a: 'Banana', b: 'apple', c: '#Cherry' };
|
const names: Record<string, string> = { a: 'Banana', b: 'apple', c: '#Cherry' };
|
||||||
const mx = {
|
const mx = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { MatrixClient } from 'matrix-js-sdk';
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { Unread } from '../../types/matrix/room';
|
||||||
|
|
||||||
export type SortFunc<T> = (a: T, b: T) => number;
|
export type SortFunc<T> = (a: T, b: T) => number;
|
||||||
|
|
||||||
@@ -42,6 +43,26 @@ export const factoryRoomIdByUnreadCount =
|
|||||||
return bT - aT;
|
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 byTsOldToNew: SortFunc<number> = (a, b) => a - b;
|
||||||
|
|
||||||
export const byOrderKey: SortFunc<string | undefined> = (a, b) => {
|
export const byOrderKey: SortFunc<string | undefined> = (a, b) => {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import {
|
|||||||
getBlueskyEmbed,
|
getBlueskyEmbed,
|
||||||
getLoomId,
|
getLoomId,
|
||||||
getKickChannel,
|
getKickChannel,
|
||||||
|
getMixcloudFeed,
|
||||||
|
getDeezerEmbed,
|
||||||
|
deezerEmbedHeight,
|
||||||
getSteamTarget,
|
getSteamTarget,
|
||||||
steamWidgetEmbedUrl,
|
steamWidgetEmbedUrl,
|
||||||
buildVideoEmbedUrl,
|
buildVideoEmbedUrl,
|
||||||
@@ -210,6 +213,58 @@ test('Apple Music: album vs single song height, embed host swap', () => {
|
|||||||
assert.equal(getAppleMusicEmbed('https://example.com/album/x/1'), null);
|
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', () => {
|
test('getSteamTarget: app / news / bundle / non-content', () => {
|
||||||
assert.deepEqual(getSteamTarget('https://store.steampowered.com/app/739630/Phasmophobia/'), {
|
assert.deepEqual(getSteamTarget('https://store.steampowered.com/app/739630/Phasmophobia/'), {
|
||||||
kind: 'app',
|
kind: 'app',
|
||||||
|
|||||||
@@ -555,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 ---------------------------------------------------
|
// --- Embed-URL builders ---------------------------------------------------
|
||||||
|
|
||||||
const enc = encodeURIComponent;
|
const enc = encodeURIComponent;
|
||||||
@@ -672,6 +756,26 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
|
|||||||
if (tidal)
|
if (tidal)
|
||||||
return { provider: 'tidal', kind: tidal.kind, embedUrl: tidal.embedUrl, height: tidal.height };
|
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);
|
const insta = getInstagramEmbed(url);
|
||||||
if (insta) return { provider: 'instagram', kind: 'rich', embedUrl: insta, height: 720 };
|
if (insta) return { provider: 'instagram', kind: 'rich', embedUrl: insta, height: 720 };
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -246,7 +246,7 @@ const vendorChunks = (id) => {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
appType: 'spa',
|
appType: 'spa',
|
||||||
publicDir: false,
|
publicDir: './public/res',
|
||||||
base: buildConfig.base,
|
base: buildConfig.base,
|
||||||
server: {
|
server: {
|
||||||
port: 8080,
|
port: 8080,
|
||||||
|
|||||||
Reference in New Issue
Block a user